import type { MoveRecord } from './types'; export interface ScoreResult { black: number; white: number; winner: 'black' | 'white' | 'tie'; } export interface TerritoryMap { territory: Array>; blackTerritory: number; whiteTerritory: number; } /** * Client-side scoring calculation that doesn't rely on tenuki to avoid hangs. * Replays moves to build board state, then calculates territory using flood fill. */ export function calculateScore( moves: MoveRecord[], boardSize: number = 19, komi: number = 6.5, deadStones: string[] = [] ): ScoreResult { console.log('[client-scoring] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); // Build board state by replaying moves with capture logic const boardState = buildBoardState(moves, boardSize); // Count stones on the board let blackStones = 0; let whiteStones = 0; for (let y = 0; y < boardSize; y++) { for (let x = 0; x < boardSize; x++) { if (boardState[y][x] === 'black') blackStones++; if (boardState[y][x] === 'white') whiteStones++; } } console.log('[client-scoring] Before dead stones - 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('[client-scoring] 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] = null; // Count as capture for opponent if (color === 'black') { blackStones--; whiteCaptures++; } else { whiteStones--; blackCaptures++; } console.log(`[client-scoring] Removed dead ${color} stone at (${col}, ${row})`); } } } console.log('[client-scoring] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); console.log('[client-scoring] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); } // Calculate territory using flood fill const territoryMap = calculateTerritory(boardState, boardSize); console.log('[client-scoring] Territory calculated. Black:', territoryMap.blackTerritory, 'White:', territoryMap.whiteTerritory); // Calculate final scores (territory + stones + captures) const blackScore = blackStones + territoryMap.blackTerritory + blackCaptures; const whiteScore = whiteStones + territoryMap.whiteTerritory + whiteCaptures + komi; console.log('[client-scoring] 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('[client-scoring] Winner determined:', winner); return { black: blackScore, white: whiteScore, winner, }; } /** * Build board state by replaying moves with basic capture logic. * This is a simplified implementation that handles captures but not complex ko situations. */ export function buildBoardState( moves: MoveRecord[], boardSize: number ): Array> { const board: Array> = Array.from( { length: boardSize }, () => Array.from({ length: boardSize }, () => null) ); for (const move of moves) { const { x, y, color } = move; if (x < 0 || x >= boardSize || y < 0 || y >= boardSize) continue; if (board[y][x] !== null) continue; // Square already occupied // Place the stone board[y][x] = color; // Check for captures of opponent stones const opponent = color === 'black' ? 'white' : 'black'; const neighbors = getNeighbors(x, y, boardSize); for (const [nx, ny] of neighbors) { if (board[ny][nx] === opponent) { // Check if this opponent group has no liberties if (getLiberties(board, nx, ny, boardSize).length === 0) { // Capture the group removeGroup(board, nx, ny, boardSize); } } } } return board; } /** * Get neighboring coordinates */ function getNeighbors(x: number, y: number, boardSize: number): Array<[number, number]> { const neighbors: Array<[number, number]> = []; if (x > 0) neighbors.push([x - 1, y]); if (x < boardSize - 1) neighbors.push([x + 1, y]); if (y > 0) neighbors.push([x, y - 1]); if (y < boardSize - 1) neighbors.push([x, y + 1]); return neighbors; } /** * Get all liberties (empty neighbors) of a group */ function getLiberties( board: Array>, x: number, y: number, boardSize: number ): Array<[number, number]> { const color = board[y][x]; if (color === null) return []; const liberties = new Set(); const visited = new Set(); const queue: Array<[number, number]> = [[x, y]]; while (queue.length > 0) { const [cx, cy] = queue.shift()!; const key = `${cx},${cy}`; if (visited.has(key)) continue; visited.add(key); for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { const nkey = `${nx},${ny}`; if (board[ny][nx] === null) { liberties.add(nkey); } else if (board[ny][nx] === color && !visited.has(nkey)) { queue.push([nx, ny]); } } } return Array.from(liberties).map(key => { const [x, y] = key.split(',').map(Number); return [x, y] as [number, number]; }); } /** * Remove a captured group from the board */ function removeGroup( board: Array>, x: number, y: number, boardSize: number ): void { const color = board[y][x]; if (color === null) return; const visited = new Set(); const queue: Array<[number, number]> = [[x, y]]; while (queue.length > 0) { const [cx, cy] = queue.shift()!; const key = `${cx},${cy}`; if (visited.has(key)) continue; visited.add(key); board[cy][cx] = null; for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { const nkey = `${nx},${ny}`; if (board[ny][nx] === color && !visited.has(nkey)) { queue.push([nx, ny]); } } } } /** * Calculate territory for each empty intersection using flood fill. * An empty region belongs to a player if it's completely surrounded by their stones. */ export function calculateTerritory( boardState: Array>, boardSize: number ): TerritoryMap { const territory: Array> = Array.from( { length: boardSize }, () => Array.from({ length: boardSize }, () => null) ); const visited: Array> = Array.from( { length: boardSize }, () => Array.from({ length: boardSize }, () => false) ); let blackTerritory = 0; let whiteTerritory = 0; // Flood fill to find connected empty regions 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] === null) { const { points, owner } = floodFill(y, x); for (const [py, px] of points) { territory[py][px] = owner; if (owner === 'black') blackTerritory++; else if (owner === 'white') whiteTerritory++; } } } } return { territory, blackTerritory, whiteTerritory, }; }