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 feature/study-tab 341 lines 11 kB view raw
1import tenuki from 'tenuki'; 2import type { MoveRecord } from '$lib/types'; 3 4const { Game } = tenuki; 5 6export interface ScoreResult { 7 black: number; 8 white: number; 9 winner: 'black' | 'white' | 'tie'; 10} 11 12/** 13 * Calculate the score for a completed Go game using tenuki's scoring engine. 14 * This replays all moves and uses territory scoring (default). 15 * 16 * Note: This provides an estimate - proper scoring requires marking dead stones, 17 * which may need manual adjustment by players. 18 */ 19export function calculateScore( 20 moves: MoveRecord[], 21 boardSize: number = 19, 22 komi: number = 6.5, 23 deadStones: string[] = [] 24): ScoreResult { 25 console.log('[calculateScore] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); 26 27 // Validate boardSize 28 if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 29 console.error('[calculateScore] Invalid boardSize:', boardSize); 30 return { 31 black: 0, 32 white: 0, 33 winner: 'tie', 34 }; 35 } 36 37 // Create a new tenuki game instance (no DOM element needed for scoring) 38 console.log('[calculateScore] Creating tenuki Game instance...'); 39 const game = new Game({ 40 boardSize, 41 komi, 42 scoring: 'territory', 43 }); 44 console.log('[calculateScore] Game instance created'); 45 46 // Replay all moves 47 console.log('[calculateScore] Replaying moves...'); 48 for (let i = 0; i < moves.length; i++) { 49 const move = moves[i]; 50 // tenuki uses (y, x) coordinates, not (x, y) 51 const success = game.playAt(move.y, move.x); 52 if (!success) { 53 console.warn(`[calculateScore] Failed to replay move ${i + 1} at (${move.x}, ${move.y})`); 54 } 55 } 56 console.log('[calculateScore] All moves replayed'); 57 58 // Extract current board state to calculate territory manually 59 console.log('[calculateScore] Extracting board state for manual scoring...'); 60 const boardState: Array<Array<'empty' | 'black' | 'white'>> = []; 61 let blackStones = 0; 62 let whiteStones = 0; 63 64 for (let y = 0; y < boardSize; y++) { 65 const row: Array<'empty' | 'black' | 'white'> = []; 66 for (let x = 0; x < boardSize; x++) { 67 const intersection = game.intersectionAt(y, x); 68 const value = intersection.value as 'empty' | 'black' | 'white'; 69 row.push(value); 70 if (value === 'black') blackStones++; 71 if (value === 'white') whiteStones++; 72 } 73 boardState.push(row); 74 } 75 76 console.log('[calculateScore] Board state extracted. Black stones:', blackStones, 'White stones:', whiteStones); 77 78 // Process dead stones - remove them from the board and count as captures for opponent 79 let blackCaptures = 0; 80 let whiteCaptures = 0; 81 82 if (deadStones.length > 0) { 83 console.log('[calculateScore] Processing', deadStones.length, 'dead stones...'); 84 for (const notation of deadStones) { 85 // Parse notation like "bA01" or "wT19" 86 const color = notation[0] === 'b' ? 'black' : 'white'; 87 const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 88 const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 89 90 if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { 91 if (boardState[row][col] === color) { 92 // Remove the stone from the board 93 boardState[row][col] = 'empty'; 94 95 // Count as capture for opponent 96 if (color === 'black') { 97 blackStones--; 98 whiteCaptures++; 99 } else { 100 whiteStones--; 101 blackCaptures++; 102 } 103 104 console.log(`[calculateScore] Removed dead ${color} stone at (${col}, ${row})`); 105 } else { 106 console.warn(`[calculateScore] Dead stone notation ${notation} doesn't match board state at (${col}, ${row})`); 107 } 108 } else { 109 console.warn(`[calculateScore] Invalid dead stone notation: ${notation}`); 110 } 111 } 112 console.log('[calculateScore] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 113 console.log('[calculateScore] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); 114 } 115 116 // Calculate territory using flood fill (same algorithm as calculateTerritory in board-svg.ts) 117 console.log('[calculateScore] Calculating territory...'); 118 const visited: Array<Array<boolean>> = Array.from( 119 { length: boardSize }, 120 () => Array.from({ length: boardSize }, () => false) 121 ); 122 123 let blackTerritory = 0; 124 let whiteTerritory = 0; 125 126 function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { 127 const points: Array<[number, number]> = []; 128 const stack: Array<[number, number]> = [[startY, startX]]; 129 let touchesBlack = false; 130 let touchesWhite = false; 131 132 while (stack.length > 0) { 133 const [y, x] = stack.pop()!; 134 135 if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; 136 if (visited[y][x]) continue; 137 138 const cell = boardState[y][x]; 139 140 if (cell === 'black') { 141 touchesBlack = true; 142 continue; 143 } 144 if (cell === 'white') { 145 touchesWhite = true; 146 continue; 147 } 148 149 // Empty intersection 150 visited[y][x] = true; 151 points.push([y, x]); 152 153 // Add neighbors 154 stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); 155 } 156 157 let owner: 'black' | 'white' | 'neutral'; 158 if (touchesBlack && !touchesWhite) { 159 owner = 'black'; 160 } else if (touchesWhite && !touchesBlack) { 161 owner = 'white'; 162 } else { 163 owner = 'neutral'; 164 } 165 166 return { points, owner }; 167 } 168 169 // Find all empty regions 170 for (let y = 0; y < boardSize; y++) { 171 for (let x = 0; x < boardSize; x++) { 172 if (!visited[y][x] && boardState[y][x] === 'empty') { 173 const { points, owner } = floodFill(y, x); 174 if (owner === 'black') { 175 blackTerritory += points.length; 176 } else if (owner === 'white') { 177 whiteTerritory += points.length; 178 } 179 } 180 } 181 } 182 183 console.log('[calculateScore] Territory calculated. Black:', blackTerritory, 'White:', whiteTerritory); 184 185 // Calculate final scores (territory + stones + captures) 186 const blackScore = blackStones + blackTerritory + blackCaptures; 187 const whiteScore = whiteStones + whiteTerritory + whiteCaptures + komi; 188 189 console.log('[calculateScore] Final scores. Black:', blackScore, 'White:', whiteScore); 190 191 // Determine winner 192 let winner: 'black' | 'white' | 'tie'; 193 if (blackScore > whiteScore) { 194 winner = 'black'; 195 } else if (whiteScore > blackScore) { 196 winner = 'white'; 197 } else { 198 winner = 'tie'; 199 } 200 201 console.log('[calculateScore] Winner determined:', winner); 202 203 return { 204 black: blackScore, 205 white: whiteScore, 206 winner, 207 }; 208} 209 210/** 211 * Get a board state representation from moves for display or analysis. 212 */ 213export function getBoardState( 214 moves: MoveRecord[], 215 boardSize: number = 19 216): Array<Array<'empty' | 'black' | 'white'>> { 217 // Validate boardSize 218 if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 219 console.error('Invalid boardSize:', boardSize); 220 return []; 221 } 222 223 const game = new Game({ 224 boardSize, 225 }); 226 227 // Replay all moves 228 for (const move of moves) { 229 game.playAt(move.y, move.x); 230 } 231 232 // Build board state array 233 const state: Array<Array<'empty' | 'black' | 'white'>> = []; 234 for (let y = 0; y < boardSize; y++) { 235 const row: Array<'empty' | 'black' | 'white'> = []; 236 for (let x = 0; x < boardSize; x++) { 237 const intersection = game.intersectionAt(y, x); 238 row.push(intersection.value as 'empty' | 'black' | 'white'); 239 } 240 state.push(row); 241 } 242 243 return state; 244} 245 246/** 247 * Calculate liberties for all stones on the board. 248 * Returns a 2D array where each position contains the liberty count for that stone's group. 249 */ 250export function calculateLiberties( 251 moves: MoveRecord[], 252 boardSize: number = 19 253): Array<Array<number>> { 254 // Validate boardSize 255 if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 256 console.error('Invalid boardSize:', boardSize); 257 return []; 258 } 259 260 const game = new Game({ 261 boardSize, 262 }); 263 264 // Replay all moves 265 for (const move of moves) { 266 game.playAt(move.y, move.x); 267 } 268 269 // Get current board state 270 const state: Array<Array<'empty' | 'black' | 'white'>> = []; 271 for (let y = 0; y < boardSize; y++) { 272 const row: Array<'empty' | 'black' | 'white'> = []; 273 for (let x = 0; x < boardSize; x++) { 274 const intersection = game.intersectionAt(y, x); 275 row.push(intersection.value as 'empty' | 'black' | 'white'); 276 } 277 state.push(row); 278 } 279 280 // Initialize liberty counts 281 const liberties: Array<Array<number>> = Array(boardSize).fill(0).map(() => Array(boardSize).fill(0)); 282 const visited: Array<Array<boolean>> = Array(boardSize).fill(false).map(() => Array(boardSize).fill(false)); 283 284 // Helper to get adjacent coordinates 285 const getAdjacent = (y: number, x: number): Array<[number, number]> => { 286 const adj: Array<[number, number]> = []; 287 if (y > 0) adj.push([y - 1, x]); 288 if (y < boardSize - 1) adj.push([y + 1, x]); 289 if (x > 0) adj.push([y, x - 1]); 290 if (x < boardSize - 1) adj.push([y, x + 1]); 291 return adj; 292 }; 293 294 // Find all stones in a group and their liberties using flood fill 295 const findGroup = (startY: number, startX: number): { stones: Array<[number, number]>, libertyCount: number } => { 296 const color = state[startY][startX]; 297 if (color === 'empty') return { stones: [], libertyCount: 0 }; 298 299 const stones: Array<[number, number]> = []; 300 const libertySet = new Set<string>(); 301 const queue: Array<[number, number]> = [[startY, startX]]; 302 const groupVisited = new Set<string>(); 303 304 while (queue.length > 0) { 305 const [y, x] = queue.shift()!; 306 const key = `${y},${x}`; 307 308 if (groupVisited.has(key)) continue; 309 groupVisited.add(key); 310 stones.push([y, x]); 311 312 for (const [ny, nx] of getAdjacent(y, x)) { 313 const adjKey = `${ny},${nx}`; 314 if (state[ny][nx] === 'empty') { 315 libertySet.add(adjKey); 316 } else if (state[ny][nx] === color && !groupVisited.has(adjKey)) { 317 queue.push([ny, nx]); 318 } 319 } 320 } 321 322 return { stones, libertyCount: libertySet.size }; 323 }; 324 325 // Calculate liberties for each group 326 for (let y = 0; y < boardSize; y++) { 327 for (let x = 0; x < boardSize; x++) { 328 if (state[y][x] !== 'empty' && !visited[y][x]) { 329 const { stones, libertyCount } = findGroup(y, x); 330 331 // Set the liberty count for all stones in this group 332 for (const [sy, sx] of stones) { 333 liberties[sy][sx] = libertyCount; 334 visited[sy][sx] = true; 335 } 336 } 337 } 338 } 339 340 return liberties; 341}