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.
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}