예제 소스
장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개
game.ts
import {
EMPTY,
inBoard,
inPalace,
makePass,
materialValue,
opposite,
pieceEmpty,
pieceNameKo,
posEq,
posLabel,
pawnForward,
type Formation,
type GameResult,
type Move,
type Piece,
type PieceType,
type Pos,
type Side,
} from "./types";
const HAN_DIAG_A: Pos[] = [
{ file: 3, rank: 0 },
{ file: 4, rank: 1 },
{ file: 5, rank: 2 },
];
const HAN_DIAG_B: Pos[] = [
{ file: 5, rank: 0 },
{ file: 4, rank: 1 },
{ file: 3, rank: 2 },
];
const CHO_DIAG_A: Pos[] = [
{ file: 3, rank: 7 },
{ file: 4, rank: 8 },
{ file: 5, rank: 9 },
];
const CHO_DIAG_B: Pos[] = [
{ file: 5, rank: 7 },
{ file: 4, rank: 8 },
{ file: 3, rank: 9 },
];
function indexOf(p: Pos): number {
return p.rank * 9 + p.file;
}
function onLine(p: Pos, line: Pos[]): boolean {
return line.some((q) => posEq(p, q));
}
function clonePiece(p: Piece): Piece {
return { type: p.type, side: p.side };
}
function cloneMove(m: Move): Move {
return {
from: { ...m.from },
to: { ...m.to },
moving: clonePiece(m.moving),
captured: clonePiece(m.captured),
pass: m.pass,
};
}
/** 같은 국면이 이 횟수 이상이면 그 수는 금지 (3회부터 불가). */
const REPETITION_LIMIT = 3;
const PIECE_KEY: Record<PieceType, string> = {
none: ".",
king: "K",
advisor: "A",
chariot: "R",
cannon: "C",
horse: "H",
elephant: "E",
pawn: "P",
};
export class Game {
private squares: Piece[] = Array.from({ length: 90 }, () => ({ ...EMPTY }));
private sideToMove_: Side = "cho";
private result_: GameResult = "playing";
private choKing: Pos = { file: 4, rank: 8 };
private hanKing: Pos = { file: 4, rank: 1 };
private history_: Move[] = [];
private capturedCho: Piece[] = [];
private capturedHan: Piece[] = [];
private bikjangEnabled_ = true;
private consecutivePasses = 0;
/** 시작 국면 + 매 수 이후 국면 키 (보드 + 수 둘 차례). */
private positionKeys: string[] = [];
constructor() {
this.newGame("inner", "inner");
}
clone(): Game {
const g = new Game();
g.squares = this.squares.map(clonePiece);
g.sideToMove_ = this.sideToMove_;
g.result_ = this.result_;
g.choKing = { ...this.choKing };
g.hanKing = { ...this.hanKing };
g.history_ = this.history_.map(cloneMove);
g.capturedCho = this.capturedCho.map(clonePiece);
g.capturedHan = this.capturedHan.map(clonePiece);
g.bikjangEnabled_ = this.bikjangEnabled_;
g.consecutivePasses = this.consecutivePasses;
g.positionKeys = [...this.positionKeys];
return g;
}
private positionKey(): string {
let key = this.sideToMove_ === "cho" ? "c|" : "h|";
for (const p of this.squares) {
if (pieceEmpty(p)) {
key += ".";
continue;
}
key += (p.side === "cho" ? "c" : "h") + PIECE_KEY[p.type];
}
return key;
}
private repetitionCount(key: string): number {
let n = 0;
for (const k of this.positionKeys) {
if (k === key) n += 1;
}
return n;
}
/** 현재 국면이 이미 3회 이상이면 true (방금 둔 수가 반복수 위반). */
private isForbiddenRepetition(): boolean {
return this.repetitionCount(this.positionKey()) >= REPETITION_LIMIT;
}
at(p: Pos): Piece {
if (!inBoard(p)) return { ...EMPTY };
return this.squares[indexOf(p)]!;
}
private ref(p: Pos): Piece {
return this.squares[indexOf(p)]!;
}
sideToMove(): Side {
return this.sideToMove_;
}
result(): GameResult {
return this.result_;
}
bikjangEnabled(): boolean {
return this.bikjangEnabled_;
}
setBikjangEnabled(enabled: boolean): void {
this.bikjangEnabled_ = enabled;
}
history(): Move[] {
return this.history_;
}
lastMove(): Move | null {
return this.history_.length ? this.history_[this.history_.length - 1]! : null;
}
kingPos(side: Side): Pos {
return side === "cho" ? { ...this.choKing } : { ...this.hanKing };
}
capturedBy(side: Side): Piece[] {
return (side === "cho" ? this.capturedCho : this.capturedHan).map(clonePiece);
}
private clear(): void {
this.squares = Array.from({ length: 90 }, () => ({ ...EMPTY }));
this.history_ = [];
this.capturedCho = [];
this.capturedHan = [];
this.sideToMove_ = "cho";
this.result_ = "playing";
this.consecutivePasses = 0;
this.positionKeys = [];
}
private placeFormation(side: Side, formation: Formation): void {
const back = side === "han" ? 0 : 9;
const kingRank = side === "han" ? 1 : 8;
const cannonRank = side === "han" ? 2 : 7;
const pawnRank = side === "han" ? 3 : 6;
const place = (file: number, rank: number, type: PieceType) => {
this.squares[indexOf({ file, rank })] = { type, side };
};
place(0, back, "chariot");
place(8, back, "chariot");
place(3, back, "advisor");
place(5, back, "advisor");
place(4, kingRank, "king");
place(1, cannonRank, "cannon");
place(7, cannonRank, "cannon");
for (let file = 0; file < 9; file += 2) {
place(file, pawnRank, "pawn");
}
const setWing = (leftFile: number, elephant: boolean) => {
place(leftFile, back, elephant ? "elephant" : "horse");
};
switch (formation) {
case "inner":
setWing(1, false);
setWing(2, true);
setWing(6, true);
setWing(7, false);
break;
case "outer":
setWing(1, true);
setWing(2, false);
setWing(6, false);
setWing(7, true);
break;
case "left":
setWing(1, true);
setWing(2, false);
setWing(6, true);
setWing(7, false);
break;
case "right":
setWing(1, false);
setWing(2, true);
setWing(6, false);
setWing(7, true);
break;
}
}
newGame(cho: Formation, han: Formation): void {
this.clear();
this.placeFormation("han", han);
this.placeFormation("cho", cho);
this.recomputeKings();
this.positionKeys = [this.positionKey()];
}
private recomputeKings(): void {
this.choKing = { file: -1, rank: -1 };
this.hanKing = { file: -1, rank: -1 };
for (let rank = 0; rank < 10; rank++) {
for (let file = 0; file < 9; file++) {
const p = this.at({ file, rank });
if (p.type === "king") {
if (p.side === "cho") this.choKing = { file, rank };
else this.hanKing = { file, rank };
}
}
}
}
pieceScore(side: Side): number {
let score = 0;
for (const p of this.squares) {
if (!pieceEmpty(p) && p.side === side && p.type !== "king") {
score += materialValue(p.type);
}
}
if (side === "han") score += 15;
return score;
}
private onPalaceDiagonalStep(a: Pos, b: Pos): boolean {
const df = Math.abs(a.file - b.file);
const dr = Math.abs(a.rank - b.rank);
if (df !== 1 || dr !== 1) return false;
return (
(onLine(a, HAN_DIAG_A) && onLine(b, HAN_DIAG_A)) ||
(onLine(a, HAN_DIAG_B) && onLine(b, HAN_DIAG_B)) ||
(onLine(a, CHO_DIAG_A) && onLine(b, CHO_DIAG_A)) ||
(onLine(a, CHO_DIAG_B) && onLine(b, CHO_DIAG_B))
);
}
private pushMove(from: Pos, to: Pos, piece: Piece, out: Move[]): void {
if (!inBoard(to)) return;
const target = this.at(to);
if (!pieceEmpty(target) && target.side === piece.side) return;
out.push({
from: { ...from },
to: { ...to },
moving: clonePiece(piece),
captured: clonePiece(target),
pass: false,
});
}
private addKingAdvisorMoves(from: Pos, piece: Piece, out: Move[]): void {
const dfs = [-1, -1, -1, 0, 0, 1, 1, 1];
const drs = [-1, 0, 1, -1, 1, -1, 0, 1];
for (let i = 0; i < 8; i++) {
const to = { file: from.file + dfs[i]!, rank: from.rank + drs[i]! };
if (!inPalace(to, piece.side)) continue;
const ortho = (dfs[i] === 0) !== (drs[i] === 0);
if (!ortho && !this.onPalaceDiagonalStep(from, to)) continue;
this.pushMove(from, to, piece, out);
}
}
private addSliderOnLine(from: Pos, piece: Piece, line: Pos[], cannon: boolean, out: Move[]): void {
if (!onLine(from, line)) return;
let idx = 0;
for (let i = 0; i < 3; i++) {
if (posEq(line[i]!, from)) idx = i;
}
const scan = (step: number) => {
if (cannon) {
let jumped = false;
for (let i = idx + step; i >= 0 && i < 3; i += step) {
const to = line[i]!;
const target = this.at(to);
if (!jumped) {
if (!pieceEmpty(target)) {
if (target.type === "cannon") return;
jumped = true;
}
continue;
}
if (pieceEmpty(target)) {
this.pushMove(from, to, piece, out);
continue;
}
if (target.type !== "cannon") this.pushMove(from, to, piece, out);
return;
}
} else {
for (let i = idx + step; i >= 0 && i < 3; i += step) {
const to = line[i]!;
const target = this.at(to);
if (pieceEmpty(target)) {
this.pushMove(from, to, piece, out);
continue;
}
this.pushMove(from, to, piece, out);
return;
}
}
};
scan(-1);
scan(1);
}
private addChariotMoves(from: Pos, piece: Piece, out: Move[]): void {
const dfs = [1, -1, 0, 0];
const drs = [0, 0, 1, -1];
for (let d = 0; d < 4; d++) {
for (let step = 1; step < 10; step++) {
const to = { file: from.file + dfs[d]! * step, rank: from.rank + drs[d]! * step };
if (!inBoard(to)) break;
const target = this.at(to);
if (pieceEmpty(target)) {
this.pushMove(from, to, piece, out);
continue;
}
this.pushMove(from, to, piece, out);
break;
}
}
this.addSliderOnLine(from, piece, HAN_DIAG_A, false, out);
this.addSliderOnLine(from, piece, HAN_DIAG_B, false, out);
this.addSliderOnLine(from, piece, CHO_DIAG_A, false, out);
this.addSliderOnLine(from, piece, CHO_DIAG_B, false, out);
}
private addCannonMoves(from: Pos, piece: Piece, out: Move[]): void {
const dfs = [1, -1, 0, 0];
const drs = [0, 0, 1, -1];
for (let d = 0; d < 4; d++) {
let jumped = false;
for (let step = 1; step < 10; step++) {
const to = { file: from.file + dfs[d]! * step, rank: from.rank + drs[d]! * step };
if (!inBoard(to)) break;
const target = this.at(to);
if (!jumped) {
if (!pieceEmpty(target)) {
if (target.type === "cannon") break;
jumped = true;
}
continue;
}
if (pieceEmpty(target)) {
this.pushMove(from, to, piece, out);
continue;
}
if (target.type !== "cannon") this.pushMove(from, to, piece, out);
break;
}
}
this.addSliderOnLine(from, piece, HAN_DIAG_A, true, out);
this.addSliderOnLine(from, piece, HAN_DIAG_B, true, out);
this.addSliderOnLine(from, piece, CHO_DIAG_A, true, out);
this.addSliderOnLine(from, piece, CHO_DIAG_B, true, out);
}
private addHorseMoves(from: Pos, piece: Piece, out: Move[]): void {
const steps = [
{ df: 1, dr: 2, bf: 0, br: 1 },
{ df: 1, dr: -2, bf: 0, br: -1 },
{ df: -1, dr: 2, bf: 0, br: 1 },
{ df: -1, dr: -2, bf: 0, br: -1 },
{ df: 2, dr: 1, bf: 1, br: 0 },
{ df: 2, dr: -1, bf: 1, br: 0 },
{ df: -2, dr: 1, bf: -1, br: 0 },
{ df: -2, dr: -1, bf: -1, br: 0 },
];
for (const s of steps) {
const block = { file: from.file + s.bf, rank: from.rank + s.br };
if (!inBoard(block) || !pieceEmpty(this.at(block))) continue;
this.pushMove(from, { file: from.file + s.df, rank: from.rank + s.dr }, piece, out);
}
}
private addElephantMoves(from: Pos, piece: Piece, out: Move[]): void {
const steps = [
{ df: 2, dr: 3, b1f: 0, b1r: 1, b2f: 1, b2r: 2 },
{ df: 2, dr: -3, b1f: 0, b1r: -1, b2f: 1, b2r: -2 },
{ df: -2, dr: 3, b1f: 0, b1r: 1, b2f: -1, b2r: 2 },
{ df: -2, dr: -3, b1f: 0, b1r: -1, b2f: -1, b2r: -2 },
{ df: 3, dr: 2, b1f: 1, b1r: 0, b2f: 2, b2r: 1 },
{ df: 3, dr: -2, b1f: 1, b1r: 0, b2f: 2, b2r: -1 },
{ df: -3, dr: 2, b1f: -1, b1r: 0, b2f: -2, b2r: 1 },
{ df: -3, dr: -2, b1f: -1, b1r: 0, b2f: -2, b2r: -1 },
];
for (const s of steps) {
const b1 = { file: from.file + s.b1f, rank: from.rank + s.b1r };
const b2 = { file: from.file + s.b2f, rank: from.rank + s.b2r };
if (!inBoard(b1) || !pieceEmpty(this.at(b1)) || !inBoard(b2) || !pieceEmpty(this.at(b2))) {
continue;
}
this.pushMove(from, { file: from.file + s.df, rank: from.rank + s.dr }, piece, out);
}
}
private addPawnMoves(from: Pos, piece: Piece, out: Move[]): void {
const fwd = pawnForward(piece.side);
this.pushMove(from, { file: from.file, rank: from.rank + fwd }, piece, out);
this.pushMove(from, { file: from.file - 1, rank: from.rank }, piece, out);
this.pushMove(from, { file: from.file + 1, rank: from.rank }, piece, out);
const enemy = opposite(piece.side);
if (
inPalace(from, enemy) ||
inPalace({ file: from.file - 1, rank: from.rank + fwd }, enemy) ||
inPalace({ file: from.file + 1, rank: from.rank + fwd }, enemy)
) {
const left = { file: from.file - 1, rank: from.rank + fwd };
const right = { file: from.file + 1, rank: from.rank + fwd };
if (this.onPalaceDiagonalStep(from, left)) this.pushMove(from, left, piece, out);
if (this.onPalaceDiagonalStep(from, right)) this.pushMove(from, right, piece, out);
}
}
private addPieceMoves(from: Pos, piece: Piece, out: Move[]): void {
switch (piece.type) {
case "king":
case "advisor":
this.addKingAdvisorMoves(from, piece, out);
break;
case "chariot":
this.addChariotMoves(from, piece, out);
break;
case "cannon":
this.addCannonMoves(from, piece, out);
break;
case "horse":
this.addHorseMoves(from, piece, out);
break;
case "elephant":
this.addElephantMoves(from, piece, out);
break;
case "pawn":
this.addPawnMoves(from, piece, out);
break;
default:
break;
}
}
private pseudoMoves(side: Side): Move[] {
const out: Move[] = [];
for (let rank = 0; rank < 10; rank++) {
for (let file = 0; file < 9; file++) {
const from = { file, rank };
const piece = this.at(from);
if (!pieceEmpty(piece) && piece.side === side) {
this.addPieceMoves(from, piece, out);
}
}
}
return out;
}
private attacks(target: Pos, attacker: Side): boolean {
return this.pseudoMoves(attacker).some((m) => posEq(m.to, target));
}
isCheck(side: Side): boolean {
const king = this.kingPos(side);
if (!inBoard(king)) return true;
return this.attacks(king, opposite(side));
}
isBikjang(): boolean {
if (!inBoard(this.choKing) || !inBoard(this.hanKing)) return false;
if (this.choKing.file !== this.hanKing.file) return false;
const lo = Math.min(this.choKing.rank, this.hanKing.rank);
const hi = Math.max(this.choKing.rank, this.hanKing.rank);
for (let rank = lo + 1; rank < hi; rank++) {
if (!pieceEmpty(this.at({ file: this.choKing.file, rank }))) return false;
}
return true;
}
legalMoves(): Move[] {
if (this.result_ !== "playing") return [];
const legal: Move[] = [];
const probe = this.clone();
for (const m of this.pseudoMoves(this.sideToMove_)) {
probe.applyTrusted(m);
const safe = !probe.isCheck(this.sideToMove_) && !probe.isForbiddenRepetition();
probe.undo();
if (safe) legal.push(cloneMove(m));
}
return legal;
}
legalTargets(from: Pos): Pos[] {
const piece = this.at(from);
if (pieceEmpty(piece) || piece.side !== this.sideToMove_) return [];
return this.legalMoves()
.filter((m) => posEq(m.from, from))
.map((m) => ({ ...m.to }));
}
applyTrusted(move: Move): void {
if (move.pass) {
this.consecutivePasses += 1;
this.history_.push(cloneMove(move));
this.sideToMove_ = opposite(this.sideToMove_);
this.positionKeys.push(this.positionKey());
return;
}
this.consecutivePasses = 0;
this.squares[indexOf(move.from)] = { ...EMPTY };
this.squares[indexOf(move.to)] = clonePiece(move.moving);
if (!pieceEmpty(move.captured)) {
if (move.moving.side === "cho") this.capturedCho.push(clonePiece(move.captured));
else this.capturedHan.push(clonePiece(move.captured));
}
if (move.moving.type === "king") {
if (move.moving.side === "cho") this.choKing = { ...move.to };
else this.hanKing = { ...move.to };
}
if (move.captured.type === "king") {
if (move.captured.side === "cho") this.choKing = { file: -1, rank: -1 };
else this.hanKing = { file: -1, rank: -1 };
}
this.history_.push(cloneMove(move));
this.sideToMove_ = opposite(this.sideToMove_);
this.positionKeys.push(this.positionKey());
}
finishTurn(): void {
if (!inBoard(this.choKing)) {
this.result_ = "han_win";
return;
}
if (!inBoard(this.hanKing)) {
this.result_ = "cho_win";
return;
}
if (this.consecutivePasses >= 2) {
this.result_ = "draw";
return;
}
if (this.isCheck(this.sideToMove_) && this.legalMoves().length === 0) {
this.result_ = this.sideToMove_ === "cho" ? "han_win" : "cho_win";
}
}
tryMove(from: Pos, to: Pos): boolean {
if (this.result_ !== "playing") return false;
for (const m of this.legalMoves()) {
if (posEq(m.from, from) && posEq(m.to, to)) {
this.applyTrusted(m);
this.finishTurn();
return true;
}
}
return false;
}
passTurn(): boolean {
if (this.result_ !== "playing") return false;
if (this.isCheck(this.sideToMove_)) return false;
const probe = this.clone();
probe.applyTrusted(makePass());
if (probe.isForbiddenRepetition()) return false;
this.applyTrusted(makePass());
this.finishTurn();
return true;
}
resign(side: Side): boolean {
if (this.result_ !== "playing") return false;
this.result_ = side === "cho" ? "han_win" : "cho_win";
return true;
}
claimBikjang(): boolean {
if (this.result_ !== "playing" || !this.bikjangEnabled_) return false;
if (!this.isBikjang() || this.isCheck(this.sideToMove_)) return false;
this.result_ = "draw";
return true;
}
undo(): boolean {
if (this.history_.length === 0) return false;
if (this.result_ !== "playing") this.result_ = "playing";
const move = this.history_.pop()!;
if (this.positionKeys.length > 1) this.positionKeys.pop();
this.sideToMove_ = opposite(this.sideToMove_);
if (move.pass) {
this.consecutivePasses = 0;
if (this.history_.length && this.history_[this.history_.length - 1]!.pass) {
this.consecutivePasses = 1;
}
this.result_ = "playing";
return true;
}
this.squares[indexOf(move.to)] = clonePiece(move.captured);
this.squares[indexOf(move.from)] = clonePiece(move.moving);
if (!pieceEmpty(move.captured)) {
const bag = move.moving.side === "cho" ? this.capturedCho : this.capturedHan;
bag.pop();
}
this.recomputeKings();
this.consecutivePasses = 0;
if (this.history_.length && this.history_[this.history_.length - 1]!.pass) {
this.consecutivePasses = 1;
}
this.result_ = "playing";
return true;
}
moveText(move: Move): string {
if (move.pass) return "한수쉼";
let text = move.moving.side === "cho" ? "초 " : "한 ";
text += pieceNameKo(move.moving.type);
text += ` ${posLabel(move.from)} → ${posLabel(move.to)}`;
if (!pieceEmpty(move.captured)) {
text += ` (${pieceNameKo(move.captured.type)})`;
}
return text;
}
/** Snapshot for React / worker transfer */
snapshot(): GameSnapshot {
return {
squares: this.squares.map(clonePiece),
sideToMove: this.sideToMove_,
result: this.result_,
choKing: { ...this.choKing },
hanKing: { ...this.hanKing },
history: this.history_.map(cloneMove),
capturedCho: this.capturedCho.map(clonePiece),
capturedHan: this.capturedHan.map(clonePiece),
bikjangEnabled: this.bikjangEnabled_,
consecutivePasses: this.consecutivePasses,
positionKeys: [...this.positionKeys],
};
}
static fromSnapshot(s: GameSnapshot): Game {
const g = new Game();
g.squares = s.squares.map(clonePiece);
g.sideToMove_ = s.sideToMove;
g.result_ = s.result;
g.choKing = { ...s.choKing };
g.hanKing = { ...s.hanKing };
g.history_ = s.history.map(cloneMove);
g.capturedCho = s.capturedCho.map(clonePiece);
g.capturedHan = s.capturedHan.map(clonePiece);
g.bikjangEnabled_ = s.bikjangEnabled;
g.consecutivePasses = s.consecutivePasses;
g.positionKeys = s.positionKeys?.length ? [...s.positionKeys] : [g.positionKey()];
return g;
}
}
export type GameSnapshot = {
squares: Piece[];
sideToMove: Side;
result: GameResult;
choKing: Pos;
hanKing: Pos;
history: Move[];
capturedCho: Piece[];
capturedHan: Piece[];
bikjangEnabled: boolean;
consecutivePasses: number;
positionKeys: string[];
};