예제 소스
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,
};
}