extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
at master 315 lines 9.1 kB view raw
1import type { MoveRecord } from './types'; 2 3export interface ScoreResult { 4 black: number; 5 white: number; 6 winner: 'black' | 'white' | 'tie'; 7} 8 9export interface TerritoryMap { 10 territory: Array<Array<'black' | 'white' | 'neutral' | null>>; 11 blackTerritory: number; 12 whiteTerritory: number; 13} 14 15/** 16 * Client-side scoring calculation that doesn't rely on tenuki to avoid hangs. 17 * Replays moves to build board state, then calculates territory using flood fill. 18 */ 19export function calculateScore( 20 moves: MoveRecord[], 21 boardSize: number = 19, 22 komi: number = 6.5, 23 deadStones: string[] = [] 24): ScoreResult { 25 console.log('[client-scoring] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); 26 27 // Build board state by replaying moves with capture logic 28 const boardState = buildBoardState(moves, boardSize); 29 30 // Count stones on the board 31 let blackStones = 0; 32 let whiteStones = 0; 33 34 for (let y = 0; y < boardSize; y++) { 35 for (let x = 0; x < boardSize; x++) { 36 if (boardState[y][x] === 'black') blackStones++; 37 if (boardState[y][x] === 'white') whiteStones++; 38 } 39 } 40 41 console.log('[client-scoring] Before dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 42 43 // Process dead stones - remove them from the board and count as captures for opponent 44 let blackCaptures = 0; 45 let whiteCaptures = 0; 46 47 if (deadStones.length > 0) { 48 console.log('[client-scoring] Processing', deadStones.length, 'dead stones...'); 49 for (const notation of deadStones) { 50 // Parse notation like "bA01" or "wT19" 51 const color = notation[0] === 'b' ? 'black' : 'white'; 52 const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 53 const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 54 55 if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { 56 if (boardState[row][col] === color) { 57 // Remove the stone from the board 58 boardState[row][col] = null; 59 60 // Count as capture for opponent 61 if (color === 'black') { 62 blackStones--; 63 whiteCaptures++; 64 } else { 65 whiteStones--; 66 blackCaptures++; 67 } 68 69 console.log(`[client-scoring] Removed dead ${color} stone at (${col}, ${row})`); 70 } 71 } 72 } 73 console.log('[client-scoring] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 74 console.log('[client-scoring] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); 75 } 76 77 // Calculate territory using flood fill 78 const territoryMap = calculateTerritory(boardState, boardSize); 79 80 console.log('[client-scoring] Territory calculated. Black:', territoryMap.blackTerritory, 'White:', territoryMap.whiteTerritory); 81 82 // Calculate final scores (territory + stones + captures) 83 const blackScore = blackStones + territoryMap.blackTerritory + blackCaptures; 84 const whiteScore = whiteStones + territoryMap.whiteTerritory + whiteCaptures + komi; 85 86 console.log('[client-scoring] Final scores. Black:', blackScore, 'White:', whiteScore); 87 88 // Determine winner 89 let winner: 'black' | 'white' | 'tie'; 90 if (blackScore > whiteScore) { 91 winner = 'black'; 92 } else if (whiteScore > blackScore) { 93 winner = 'white'; 94 } else { 95 winner = 'tie'; 96 } 97 98 console.log('[client-scoring] Winner determined:', winner); 99 100 return { 101 black: blackScore, 102 white: whiteScore, 103 winner, 104 }; 105} 106 107/** 108 * Build board state by replaying moves with basic capture logic. 109 * This is a simplified implementation that handles captures but not complex ko situations. 110 */ 111export function buildBoardState( 112 moves: MoveRecord[], 113 boardSize: number 114): Array<Array<'black' | 'white' | null>> { 115 const board: Array<Array<'black' | 'white' | null>> = Array.from( 116 { length: boardSize }, 117 () => Array.from({ length: boardSize }, () => null) 118 ); 119 120 for (const move of moves) { 121 const { x, y, color } = move; 122 123 if (x < 0 || x >= boardSize || y < 0 || y >= boardSize) continue; 124 if (board[y][x] !== null) continue; // Square already occupied 125 126 // Place the stone 127 board[y][x] = color; 128 129 // Check for captures of opponent stones 130 const opponent = color === 'black' ? 'white' : 'black'; 131 const neighbors = getNeighbors(x, y, boardSize); 132 133 for (const [nx, ny] of neighbors) { 134 if (board[ny][nx] === opponent) { 135 // Check if this opponent group has no liberties 136 if (getLiberties(board, nx, ny, boardSize).length === 0) { 137 // Capture the group 138 removeGroup(board, nx, ny, boardSize); 139 } 140 } 141 } 142 } 143 144 return board; 145} 146 147/** 148 * Get neighboring coordinates 149 */ 150function getNeighbors(x: number, y: number, boardSize: number): Array<[number, number]> { 151 const neighbors: Array<[number, number]> = []; 152 if (x > 0) neighbors.push([x - 1, y]); 153 if (x < boardSize - 1) neighbors.push([x + 1, y]); 154 if (y > 0) neighbors.push([x, y - 1]); 155 if (y < boardSize - 1) neighbors.push([x, y + 1]); 156 return neighbors; 157} 158 159/** 160 * Get all liberties (empty neighbors) of a group 161 */ 162function getLiberties( 163 board: Array<Array<'black' | 'white' | null>>, 164 x: number, 165 y: number, 166 boardSize: number 167): Array<[number, number]> { 168 const color = board[y][x]; 169 if (color === null) return []; 170 171 const liberties = new Set<string>(); 172 const visited = new Set<string>(); 173 const queue: Array<[number, number]> = [[x, y]]; 174 175 while (queue.length > 0) { 176 const [cx, cy] = queue.shift()!; 177 const key = `${cx},${cy}`; 178 179 if (visited.has(key)) continue; 180 visited.add(key); 181 182 for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { 183 const nkey = `${nx},${ny}`; 184 if (board[ny][nx] === null) { 185 liberties.add(nkey); 186 } else if (board[ny][nx] === color && !visited.has(nkey)) { 187 queue.push([nx, ny]); 188 } 189 } 190 } 191 192 return Array.from(liberties).map(key => { 193 const [x, y] = key.split(',').map(Number); 194 return [x, y] as [number, number]; 195 }); 196} 197 198/** 199 * Remove a captured group from the board 200 */ 201function removeGroup( 202 board: Array<Array<'black' | 'white' | null>>, 203 x: number, 204 y: number, 205 boardSize: number 206): void { 207 const color = board[y][x]; 208 if (color === null) return; 209 210 const visited = new Set<string>(); 211 const queue: Array<[number, number]> = [[x, y]]; 212 213 while (queue.length > 0) { 214 const [cx, cy] = queue.shift()!; 215 const key = `${cx},${cy}`; 216 217 if (visited.has(key)) continue; 218 visited.add(key); 219 220 board[cy][cx] = null; 221 222 for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { 223 const nkey = `${nx},${ny}`; 224 if (board[ny][nx] === color && !visited.has(nkey)) { 225 queue.push([nx, ny]); 226 } 227 } 228 } 229} 230 231/** 232 * Calculate territory for each empty intersection using flood fill. 233 * An empty region belongs to a player if it's completely surrounded by their stones. 234 */ 235export function calculateTerritory( 236 boardState: Array<Array<'black' | 'white' | null>>, 237 boardSize: number 238): TerritoryMap { 239 const territory: Array<Array<'black' | 'white' | 'neutral' | null>> = Array.from( 240 { length: boardSize }, 241 () => Array.from({ length: boardSize }, () => null) 242 ); 243 244 const visited: Array<Array<boolean>> = Array.from( 245 { length: boardSize }, 246 () => Array.from({ length: boardSize }, () => false) 247 ); 248 249 let blackTerritory = 0; 250 let whiteTerritory = 0; 251 252 // Flood fill to find connected empty regions 253 function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { 254 const points: Array<[number, number]> = []; 255 const stack: Array<[number, number]> = [[startY, startX]]; 256 let touchesBlack = false; 257 let touchesWhite = false; 258 259 while (stack.length > 0) { 260 const [y, x] = stack.pop()!; 261 262 if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; 263 if (visited[y][x]) continue; 264 265 const cell = boardState[y][x]; 266 267 if (cell === 'black') { 268 touchesBlack = true; 269 continue; 270 } 271 if (cell === 'white') { 272 touchesWhite = true; 273 continue; 274 } 275 276 // Empty intersection 277 visited[y][x] = true; 278 points.push([y, x]); 279 280 // Add neighbors 281 stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); 282 } 283 284 let owner: 'black' | 'white' | 'neutral'; 285 if (touchesBlack && !touchesWhite) { 286 owner = 'black'; 287 } else if (touchesWhite && !touchesBlack) { 288 owner = 'white'; 289 } else { 290 owner = 'neutral'; 291 } 292 293 return { points, owner }; 294 } 295 296 // Find all empty regions 297 for (let y = 0; y < boardSize; y++) { 298 for (let x = 0; x < boardSize; x++) { 299 if (!visited[y][x] && boardState[y][x] === null) { 300 const { points, owner } = floodFill(y, x); 301 for (const [py, px] of points) { 302 territory[py][px] = owner; 303 if (owner === 'black') blackTerritory++; 304 else if (owner === 'white') whiteTerritory++; 305 } 306 } 307 } 308 } 309 310 return { 311 territory, 312 blackTerritory, 313 whiteTerritory, 314 }; 315}