게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 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[];
};