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.

Add automatic score calculation using tenuki scoring engine

- Created scoring utility that uses tenuki to calculate territory scores
- Added GET endpoint to /api/games/[id]/score for fetching suggested scores
- UI now shows "Calculate & Enter Scores" button that fetches auto-calculated scores
- Scores are pre-populated in the form with auto-calculated values
- Shows "Auto-calculated" badge and winner/margin info
- Users can still adjust scores before submitting (for dead stones, etc.)
- Default komi of 6.5 points for white

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+270 -38
+1 -1
TODOS.md
··· 3 3 - [x] allow viewing all reactions at once rather than just for a given move 4 4 - [x] add url parameters to navigate to a specific move URI in a game rather than the current move. 5 5 - [ ] swap board view from to more scalable and robust tenuki library https://github.com/aprescott/tenuki?tab=readme-ov-file, which also includes a scoring engine 6 - - [ ] fix the scoring logic upon completion of a game to use tenuki's scoring engine and pre-populate scores based on these, still allow changes if the owner disagrees but suggesting a score makes it much easier than counting manually. 6 + - [x] fix the scoring logic upon completion of a game to use tenuki's scoring engine and pre-populate scores based on these, still allow changes if the owner disagrees but suggesting a score makes it much easier than counting manually.
+96
src/lib/server/scoring.ts
··· 1 + import tenuki from 'tenuki'; 2 + import type { MoveRecord } from '$lib/types'; 3 + 4 + const { Game } = tenuki; 5 + 6 + export 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 + */ 19 + export function calculateScore( 20 + moves: MoveRecord[], 21 + boardSize: number = 19, 22 + komi: number = 6.5 23 + ): ScoreResult { 24 + // Create a new tenuki game instance (no DOM element needed for scoring) 25 + const game = new Game({ 26 + boardSize, 27 + komi, 28 + scoring: 'territory', 29 + }); 30 + 31 + // Replay all moves 32 + for (const move of moves) { 33 + // tenuki uses (y, x) coordinates, not (x, y) 34 + const success = game.playAt(move.y, move.x); 35 + if (!success) { 36 + console.warn(`Failed to replay move at (${move.x}, ${move.y})`); 37 + } 38 + } 39 + 40 + // Pass twice to end the game so we can score 41 + // (the game should already be in "game over" state after double-pass, 42 + // but if we're scoring mid-game for preview, we need this) 43 + if (!game.isOver()) { 44 + game.pass(); 45 + game.pass(); 46 + } 47 + 48 + // Get the score from tenuki 49 + const score = game.score(); 50 + 51 + // Determine winner 52 + let winner: 'black' | 'white' | 'tie'; 53 + if (score.black > score.white) { 54 + winner = 'black'; 55 + } else if (score.white > score.black) { 56 + winner = 'white'; 57 + } else { 58 + winner = 'tie'; 59 + } 60 + 61 + return { 62 + black: score.black, 63 + white: score.white, 64 + winner, 65 + }; 66 + } 67 + 68 + /** 69 + * Get a board state representation from moves for display or analysis. 70 + */ 71 + export function getBoardState( 72 + moves: MoveRecord[], 73 + boardSize: number = 19 74 + ): Array<Array<'empty' | 'black' | 'white'>> { 75 + const game = new Game({ 76 + boardSize, 77 + }); 78 + 79 + // Replay all moves 80 + for (const move of moves) { 81 + game.playAt(move.y, move.x); 82 + } 83 + 84 + // Build board state array 85 + const state: Array<Array<'empty' | 'black' | 'white'>> = []; 86 + for (let y = 0; y < boardSize; y++) { 87 + const row: Array<'empty' | 'black' | 'white'> = []; 88 + for (let x = 0; x < boardSize; x++) { 89 + const intersection = game.intersectionAt(y, x); 90 + row.push(intersection.value as 'empty' | 'black' | 'white'); 91 + } 92 + state.push(row); 93 + } 94 + 95 + return state; 96 + }
+58
src/routes/api/games/[id]/score/+server.ts
··· 2 2 import type { RequestHandler } from './$types'; 3 3 import { getSession, getAgent } from '$lib/server/auth'; 4 4 import { getDb } from '$lib/server/db'; 5 + import { calculateScore } from '$lib/server/scoring'; 6 + import { fetchGameActionsFromPds } from '$lib/atproto-client'; 7 + 8 + /** 9 + * GET: Calculate suggested scores using tenuki's scoring engine 10 + */ 11 + export const GET: RequestHandler = async (event) => { 12 + const { params } = event; 13 + const { id: rkey } = params; 14 + 15 + try { 16 + const db = getDb(); 17 + 18 + const game = await db 19 + .selectFrom('games') 20 + .selectAll() 21 + .where('rkey', '=', rkey) 22 + .executeTakeFirst(); 23 + 24 + if (!game) { 25 + throw error(404, 'Game not found'); 26 + } 27 + 28 + // Fetch moves from PDS 29 + const { moves } = await fetchGameActionsFromPds( 30 + game.player_one, 31 + game.player_two, 32 + game.id // AT URI 33 + ); 34 + 35 + if (moves.length === 0) { 36 + return json({ 37 + suggested: null, 38 + message: 'No moves to score', 39 + }); 40 + } 41 + 42 + // Calculate score using tenuki 43 + // Default komi of 6.5 for territory scoring 44 + const score = calculateScore(moves, game.board_size, 6.5); 45 + 46 + return json({ 47 + suggested: { 48 + blackScore: Math.round(score.black), 49 + whiteScore: Math.round(score.white), 50 + winner: score.winner, 51 + }, 52 + message: 'Scores calculated automatically. Please verify and adjust if needed (e.g., for dead stones).', 53 + }); 54 + } catch (err) { 55 + console.error('Failed to calculate suggested scores:', err); 56 + // Return null suggestion on error rather than failing 57 + return json({ 58 + suggested: null, 59 + message: 'Could not calculate scores automatically', 60 + }); 61 + } 62 + }; 5 63 6 64 export const POST: RequestHandler = async (event) => { 7 65 const session = await getSession(event);
+115 -37
src/routes/game/[id]/+page.svelte
··· 23 23 let showScoreInput = $state(false); 24 24 let blackScore = $state(0); 25 25 let whiteScore = $state(0); 26 + let suggestedScores = $state<{ blackScore: number; whiteScore: number; winner: string } | null>(null); 27 + let loadingSuggestions = $state(false); 26 28 let firehose: GameFirehose | null = null; 27 29 let showMoveNotification = $state(false); 28 30 let jetstreamConnected = $state(false); ··· 285 287 alert('Failed to record pass'); 286 288 } finally { 287 289 isSubmitting = false; 290 + } 291 + } 292 + 293 + async function fetchSuggestedScores() { 294 + if (loadingSuggestions || suggestedScores) return; 295 + 296 + loadingSuggestions = true; 297 + try { 298 + const response = await fetch(`/api/games/${data.gameRkey}/score`); 299 + if (response.ok) { 300 + const result = await response.json(); 301 + if (result.suggested) { 302 + suggestedScores = result.suggested; 303 + // Pre-populate the form with suggested values 304 + blackScore = result.suggested.blackScore; 305 + whiteScore = result.suggested.whiteScore; 306 + } 307 + } 308 + } catch (err) { 309 + console.error('Failed to fetch suggested scores:', err); 310 + } finally { 311 + loadingSuggestions = false; 288 312 } 289 313 } 290 314 ··· 770 794 <div class="score-input-card"> 771 795 <h3>Submit Final Scores</h3> 772 796 <p class="score-instructions"> 773 - Both players have passed. Please count the territory and enter the final scores below. 797 + Both players have passed. Review the calculated scores below and adjust if needed (e.g., for dead stones). 774 798 </p> 775 799 776 800 {#if showScoreInput} 777 - <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 778 - <div class="score-input-group"> 779 - <label for="black-score"> 780 - <span class="player-black">⚫</span> Black Score: 781 - </label> 782 - <input 783 - id="black-score" 784 - type="number" 785 - min="0" 786 - bind:value={blackScore} 787 - required 788 - /> 789 - </div> 801 + {#if loadingSuggestions} 802 + <p class="loading-suggestions">Calculating scores...</p> 803 + {:else} 804 + {#if suggestedScores} 805 + <div class="suggested-scores-info"> 806 + <span class="auto-calculated-badge">Auto-calculated</span> 807 + {#if suggestedScores.winner !== 'tie'} 808 + <span class="suggested-winner"> 809 + {suggestedScores.winner === 'black' ? '⚫' : '⚪'} 810 + {suggestedScores.winner} leads by {Math.abs(suggestedScores.blackScore - suggestedScores.whiteScore)} points 811 + </span> 812 + {:else} 813 + <span class="suggested-winner">Tie game</span> 814 + {/if} 815 + </div> 816 + {/if} 817 + <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 818 + <div class="score-input-group"> 819 + <label for="black-score"> 820 + <span class="player-black">⚫</span> Black Score: 821 + </label> 822 + <input 823 + id="black-score" 824 + type="number" 825 + min="0" 826 + step="0.5" 827 + bind:value={blackScore} 828 + required 829 + /> 830 + </div> 790 831 791 - <div class="score-input-group"> 792 - <label for="white-score"> 793 - <span class="player-white">⚪</span> White Score: 794 - </label> 795 - <input 796 - id="white-score" 797 - type="number" 798 - min="0" 799 - bind:value={whiteScore} 800 - required 801 - /> 802 - </div> 832 + <div class="score-input-group"> 833 + <label for="white-score"> 834 + <span class="player-white">⚪</span> White Score: 835 + </label> 836 + <input 837 + id="white-score" 838 + type="number" 839 + min="0" 840 + step="0.5" 841 + bind:value={whiteScore} 842 + required 843 + /> 844 + </div> 803 845 804 - <div class="score-buttons"> 805 - <button type="submit" class="submit-score-button" disabled={isSubmitting}> 806 - {isSubmitting ? 'Submitting...' : 'Submit Scores'} 807 - </button> 808 - <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 809 - Cancel 810 - </button> 811 - </div> 812 - </form> 846 + <div class="score-buttons"> 847 + <button type="submit" class="submit-score-button" disabled={isSubmitting}> 848 + {isSubmitting ? 'Submitting...' : 'Submit Scores'} 849 + </button> 850 + <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 851 + Cancel 852 + </button> 853 + </div> 854 + </form> 855 + {/if} 813 856 {:else} 814 - <button class="show-score-input-button" onclick={() => showScoreInput = true}> 815 - Enter Scores 857 + <button class="show-score-input-button" onclick={() => { showScoreInput = true; fetchSuggestedScores(); }}> 858 + Calculate & Enter Scores 816 859 </button> 817 860 {/if} 818 861 </div> ··· 1441 1484 .score-instructions { 1442 1485 color: var(--sky-gray); 1443 1486 margin-bottom: 1.5rem; 1487 + } 1488 + 1489 + .loading-suggestions { 1490 + text-align: center; 1491 + color: var(--sky-gray); 1492 + padding: 1rem; 1493 + animation: pulse 1.5s ease-in-out infinite; 1494 + } 1495 + 1496 + .suggested-scores-info { 1497 + display: flex; 1498 + align-items: center; 1499 + gap: 0.75rem; 1500 + padding: 0.75rem 1rem; 1501 + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); 1502 + border-radius: 0.5rem; 1503 + margin-bottom: 1rem; 1504 + flex-wrap: wrap; 1505 + } 1506 + 1507 + .auto-calculated-badge { 1508 + display: inline-block; 1509 + padding: 0.25rem 0.5rem; 1510 + background: #059669; 1511 + color: white; 1512 + border-radius: 0.25rem; 1513 + font-size: 0.75rem; 1514 + font-weight: 600; 1515 + text-transform: uppercase; 1516 + } 1517 + 1518 + .suggested-winner { 1519 + color: #065f46; 1520 + font-weight: 600; 1521 + font-size: 0.9rem; 1444 1522 } 1445 1523 1446 1524 .score-form {