게시판SGF 기보로 학습하는 바둑 LLM 인공지능예제 소스

예제 소스

SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개

board.ts

/** Minimal 19×19 Go board (simple ko). Client-side legality for the prototype. */

export const BOARD_SIZE = 19;
export const EMPTY = 0;
export const BLACK = 1;
export const WHITE = 2;

export type Stone = 0 | 1 | 2;
export type Point = { r: number; c: number };

const DR = [-1, 1, 0, 0];
const DC = [0, 0, -1, 1];

export function emptyBoard(): Stone[] {
  return Array(BOARD_SIZE * BOARD_SIZE).fill(EMPTY) as Stone[];
}

export function idx(r: number, c: number): number {
  return r * BOARD_SIZE + c;
}

export function opponent(color: Stone): Stone {
  return color === BLACK ? WHITE : BLACK;
}

function inBounds(r: number, c: number): boolean {
  return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
}

function groupAndLibs(board: Stone[], r: number, c: number): { stones: number[]; libs: Set<number> } {
  const color = board[idx(r, c)];
  const stones: number[] = [];
  const libs = new Set<number>();
  const seen = new Set<number>();
  const stack = [idx(r, c)];
  seen.add(idx(r, c));
  while (stack.length) {
    const cur = stack.pop()!;
    stones.push(cur);
    const rr = Math.floor(cur / BOARD_SIZE);
    const cc = cur % BOARD_SIZE;
    for (let d = 0; d < 4; d++) {
      const nr = rr + DR[d];
      const nc = cc + DC[d];
      if (!inBounds(nr, nc)) continue;
      const n = idx(nr, nc);
      const v = board[n];
      if (v === EMPTY) libs.add(n);
      else if (v === color && !seen.has(n)) {
        seen.add(n);
        stack.push(n);
      }
    }
  }
  return { stones, libs };
}

export type PlayResult = {
  ok: boolean;
  reason?: string;
  board: Stone[];
  captured: number;
  koPoint: Point | null;
};

export function tryPlay(
  board: Stone[],
  r: number,
  c: number,
  who: Stone,
  koPoint: Point | null,
): PlayResult {
  if (!inBounds(r, c)) return { ok: false, reason: "out_of_bounds", board, captured: 0, koPoint };
  if (board[idx(r, c)] !== EMPTY) return { ok: false, reason: "occupied", board, captured: 0, koPoint };
  if (koPoint && koPoint.r === r && koPoint.c === c) {
    return { ok: false, reason: "ko", board, captured: 0, koPoint };
  }

  const next = board.slice() as Stone[];
  next[idx(r, c)] = who;
  const opp = opponent(who);
  let captured = 0;
  const capturedPts: number[] = [];
  const visited = new Set<number>();

  for (let d = 0; d < 4; d++) {
    const nr = r + DR[d];
    const nc = c + DC[d];
    if (!inBounds(nr, nc)) continue;
    const n = idx(nr, nc);
    if (next[n] !== opp || visited.has(n)) continue;
    const g = groupAndLibs(next, nr, nc);
    for (const s of g.stones) visited.add(s);
    if (g.libs.size === 0) {
      for (const s of g.stones) {
        next[s] = EMPTY;
        capturedPts.push(s);
        captured++;
      }
    }
  }

  const my = groupAndLibs(next, r, c);
  if (my.libs.size === 0) {
    return { ok: false, reason: "suicide", board, captured: 0, koPoint };
  }

  let newKo: Point | null = null;
  if (captured === 1 && my.stones.length === 1) {
    const p = capturedPts[0];
    newKo = { r: Math.floor(p / BOARD_SIZE), c: p % BOARD_SIZE };
  }

  return { ok: true, board: next, captured, koPoint: newKo };
}

/**
 * 방금 (r,c)에 둔 수로 단수(활로 1)가 된 상대 돌 수. 0이면 단수 아님.
 * board 는 착수(·따냄)가 반영된 판. 인접 상대 그룹만 보므로 이 수가 활로를 막은 경우만 센다.
 */
export function atariStonesAfter(board: Stone[], r: number, c: number, who: Stone): number {
  const opp = opponent(who);
  const seen = new Set<number>();
  let stones = 0;
  for (let d = 0; d < 4; d++) {
    const nr = r + DR[d];
    const nc = c + DC[d];
    if (!inBounds(nr, nc)) continue;
    const n = idx(nr, nc);
    if (board[n] !== opp || seen.has(n)) continue;
    const g = groupAndLibs(board, nr, nc);
    for (const s of g.stones) seen.add(s);
    if (g.libs.size === 1) stones += g.stones.length;
  }
  return stones;
}

export function coordLabel(r: number, c: number): string {
  const letters = "ABCDEFGHJKLMNOPQRST";
  return `${letters[c]}${BOARD_SIZE - r}`;
}

