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.

updated scoring dialogues to make more sense for white player

+367 -60
+66 -2
src/lib/components/Board.svelte
··· 2 2 import { untrack } from 'svelte'; 3 3 import JGO from 'jgoboard'; 4 4 5 + interface TerritoryData { 6 + territory: Array<Array<'black' | 'white' | 'neutral' | null>>; 7 + blackTerritory: number; 8 + whiteTerritory: number; 9 + } 10 + 5 11 interface Props { 6 12 boardSize?: number; 7 13 gameState?: any; ··· 9 15 onPass?: () => void; 10 16 interactive?: boolean; 11 17 currentTurn?: 'black' | 'white'; 18 + territoryData?: TerritoryData | null; 12 19 } 13 20 14 21 let { ··· 17 24 onMove = () => {}, 18 25 onPass = () => {}, 19 26 interactive = true, 20 - currentTurn = 'black' 27 + currentTurn = 'black', 28 + territoryData = null 21 29 }: Props = $props(); 22 30 23 31 let boardElement: HTMLDivElement; ··· 444 452 {/if} 445 453 446 454 <div class="board-wrapper" class:hidden={!isReady}> 447 - <div bind:this={boardElement} class="jgoboard"></div> 455 + <div class="board-with-overlay"> 456 + <div bind:this={boardElement} class="jgoboard"></div> 457 + {#if territoryData && territoryData.territory} 458 + {@const padding = gridSize * 0.8} 459 + {@const margin = gridSize * 0.8} 460 + {@const markerSize = gridSize * 0.5} 461 + {@const boardOffset = padding + margin + markerSize} 462 + <div class="territory-overlay"> 463 + {#each territoryData.territory as row, y} 464 + {#each row as cell, x} 465 + {#if cell === 'black' || cell === 'white'} 466 + <div 467 + class="territory-marker {cell}" 468 + style=" 469 + left: {boardOffset + x * gridSize}px; 470 + top: {boardOffset + y * gridSize}px; 471 + width: {gridSize * 0.5}px; 472 + height: {gridSize * 0.5}px; 473 + transform: translate(-50%, -50%); 474 + " 475 + ></div> 476 + {/if} 477 + {/each} 478 + {/each} 479 + </div> 480 + {/if} 481 + </div> 448 482 </div> 449 483 450 484 {#if interactive && isReady} ··· 478 512 justify-content: center; 479 513 } 480 514 515 + .board-with-overlay { 516 + position: relative; 517 + display: inline-block; 518 + } 519 + 481 520 .jgoboard { 482 521 box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15); 483 522 display: block; ··· 543 582 .hidden { 544 583 visibility: hidden; 545 584 height: 0; 585 + } 586 + 587 + .territory-overlay { 588 + position: absolute; 589 + top: 0; 590 + left: 0; 591 + right: 0; 592 + bottom: 0; 593 + pointer-events: none; 594 + } 595 + 596 + .territory-marker { 597 + position: absolute; 598 + border-radius: 50%; 599 + transform: translate(-50%, -50%); 600 + } 601 + 602 + .territory-marker.black { 603 + background: rgba(20, 20, 20, 0.7); 604 + box-shadow: 0 0 2px rgba(0, 0, 0, 0.5); 605 + } 606 + 607 + .territory-marker.white { 608 + background: rgba(255, 255, 255, 0.85); 609 + box-shadow: 0 0 2px rgba(0, 0, 0, 0.3); 546 610 } 547 611 </style>
+117 -2
src/lib/server/board-svg.ts
··· 7 7 padding?: number; 8 8 showLastMove?: boolean; 9 9 backgroundColor?: string; 10 + showTerritory?: boolean; 11 + } 12 + 13 + export interface TerritoryMap { 14 + territory: Array<Array<'black' | 'white' | 'neutral' | null>>; 15 + blackTerritory: number; 16 + whiteTerritory: number; 17 + blackCaptures: number; 18 + whiteCaptures: number; 19 + } 20 + 21 + /** 22 + * Calculate territory for each empty intersection using flood fill. 23 + * An empty region belongs to a player if it's completely surrounded by their stones. 24 + */ 25 + export function calculateTerritory( 26 + boardState: Array<Array<'black' | 'white' | null>>, 27 + boardSize: number 28 + ): TerritoryMap { 29 + const territory: Array<Array<'black' | 'white' | 'neutral' | null>> = Array.from( 30 + { length: boardSize }, 31 + () => Array.from({ length: boardSize }, () => null) 32 + ); 33 + 34 + const visited: Array<Array<boolean>> = Array.from( 35 + { length: boardSize }, 36 + () => Array.from({ length: boardSize }, () => false) 37 + ); 38 + 39 + let blackTerritory = 0; 40 + let whiteTerritory = 0; 41 + 42 + // Flood fill to find connected empty regions 43 + function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { 44 + const points: Array<[number, number]> = []; 45 + const stack: Array<[number, number]> = [[startY, startX]]; 46 + let touchesBlack = false; 47 + let touchesWhite = false; 48 + 49 + while (stack.length > 0) { 50 + const [y, x] = stack.pop()!; 51 + 52 + if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; 53 + if (visited[y][x]) continue; 54 + 55 + const cell = boardState[y][x]; 56 + 57 + if (cell === 'black') { 58 + touchesBlack = true; 59 + continue; 60 + } 61 + if (cell === 'white') { 62 + touchesWhite = true; 63 + continue; 64 + } 65 + 66 + // Empty intersection 67 + visited[y][x] = true; 68 + points.push([y, x]); 69 + 70 + // Add neighbors 71 + stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); 72 + } 73 + 74 + let owner: 'black' | 'white' | 'neutral'; 75 + if (touchesBlack && !touchesWhite) { 76 + owner = 'black'; 77 + } else if (touchesWhite && !touchesBlack) { 78 + owner = 'white'; 79 + } else { 80 + owner = 'neutral'; 81 + } 82 + 83 + return { points, owner }; 84 + } 85 + 86 + // Find all empty regions 87 + for (let y = 0; y < boardSize; y++) { 88 + for (let x = 0; x < boardSize; x++) { 89 + if (!visited[y][x] && boardState[y][x] === null) { 90 + const { points, owner } = floodFill(y, x); 91 + for (const [py, px] of points) { 92 + territory[py][px] = owner; 93 + if (owner === 'black') blackTerritory++; 94 + else if (owner === 'white') whiteTerritory++; 95 + } 96 + } 97 + } 98 + } 99 + 100 + return { 101 + territory, 102 + blackTerritory, 103 + whiteTerritory, 104 + blackCaptures: 0, // Would need to track captures during game 105 + whiteCaptures: 0, 106 + }; 10 107 } 11 108 12 109 export function generateBoardSvg( 13 110 boardSize: number, 14 111 boardState: Array<Array<'black' | 'white' | null>>, 15 112 lastMove: { x: number; y: number } | null = null, 16 - options: BoardSvgOptions = {} 113 + options: BoardSvgOptions = {}, 114 + territoryMap?: TerritoryMap 17 115 ): string { 18 116 const { 19 117 size: svgSize = 200, 20 118 padding = 10, 21 119 showLastMove = true, 22 - backgroundColor = '#DEB887', 120 + showTerritory = false, 23 121 } = options; 24 122 25 123 const innerSize = svgSize - padding * 2; ··· 67 165 const px = padding + x * step; 68 166 const py = padding + y * step; 69 167 svg += `<circle cx="${px}" cy="${py}" r="${hoshiRadius}" fill="#5C4830" opacity="0.8"/>`; 168 + } 169 + 170 + // Territory markers (small squares at intersections) 171 + if (showTerritory && territoryMap) { 172 + const markerSize = step * 0.25; 173 + for (let y = 0; y < boardSize; y++) { 174 + for (let x = 0; x < boardSize; x++) { 175 + const owner = territoryMap.territory[y]?.[x]; 176 + if (owner && owner !== 'neutral' && boardState[y]?.[x] === null) { 177 + const px = padding + x * step; 178 + const py = padding + y * step; 179 + const color = owner === 'black' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.7)'; 180 + const stroke = owner === 'black' ? 'none' : 'rgba(0, 0, 0, 0.3)'; 181 + svg += `<rect x="${px - markerSize/2}" y="${py - markerSize/2}" width="${markerSize}" height="${markerSize}" fill="${color}" stroke="${stroke}" stroke-width="0.5" rx="1"/>`; 182 + } 183 + } 184 + } 70 185 } 71 186 72 187 // Stones
+14
src/routes/api/games/[id]/score/+server.ts
··· 4 4 import { getDb } from '$lib/server/db'; 5 5 import { calculateScore } from '$lib/server/scoring'; 6 6 import { fetchGameActionsFromPds } from '$lib/atproto-client'; 7 + import { buildBoardStateFromMoves, calculateTerritory } from '$lib/server/board-svg'; 7 8 8 9 /** 9 10 * GET: Calculate suggested scores using tenuki's scoring engine ··· 35 36 if (moves.length === 0) { 36 37 return json({ 37 38 suggested: null, 39 + territory: null, 38 40 message: 'No moves to score', 39 41 }); 40 42 } ··· 43 45 // Default komi of 6.5 for territory scoring 44 46 const score = calculateScore(moves, game.board_size, 6.5); 45 47 48 + // Build board state and calculate territory 49 + const { boardState } = await buildBoardStateFromMoves( 50 + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), 51 + game.board_size 52 + ); 53 + const territoryMap = calculateTerritory(boardState, game.board_size); 54 + 46 55 return json({ 47 56 suggested: { 48 57 blackScore: Math.round(score.black), 49 58 whiteScore: Math.round(score.white), 50 59 winner: score.winner, 60 + }, 61 + territory: { 62 + territory: territoryMap.territory, 63 + blackTerritory: territoryMap.blackTerritory, 64 + whiteTerritory: territoryMap.whiteTerritory, 51 65 }, 52 66 message: 'Scores calculated automatically. Please verify and adjust if needed (e.g., for dead stones).', 53 67 });
+170 -56
src/routes/game/[id]/+page.svelte
··· 24 24 let blackScore = $state(0); 25 25 let whiteScore = $state(0); 26 26 let suggestedScores = $state<{ blackScore: number; whiteScore: number; winner: string } | null>(null); 27 + let territoryData = $state<{ territory: Array<Array<'black' | 'white' | 'neutral' | null>>; blackTerritory: number; whiteTerritory: number } | null>(null); 27 28 let loadingSuggestions = $state(false); 28 29 let firehose: GameFirehose | null = null; 29 30 let showMoveNotification = $state(false); ··· 106 107 const selectedMove = $derived( 107 108 reviewMoveIndex !== null ? moves[reviewMoveIndex] : (moves.length > 0 ? moves[moves.length - 1] : null) 108 109 ); 110 + 111 + // Auto-fetch territory data when viewing the scoring section (for all users) 112 + $effect(() => { 113 + if ( 114 + gameStatus === 'completed' && 115 + gameBlackScore === null && 116 + data.session && 117 + !territoryData && 118 + !loadingSuggestions 119 + ) { 120 + fetchSuggestedScores(); 121 + } 122 + }); 109 123 110 124 // Get move URI for reactions - use actual URI if available, fallback to synthetic 111 125 function getMoveUri(move: MoveRecord): string { ··· 334 348 // Pre-populate the form with suggested values 335 349 blackScore = result.suggested.blackScore; 336 350 whiteScore = result.suggested.whiteScore; 351 + } 352 + if (result.territory) { 353 + territoryData = result.territory; 337 354 } 338 355 } 339 356 } catch (err) { ··· 788 805 onPass={handlePass} 789 806 interactive={gameStatus === 'active' && isMyTurn() && reviewMoveIndex === null} 790 807 currentTurn={currentTurn()} 808 + territoryData={gameStatus === 'completed' && gameBlackScore === null ? territoryData : null} 791 809 /> 792 810 {/if} 793 811 ··· 820 838 </div> 821 839 </div> 822 840 823 - <!-- Score Input --> 841 + <!-- Scoring Section --> 824 842 {#if gameStatus === 'completed' && gameBlackScore === null && data.session} 825 843 <div class="score-input-section"> 826 844 <div class="score-input-card"> 827 - <h3>Submit Final Scores</h3> 828 - <p class="score-instructions"> 829 - Both players have passed. Review the calculated scores below and adjust if needed (e.g., for dead stones). 830 - </p> 845 + {#if data.session.did === gamePlayerOne} 846 + <!-- Black player (game creator) can submit scores --> 847 + <h3>Submit Final Scores</h3> 848 + <p class="score-instructions"> 849 + Both players have passed. Territory is shown on the board above. Review the calculated scores and adjust if needed (e.g., for dead stones). 850 + </p> 831 851 832 - {#if showScoreInput} 852 + {#if showScoreInput} 853 + {#if loadingSuggestions} 854 + <p class="loading-suggestions">Calculating scores...</p> 855 + {:else} 856 + {#if suggestedScores} 857 + <div class="suggested-scores-info"> 858 + <span class="auto-calculated-badge">Auto-calculated</span> 859 + {#if suggestedScores.winner !== 'tie'} 860 + <span class="suggested-winner"> 861 + {suggestedScores.winner === 'black' ? '⚫' : '⚪'} 862 + {suggestedScores.winner} leads by {Math.abs(suggestedScores.blackScore - suggestedScores.whiteScore)} points 863 + </span> 864 + {:else} 865 + <span class="suggested-winner">Tie game</span> 866 + {/if} 867 + </div> 868 + {/if} 869 + <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 870 + <div class="score-input-group"> 871 + <label for="black-score"> 872 + <span class="player-black">⚫</span> Black Score: 873 + </label> 874 + <input 875 + id="black-score" 876 + type="number" 877 + min="0" 878 + step="0.5" 879 + bind:value={blackScore} 880 + required 881 + /> 882 + </div> 883 + 884 + <div class="score-input-group"> 885 + <label for="white-score"> 886 + <span class="player-white">⚪</span> White Score: 887 + </label> 888 + <input 889 + id="white-score" 890 + type="number" 891 + min="0" 892 + step="0.5" 893 + bind:value={whiteScore} 894 + required 895 + /> 896 + </div> 897 + 898 + <div class="score-buttons"> 899 + <button type="submit" class="submit-score-button" disabled={isSubmitting}> 900 + {isSubmitting ? 'Submitting...' : 'Submit Scores'} 901 + </button> 902 + <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 903 + Cancel 904 + </button> 905 + </div> 906 + </form> 907 + {/if} 908 + {:else} 909 + <button class="show-score-input-button" onclick={() => { showScoreInput = true; fetchSuggestedScores(); }}> 910 + Calculate & Enter Scores 911 + </button> 912 + {/if} 913 + {:else} 914 + <!-- White player (or spectator) sees waiting message --> 915 + <h3>Game Scoring</h3> 916 + <p class="score-instructions"> 917 + Both players have passed. Territory is shown on the board above. 918 + </p> 919 + 833 920 {#if loadingSuggestions} 834 921 <p class="loading-suggestions">Calculating scores...</p> 835 - {:else} 836 - {#if suggestedScores} 837 - <div class="suggested-scores-info"> 838 - <span class="auto-calculated-badge">Auto-calculated</span> 922 + {:else if suggestedScores} 923 + <div class="tentative-scores"> 924 + <h4>Tentative Scores</h4> 925 + <div class="score-display"> 926 + <div class="score-row"> 927 + <span class="player-black">⚫</span> Black: <strong>{suggestedScores.blackScore}</strong> 928 + </div> 929 + <div class="score-row"> 930 + <span class="player-white">⚪</span> White: <strong>{suggestedScores.whiteScore}</strong> 931 + </div> 839 932 {#if suggestedScores.winner !== 'tie'} 840 - <span class="suggested-winner"> 933 + <div class="tentative-winner"> 841 934 {suggestedScores.winner === 'black' ? '⚫' : '⚪'} 842 935 {suggestedScores.winner} leads by {Math.abs(suggestedScores.blackScore - suggestedScores.whiteScore)} points 843 - </span> 936 + </div> 844 937 {:else} 845 - <span class="suggested-winner">Tie game</span> 938 + <div class="tentative-winner">Tie game</div> 846 939 {/if} 847 940 </div> 848 - {/if} 849 - <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 850 - <div class="score-input-group"> 851 - <label for="black-score"> 852 - <span class="player-black">⚫</span> Black Score: 853 - </label> 854 - <input 855 - id="black-score" 856 - type="number" 857 - min="0" 858 - step="0.5" 859 - bind:value={blackScore} 860 - required 861 - /> 862 - </div> 941 + </div> 942 + {/if} 863 943 864 - <div class="score-input-group"> 865 - <label for="white-score"> 866 - <span class="player-white">⚪</span> White Score: 867 - </label> 868 - <input 869 - id="white-score" 870 - type="number" 871 - min="0" 872 - step="0.5" 873 - bind:value={whiteScore} 874 - required 875 - /> 876 - </div> 877 - 878 - <div class="score-buttons"> 879 - <button type="submit" class="submit-score-button" disabled={isSubmitting}> 880 - {isSubmitting ? 'Submitting...' : 'Submit Scores'} 881 - </button> 882 - <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 883 - Cancel 884 - </button> 885 - </div> 886 - </form> 887 - {/if} 888 - {:else} 889 - <button class="show-score-input-button" onclick={() => { showScoreInput = true; fetchSuggestedScores(); }}> 890 - Calculate & Enter Scores 891 - </button> 944 + <div class="waiting-for-black"> 945 + <span class="waiting-icon">⏳</span> 946 + <span>Waiting for black ({playerOneHandle}) to confirm the final scores...</span> 947 + </div> 892 948 {/if} 893 949 </div> 894 950 </div> ··· 1636 1692 1637 1693 .cancel-button:hover { 1638 1694 background: var(--sky-blue-pale); 1695 + } 1696 + 1697 + .tentative-scores { 1698 + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); 1699 + border-radius: 0.75rem; 1700 + padding: 1.25rem; 1701 + margin: 1rem 0; 1702 + } 1703 + 1704 + .tentative-scores h4 { 1705 + margin: 0 0 0.75rem 0; 1706 + color: #065f46; 1707 + font-size: 1rem; 1708 + font-weight: 600; 1709 + } 1710 + 1711 + .score-display { 1712 + display: flex; 1713 + flex-direction: column; 1714 + gap: 0.5rem; 1715 + } 1716 + 1717 + .score-row { 1718 + display: flex; 1719 + align-items: center; 1720 + gap: 0.5rem; 1721 + color: #065f46; 1722 + font-size: 1rem; 1723 + } 1724 + 1725 + .score-row strong { 1726 + font-size: 1.125rem; 1727 + } 1728 + 1729 + .tentative-winner { 1730 + margin-top: 0.5rem; 1731 + padding-top: 0.5rem; 1732 + border-top: 1px solid rgba(6, 95, 70, 0.2); 1733 + color: #065f46; 1734 + font-weight: 600; 1735 + font-size: 0.9rem; 1736 + } 1737 + 1738 + .waiting-for-black { 1739 + display: flex; 1740 + align-items: center; 1741 + gap: 0.75rem; 1742 + padding: 1rem; 1743 + background: var(--sky-apricot-light); 1744 + border: 1px solid var(--sky-apricot); 1745 + border-radius: 0.5rem; 1746 + color: var(--sky-apricot-dark); 1747 + font-weight: 500; 1748 + margin-top: 1rem; 1749 + } 1750 + 1751 + .waiting-icon { 1752 + font-size: 1.25rem; 1639 1753 } 1640 1754 1641 1755 .turn-message {
static/favicon.png

This is a binary file and will not be displayed.