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