import tenuki from 'tenuki'; import type { MoveRecord } from '$lib/types'; const { Game } = tenuki; export interface ScoreResult { black: number; white: number; winner: 'black' | 'white' | 'tie'; } /** * Calculate the score for a completed Go game using tenuki's scoring engine. * This replays all moves and uses territory scoring (default). * * Note: This provides an estimate - proper scoring requires marking dead stones, * which may need manual adjustment by players. */ export function calculateScore( moves: MoveRecord[], boardSize: number = 19, komi: number = 6.5, deadStones: string[] = [] ): ScoreResult { console.log('[calculateScore] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); // Validate boardSize if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { console.error('[calculateScore] Invalid boardSize:', boardSize); return { black: 0, white: 0, winner: 'tie', }; } // Create a new tenuki game instance (no DOM element needed for scoring) console.log('[calculateScore] Creating tenuki Game instance...'); const game = new Game({ boardSize, komi, scoring: 'territory', }); console.log('[calculateScore] Game instance created'); // Replay all moves console.log('[calculateScore] Replaying moves...'); for (let i = 0; i < moves.length; i++) { const move = moves[i]; // tenuki uses (y, x) coordinates, not (x, y) const success = game.playAt(move.y, move.x); if (!success) { console.warn(`[calculateScore] Failed to replay move ${i + 1} at (${move.x}, ${move.y})`); } } console.log('[calculateScore] All moves replayed'); // Extract current board state to calculate territory manually console.log('[calculateScore] Extracting board state for manual scoring...'); const boardState: Array> = []; let blackStones = 0; let whiteStones = 0; for (let y = 0; y < boardSize; y++) { const row: Array<'empty' | 'black' | 'white'> = []; for (let x = 0; x < boardSize; x++) { const intersection = game.intersectionAt(y, x); const value = intersection.value as 'empty' | 'black' | 'white'; row.push(value); if (value === 'black') blackStones++; if (value === 'white') whiteStones++; } boardState.push(row); } console.log('[calculateScore] Board state extracted. Black stones:', blackStones, 'White stones:', whiteStones); // Process dead stones - remove them from the board and count as captures for opponent let blackCaptures = 0; let whiteCaptures = 0; if (deadStones.length > 0) { console.log('[calculateScore] Processing', deadStones.length, 'dead stones...'); for (const notation of deadStones) { // Parse notation like "bA01" or "wT19" const color = notation[0] === 'b' ? 'black' : 'white'; const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { if (boardState[row][col] === color) { // Remove the stone from the board boardState[row][col] = 'empty'; // Count as capture for opponent if (color === 'black') { blackStones--; whiteCaptures++; } else { whiteStones--; blackCaptures++; } console.log(`[calculateScore] Removed dead ${color} stone at (${col}, ${row})`); } else { console.warn(`[calculateScore] Dead stone notation ${notation} doesn't match board state at (${col}, ${row})`); } } else { console.warn(`[calculateScore] Invalid dead stone notation: ${notation}`); } } console.log('[calculateScore] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); console.log('[calculateScore] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); } // Calculate territory using flood fill (same algorithm as calculateTerritory in board-svg.ts) console.log('[calculateScore] Calculating territory...'); const visited: Array> = Array.from( { length: boardSize }, () => Array.from({ length: boardSize }, () => false) ); let blackTerritory = 0; let whiteTerritory = 0; function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { const points: Array<[number, number]> = []; const stack: Array<[number, number]> = [[startY, startX]]; let touchesBlack = false; let touchesWhite = false; while (stack.length > 0) { const [y, x] = stack.pop()!; if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; if (visited[y][x]) continue; const cell = boardState[y][x]; if (cell === 'black') { touchesBlack = true; continue; } if (cell === 'white') { touchesWhite = true; continue; } // Empty intersection visited[y][x] = true; points.push([y, x]); // Add neighbors stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); } let owner: 'black' | 'white' | 'neutral'; if (touchesBlack && !touchesWhite) { owner = 'black'; } else if (touchesWhite && !touchesBlack) { owner = 'white'; } else { owner = 'neutral'; } return { points, owner }; } // Find all empty regions for (let y = 0; y < boardSize; y++) { for (let x = 0; x < boardSize; x++) { if (!visited[y][x] && boardState[y][x] === 'empty') { const { points, owner } = floodFill(y, x); if (owner === 'black') { blackTerritory += points.length; } else if (owner === 'white') { whiteTerritory += points.length; } } } } console.log('[calculateScore] Territory calculated. Black:', blackTerritory, 'White:', whiteTerritory); // Calculate final scores (territory + stones + captures) const blackScore = blackStones + blackTerritory + blackCaptures; const whiteScore = whiteStones + whiteTerritory + whiteCaptures + komi; console.log('[calculateScore] Final scores. Black:', blackScore, 'White:', whiteScore); // Determine winner let winner: 'black' | 'white' | 'tie'; if (blackScore > whiteScore) { winner = 'black'; } else if (whiteScore > blackScore) { winner = 'white'; } else { winner = 'tie'; } console.log('[calculateScore] Winner determined:', winner); return { black: blackScore, white: whiteScore, winner, }; } /** * Get a board state representation from moves for display or analysis. */ export function getBoardState( moves: MoveRecord[], boardSize: number = 19 ): Array> { // Validate boardSize if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { console.error('Invalid boardSize:', boardSize); return []; } const game = new Game({ boardSize, }); // Replay all moves for (const move of moves) { game.playAt(move.y, move.x); } // Build board state array const state: Array> = []; for (let y = 0; y < boardSize; y++) { const row: Array<'empty' | 'black' | 'white'> = []; for (let x = 0; x < boardSize; x++) { const intersection = game.intersectionAt(y, x); row.push(intersection.value as 'empty' | 'black' | 'white'); } state.push(row); } return state; } /** * Calculate liberties for all stones on the board. * Returns a 2D array where each position contains the liberty count for that stone's group. */ export function calculateLiberties( moves: MoveRecord[], boardSize: number = 19 ): Array> { // Validate boardSize if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { console.error('Invalid boardSize:', boardSize); return []; } const game = new Game({ boardSize, }); // Replay all moves for (const move of moves) { game.playAt(move.y, move.x); } // Get current board state const state: Array> = []; for (let y = 0; y < boardSize; y++) { const row: Array<'empty' | 'black' | 'white'> = []; for (let x = 0; x < boardSize; x++) { const intersection = game.intersectionAt(y, x); row.push(intersection.value as 'empty' | 'black' | 'white'); } state.push(row); } // Initialize liberty counts const liberties: Array> = Array(boardSize).fill(0).map(() => Array(boardSize).fill(0)); const visited: Array> = Array(boardSize).fill(false).map(() => Array(boardSize).fill(false)); // Helper to get adjacent coordinates const getAdjacent = (y: number, x: number): Array<[number, number]> => { const adj: Array<[number, number]> = []; if (y > 0) adj.push([y - 1, x]); if (y < boardSize - 1) adj.push([y + 1, x]); if (x > 0) adj.push([y, x - 1]); if (x < boardSize - 1) adj.push([y, x + 1]); return adj; }; // Find all stones in a group and their liberties using flood fill const findGroup = (startY: number, startX: number): { stones: Array<[number, number]>, libertyCount: number } => { const color = state[startY][startX]; if (color === 'empty') return { stones: [], libertyCount: 0 }; const stones: Array<[number, number]> = []; const libertySet = new Set(); const queue: Array<[number, number]> = [[startY, startX]]; const groupVisited = new Set(); while (queue.length > 0) { const [y, x] = queue.shift()!; const key = `${y},${x}`; if (groupVisited.has(key)) continue; groupVisited.add(key); stones.push([y, x]); for (const [ny, nx] of getAdjacent(y, x)) { const adjKey = `${ny},${nx}`; if (state[ny][nx] === 'empty') { libertySet.add(adjKey); } else if (state[ny][nx] === color && !groupVisited.has(adjKey)) { queue.push([ny, nx]); } } } return { stones, libertyCount: libertySet.size }; }; // Calculate liberties for each group for (let y = 0; y < boardSize; y++) { for (let x = 0; x < boardSize; x++) { if (state[y][x] !== 'empty' && !visited[y][x]) { const { stones, libertyCount } = findGroup(y, x); // Set the liberty count for all stones in this group for (const [sy, sx] of stones) { liberties[sy][sx] = libertyCount; visited[sy][sx] = true; } } } } return liberties; }