export function packBoard(board: Stone[]): number[] {
  return board.slice();
}

export type ReplayState = {
  board: Stone[];
  ko: Point | null;
  lastMove: Point | null;
  capB: number;
  capW: number;
  passStreak: number;
  toPlay: Stone;
};

/** 기보 0..upto 수까지 재현 (upto=0 이면 빈 판). */
export function replayMoves(
  moves: Array<{ color: 1 | 2; pass?: boolean; r?: number; c?: number }>,
  upto: number,
): ReplayState {
  let board = emptyBoard();
  let ko: Point | null = null;
  let lastMove: Point | null = null;
  let capB = 0;
  let capW = 0;
  let passStreak = 0;
  let toPlay: Stone = BLACK;
  const n = Math.max(0, Math.min(upto, moves.length));
  for (let i = 0; i < n; i++) {
    const m = moves[i];
    const who = m.color as Stone;
    if (m.pass) {
      passStreak += 1;
      lastMove = null;
    } else if (m.r != null && m.c != null) {
      const res = tryPlay(board, m.r, m.c, who, ko);
      if (res.ok) {
        board = res.board;
        ko = res.koPoint;
        lastMove = { r: m.r, c: m.c };
        if (who === BLACK) capB += res.captured;
        else capW += res.captured;
        passStreak = 0;
      }
    }
    toPlay = who === BLACK ? WHITE : BLACK;
  }
  return { board, ko, lastMove, capB, capW, passStreak, toPlay };
}

export type TerritoryScore = {
  blackTerritory: number;
  whiteTerritory: number;
  blackCaptures: number;
  whiteCaptures: number;
  komi: number;
  /** 계목법: 집 + 사석 (백은 +덤) */
  blackScore: number;
  whiteScore: number;
  winner: "black" | "white" | "draw";
  margin: number;
  dame: number;
  summary: string;
};

/**
 * 프로토타입용 한국식(계목) 어림 계가.
 * - 빈 영역을 flood-fill 하여 한쪽 색만 닿으면 집
 * - 양쪽 모두 닿으면 공배(빅/미정)
 * - 사활 자동판정은 없음(종국에 산 돌로 둘러싸인 집만 인정)
 */
export function scoreKoreanTerritory(
  board: Stone[],
  blackCaptures: number,
  whiteCaptures: number,
  komi: number = 6.5,
): TerritoryScore {
  const seen = new Uint8Array(BOARD_SIZE * BOARD_SIZE);
  let blackTerritory = 0;
  let whiteTerritory = 0;
  let dame = 0;

  for (let i = 0; i < board.length; i++) {
    if (board[i] !== EMPTY || seen[i]) continue;
    const stack = [i];
    seen[i] = 1;
    const region: number[] = [];
    const touch = new Set<Stone>();
    while (stack.length) {
      const cur = stack.pop()!;
      region.push(cur);
      const rr = Math.floor(cur / BOARD_SIZE);
      const cc = cur % BOARD_SIZE;
      for (let d = 0; d < 4; d++) {
        const nr = rr + DR[d];
        const nc = cc + DC[d];
        if (!inBounds(nr, nc)) continue;
        const n = idx(nr, nc);
        const v = board[n];
        if (v === EMPTY && !seen[n]) {
          seen[n] = 1;
          stack.push(n);
        } else if (v === BLACK || v === WHITE) {
          touch.add(v);
        }
      }
    }
    if (touch.size === 1 && touch.has(BLACK)) blackTerritory += region.length;
    else if (touch.size === 1 && touch.has(WHITE)) whiteTerritory += region.length;
    else dame += region.length;
  }

  const blackScore = blackTerritory + blackCaptures;
  const whiteScore = whiteTerritory + whiteCaptures + komi;
  const diff = blackScore - whiteScore;
  let winner: TerritoryScore["winner"] = "draw";
  if (diff > 0) winner = "black";
  else if (diff < 0) winner = "white";
  const margin = Math.abs(diff);
  const who =
    winner === "black" ? "흑" : winner === "white" ? "백" : "무승부";
  const summary =
    winner === "draw"
      ? `계가 결과 무승부 (흑 ${blackScore} · 백 ${whiteScore})`
      : `계가 결과 ${who} ${margin}집 승\n흑 ${blackTerritory}집+사석${blackCaptures}=${blackScore} · 백 ${whiteTerritory}집+사석${whiteCaptures}+덤${komi}=${whiteScore}`;

  return {
    blackTerritory,
    whiteTerritory,
    blackCaptures,
    whiteCaptures,
    komi,
    blackScore,
    whiteScore,
    winner,
    margin,
    dame,
    summary,
  };
}