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 handicap and color choice support for game creation

This commit adds comprehensive support for handicap games and player color
selection when creating games.

Features:
- Players can choose their color (black, white, or random) when creating games
- Handicap option for 13x13 (max 5 stones) and 19x19 (max 9 stones) boards
- Handicap stones are automatically placed at star points in canonical order
- When handicap is selected, creator is forced to play as white
- Extra options UI: color and handicap settings hidden behind collapsible toggle

Implementation:
- Updated boo.sky.go.game lexicon to include handicap and handicapStones fields
- Added handicap column to games database table with migration
- Added creator_did column to track who owns the game record in ATProto
- Turn order reversed for handicap games (white plays first after handicap stones)
- Fixed game record fetching to use actual creator DID from AT URI
- Fixed move/pass validation to account for handicap turn order
- Fixed score submission to update correct repo (creator's, not always player_one)
- Handicap stones displayed in move history with H1, H2, etc. labels
- Updated join logic to handle games where player_one might be empty
- Fixed player resolution to use game record's actual playerOne/playerTwo values

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

+651 -102
+24
lexicons/boo.sky.go.game.json
··· 56 56 "pattern": "^[bw][A-T](0[1-9]|1[0-9]|2[0-5])$" 57 57 } 58 58 }, 59 + "handicap": { 60 + "type": "integer", 61 + "description": "Number of handicap stones (0-9)", 62 + "minimum": 0, 63 + "maximum": 9 64 + }, 65 + "handicapStones": { 66 + "type": "array", 67 + "description": "Positions of handicap stones placed at game start", 68 + "items": { 69 + "type": "object", 70 + "required": ["x", "y"], 71 + "properties": { 72 + "x": { 73 + "type": "integer", 74 + "description": "Column position (0-indexed)" 75 + }, 76 + "y": { 77 + "type": "integer", 78 + "description": "Row position (0-indexed)" 79 + } 80 + } 81 + } 82 + }, 59 83 "createdAt": { 60 84 "type": "string", 61 85 "format": "datetime"
+22 -1
src/lib/server/db.ts
··· 7 7 export interface GameRecord { 8 8 id: string; // AT URI (game_at_uri) 9 9 rkey: string; // Record key (TID) 10 - player_one: string; 10 + creator_did: string; // DID of whoever created the game (owns the ATProto record) 11 + player_one: string | null; 11 12 player_two: string | null; 12 13 board_size: number; 13 14 status: 'waiting' | 'active' | 'completed'; 14 15 action_count: number; 15 16 last_action_type: string | null; 17 + winner: string | null; 18 + handicap: number; 16 19 created_at: string; 17 20 updated_at: string; 18 21 } ··· 112 115 if (!columnNames.includes('winner')) { 113 116 sqlite.exec(` 114 117 ALTER TABLE games ADD COLUMN winner TEXT; 118 + `); 119 + } 120 + 121 + // Add handicap column if it doesn't exist 122 + if (!columnNames.includes('handicap')) { 123 + sqlite.exec(` 124 + ALTER TABLE games ADD COLUMN handicap INTEGER DEFAULT 0; 125 + `); 126 + } 127 + 128 + // Add creator_did column if it doesn't exist 129 + if (!columnNames.includes('creator_did')) { 130 + sqlite.exec(` 131 + ALTER TABLE games ADD COLUMN creator_did TEXT; 132 + `); 133 + // Backfill: assume player_one was the creator for existing games 134 + sqlite.exec(` 135 + UPDATE games SET creator_did = player_one WHERE creator_did IS NULL; 115 136 `); 116 137 } 117 138 }
+2
src/lib/types.ts
··· 12 12 blackScorer?: string; 13 13 whiteScorer?: string; 14 14 deadStones?: string[]; // Format: ["bA01", "wT19"] 15 + handicap?: number; 16 + handicapStones?: Array<{ x: number; y: number }>; 15 17 createdAt: string; 16 18 } 17 19
+16 -3
src/routes/+page.server.ts
··· 11 11 const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); 12 12 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 13 13 14 - // Fetch recent active/waiting games (updated in last 12 hours) 14 + // Fetch recent active games (updated in last 12 hours) 15 15 const activeGames = await db 16 16 .selectFrom('games') 17 17 .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 18 - .where('status', 'in', ['active', 'waiting']) 18 + .where('status', '=', 'active') 19 19 .where('updated_at', '>=', twelveHoursAgo) 20 20 .orderBy('updated_at', 'desc') 21 21 .limit(100) 22 22 .execute(); 23 23 24 + // Fetch all waiting games first 25 + const allWaitingGames = await db 26 + .selectFrom('games') 27 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 28 + .where('status', '=', 'waiting') 29 + .orderBy('created_at', 'desc') 30 + .execute(); 31 + 32 + // Only apply 12-hour filter if there are more than 10 waiting games 33 + const waitingGames = allWaitingGames.length > 10 34 + ? allWaitingGames.filter(g => g.updated_at >= twelveHoursAgo) 35 + : allWaitingGames; 36 + 24 37 // Fetch completed games from last 7 days 25 38 const completedGames = await db 26 39 .selectFrom('games') ··· 32 45 .execute(); 33 46 34 47 // Combine all games 35 - const games = [...activeGames, ...completedGames]; 48 + const games = [...activeGames, ...waitingGames, ...completedGames]; 36 49 37 50 const gamesWithTitles = games.map((game) => ({ 38 51 ...game,
+275 -22
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 - import { onMount } from 'svelte'; 3 + import { onMount, onDestroy } from 'svelte'; 4 4 import { resolveDidToHandle, fetchMoveCount, fetchCloudGoProfile, fetchUserProfile, type UserProfile } from '$lib/atproto-client'; 5 5 import type { ProfileRecord } from '$lib/types'; 6 6 import { formatElapsedTime, isStale } from '$lib/time-utils'; 7 + import { GameNotifications, onVisibilityChange } from '$lib/notifications'; 8 + import { getSoundManager } from '$lib/sound-manager'; 9 + import { browser } from '$app/environment'; 7 10 8 11 let { data }: { data: PageData } = $props(); 9 12 13 + let notifications: GameNotifications | null = null; 14 + let ws: WebSocket | null = null; 15 + const soundManager = getSoundManager(); 16 + 10 17 let handle = $state(''); 11 18 let isLoggingIn = $state(false); 12 19 let isCreatingGame = $state(false); 13 20 let boardSize = $state(19); 14 21 let opponentHandle = $state(''); 15 22 let spectating = $state(false); 23 + let colorChoice = $state<'black' | 'white' | 'random'>('black'); 24 + let handicap = $state(0); 25 + let showExtraOptions = $state(false); 26 + 27 + // Svelte action to sync input value changes with state 28 + function syncValue(node: HTMLInputElement, callback: (value: string) => void) { 29 + const observer = new MutationObserver(() => { 30 + callback(node.value); 31 + }); 32 + 33 + // Watch for attribute changes 34 + observer.observe(node, { attributes: true, attributeFilter: ['value'] }); 35 + 36 + // Also use setInterval to catch direct .value assignments 37 + const interval = setInterval(() => { 38 + callback(node.value); 39 + }, 100); 40 + 41 + return { 42 + destroy() { 43 + observer.disconnect(); 44 + clearInterval(interval); 45 + } 46 + }; 47 + } 16 48 let moveCounts = $state<Record<string, number | null>>({}); 17 49 let handles = $state<Record<string, string>>({}); 18 50 let playerStatuses = $state<Record<string, ProfileRecord | null>>({}); ··· 28 60 // Helper to determine whose turn it is in a game 29 61 function getWhoseTurn(game: typeof data.games[0]): 'black' | 'white' { 30 62 const actionCount = game.action_count || 0; 31 - return actionCount % 2 === 0 ? 'black' : 'white'; 63 + const handicap = game.handicap || 0; 64 + 65 + // With handicap, the turn order is reversed 66 + // Action 0 = white, Action 1 = black, Action 2 = white, etc. 67 + if (handicap > 0) { 68 + return actionCount % 2 === 0 ? 'white' : 'black'; 69 + } else { 70 + return actionCount % 2 === 0 ? 'black' : 'white'; 71 + } 32 72 } 33 73 34 74 // Helper to check if it's the current user's turn ··· 47 87 if (!data.session) return false; 48 88 return game.player_one === data.session.did || game.player_two === data.session.did; 49 89 } 90 + 91 + // Show handicap option only for 13x13 and 19x19 92 + const showHandicap = $derived(boardSize === 13 || boardSize === 19); 93 + const maxHandicap = $derived(boardSize === 19 ? 9 : boardSize === 13 ? 5 : 0); 94 + 95 + // Reset handicap when board size changes to unsupported size 96 + $effect(() => { 97 + if (!showHandicap) { 98 + handicap = 0; 99 + } else if (handicap > maxHandicap) { 100 + handicap = maxHandicap; 101 + } 102 + }); 103 + 104 + // If handicap is set, force white color 105 + $effect(() => { 106 + if (handicap > 0) { 107 + colorChoice = 'white'; 108 + } 109 + }); 50 110 51 111 // Split games by status 52 112 const currentGames = $derived( ··· 137 197 }); 138 198 } 139 199 } 200 + 201 + // Set up notifications for when it's your turn 202 + if (browser && data.session) { 203 + notifications = new GameNotifications(); 204 + 205 + // Stop title flash when page becomes visible 206 + const cleanupVisibility = onVisibilityChange((visible) => { 207 + if (visible && notifications) { 208 + notifications.stopTitleFlash(); 209 + } 210 + }); 211 + 212 + // Connect to Jetstream to listen for moves in user's games 213 + const jetstreamUrl = new URL('wss://jetstream2.us-east.bsky.network/subscribe'); 214 + jetstreamUrl.searchParams.append('wantedCollections', 'boo.sky.go.move'); 215 + jetstreamUrl.searchParams.append('wantedDids', data.session.did); 216 + 217 + ws = new WebSocket(jetstreamUrl.href); 218 + 219 + ws.onmessage = (event) => { 220 + try { 221 + const message = JSON.parse(event.data); 222 + if (message.kind === 'commit' && message.commit.collection === 'boo.sky.go.move') { 223 + const move = message.commit.record; 224 + 225 + // Find the game this move belongs to 226 + const game = data.games?.find(g => move.game === g.game_at_uri); 227 + if (!game || game.status !== 'active') return; 228 + 229 + // Check if it's now the current user's turn 230 + const newTurn = move.color === 'black' ? 'white' : 'black'; 231 + const isNowMyTurn = (newTurn === 'black' && data.session.did === game.player_one) || 232 + (newTurn === 'white' && data.session.did === game.player_two); 233 + 234 + if (isNowMyTurn && notifications) { 235 + // Get opponent handle 236 + const opponentDid = move.player; 237 + const opponentHandle = handles[opponentDid]; 238 + notifications.notifyYourTurn(opponentHandle); 239 + soundManager.play('move_made'); 240 + } 241 + } 242 + } catch (err) { 243 + console.error('Error processing Jetstream message:', err); 244 + } 245 + }; 246 + } 247 + }); 248 + 249 + onDestroy(() => { 250 + if (ws) { 251 + ws.close(); 252 + } 253 + if (notifications) { 254 + notifications.cleanup(); 255 + } 140 256 }); 141 257 142 258 async function login() { ··· 175 291 async function createGame() { 176 292 isCreatingGame = true; 177 293 try { 178 - const body: { boardSize: number; opponentHandle?: string } = { boardSize }; 294 + const body: { 295 + boardSize: number; 296 + opponentHandle?: string; 297 + colorChoice: 'black' | 'white' | 'random'; 298 + handicap: number; 299 + } = { 300 + boardSize, 301 + colorChoice, 302 + handicap 303 + }; 179 304 180 305 // If opponent handle is provided, include it 181 306 if (opponentHandle.trim()) { ··· 230 355 <div class="login-card"> 231 356 <h2>Login with @proto</h2> 232 357 <form onsubmit={(e) => { e.preventDefault(); login(); }}> 233 - <input 234 - type="text" 235 - bind:value={handle} 236 - placeholder="your-handle.bsky.social" 237 - disabled={isLoggingIn} 238 - class="input" 239 - /> 358 + <actor-typeahead> 359 + <input 360 + type="text" 361 + value={handle} 362 + use:syncValue={(val) => handle = val} 363 + oninput={(e) => handle = e.currentTarget.value} 364 + onchange={(e) => handle = e.currentTarget.value} 365 + placeholder="your-handle.bsky.social" 366 + disabled={isLoggingIn} 367 + class="input" 368 + /> 369 + </actor-typeahead> 240 370 <button type="submit" disabled={isLoggingIn} class="button button-primary"> 241 371 {isLoggingIn ? 'Logging in...' : 'Login'} 242 372 </button> ··· 255 385 <!-- Current Games --> 256 386 <div class="card current-games"> 257 387 <div class="section-header-with-count"> 258 - <h2>Current Games</h2> 388 + <div class="title-with-info"> 389 + <h2>Current Games</h2> 390 + <span class="info-icon" title="These are games from the last 12 hours. View your profile to see all games with older moves.">?</span> 391 + </div> 259 392 {#if currentGames.length > ACTIVE_PAGE_SIZE} 260 393 <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> 261 394 {/if} ··· 447 580 </label> 448 581 <label> 449 582 Opponent (optional): 450 - <input 451 - type="text" 452 - bind:value={opponentHandle} 453 - placeholder="@handle.bsky.social" 454 - class="input" 455 - /> 583 + <actor-typeahead> 584 + <input 585 + type="text" 586 + value={opponentHandle} 587 + use:syncValue={(val) => opponentHandle = val} 588 + oninput={(e) => opponentHandle = e.currentTarget.value} 589 + onchange={(e) => opponentHandle = e.currentTarget.value} 590 + placeholder="@handle.bsky.social" 591 + class="input" 592 + /> 593 + </actor-typeahead> 456 594 </label> 457 595 </div> 596 + 458 597 <button 459 - onclick={createGame} 460 - disabled={isCreatingGame} 461 - class="button button-primary" 598 + type="button" 599 + class="extra-options-toggle" 600 + onclick={() => showExtraOptions = !showExtraOptions} 462 601 > 463 - {isCreatingGame ? 'Creating...' : (opponentHandle ? 'Invite to Game' : 'Create Game')} 602 + {showExtraOptions ? '▼' : '▶'} Extra Options 464 603 </button> 604 + 605 + {#if showExtraOptions} 606 + <div class="form-row extra-options"> 607 + {#if showHandicap} 608 + <label> 609 + Handicap: 610 + <select bind:value={handicap} class="select"> 611 + <option value={0}>None</option> 612 + {#each Array.from({ length: maxHandicap }, (_, i) => i + 1) as h} 613 + <option value={h}>{h} stone{h > 1 ? 's' : ''}</option> 614 + {/each} 615 + </select> 616 + </label> 617 + {/if} 618 + {#if handicap === 0} 619 + <label> 620 + Your Color: 621 + <select bind:value={colorChoice} class="select"> 622 + <option value="black">Black (play first)</option> 623 + <option value="white">White (play second)</option> 624 + <option value="random">Random</option> 625 + </select> 626 + </label> 627 + {/if} 628 + </div> 629 + {/if} 630 + 631 + <div class="form-row"> 632 + <button 633 + onclick={createGame} 634 + disabled={isCreatingGame} 635 + class="button button-primary create-button" 636 + > 637 + {isCreatingGame ? 'Creating...' : (opponentHandle ? 'Invite to Game' : 'Create Game')} 638 + </button> 639 + </div> 465 640 </div> 466 641 </div> 467 642 ··· 470 645 <div class="card current-games"> 471 646 <div class="section-header"> 472 647 <div class="section-title-row"> 473 - <h2>Current Games</h2> 648 + <div class="title-with-info"> 649 + <h2>Current Games</h2> 650 + <span class="info-icon" title="These are games from the last 12 hours. View your profile to see all games with older moves.">?</span> 651 + </div> 474 652 {#if currentGames.length > ACTIVE_PAGE_SIZE} 475 653 <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> 476 654 {/if} ··· 771 949 gap: 1rem; 772 950 } 773 951 952 + .extra-options-toggle { 953 + background: none; 954 + border: none; 955 + color: var(--sky-slate); 956 + font-size: 0.9rem; 957 + font-weight: 500; 958 + cursor: pointer; 959 + padding: 0.5rem 0; 960 + text-align: left; 961 + transition: color 0.2s; 962 + display: flex; 963 + align-items: center; 964 + gap: 0.5rem; 965 + } 966 + 967 + .extra-options-toggle:hover { 968 + color: var(--sky-apricot-dark); 969 + } 970 + 971 + .extra-options { 972 + padding-top: 0.5rem; 973 + border-top: 1px solid var(--sky-blue-pale); 974 + } 975 + 774 976 .form-row { 775 977 display: flex; 776 978 gap: 1rem; 777 979 align-items: flex-end; 980 + flex-wrap: wrap; 778 981 } 779 982 780 983 .form-row label { 781 984 flex: 1; 985 + min-width: 150px; 986 + } 987 + 988 + .form-row .create-button { 989 + flex: 0 1 auto; 990 + min-width: 150px; 991 + align-self: flex-end; 992 + } 993 + 994 + @media (max-width: 640px) { 995 + .form-row .create-button { 996 + flex: 1 1 100%; 997 + } 782 998 } 783 999 784 1000 .input, .select { ··· 1168 1384 gap: 0.75rem; 1169 1385 } 1170 1386 1387 + .title-with-info { 1388 + display: flex; 1389 + align-items: center; 1390 + gap: 0.5rem; 1391 + } 1392 + 1393 + .info-icon { 1394 + display: inline-flex; 1395 + align-items: center; 1396 + justify-content: center; 1397 + width: 18px; 1398 + height: 18px; 1399 + border-radius: 50%; 1400 + border: 1.5px solid var(--sky-gray); 1401 + color: var(--sky-gray); 1402 + font-size: 0.75rem; 1403 + font-weight: 600; 1404 + cursor: help; 1405 + transition: all 0.2s; 1406 + } 1407 + 1408 + .info-icon:hover { 1409 + border-color: var(--sky-slate); 1410 + color: var(--sky-slate); 1411 + background: var(--sky-cloud); 1412 + } 1413 + 1171 1414 .game-count { 1172 1415 font-size: 0.875rem; 1173 1416 color: var(--sky-gray); ··· 1409 1652 .pagination-info { 1410 1653 font-size: 0.875rem; 1411 1654 color: var(--sky-gray); 1655 + } 1656 + 1657 + :global(actor-typeahead) { 1658 + position: relative; 1659 + z-index: 1000; 1660 + display: block; 1661 + } 1662 + 1663 + :global(actor-typeahead [role="listbox"]) { 1664 + z-index: 1001; 1412 1665 } 1413 1666 </style>
+80 -7
src/routes/api/games/+server.ts
··· 29 29 } 30 30 } 31 31 32 + // Convert Go notation (e.g., "D4") to board coordinates 33 + function notationToCoords(notation: string, boardSize: number): { x: number; y: number } { 34 + const col = notation.charAt(0); 35 + const row = parseInt(notation.substring(1)); 36 + 37 + // Convert column letter to x coordinate (A=0, B=1, C=2, D=3, etc., skipping I) 38 + let x = col.charCodeAt(0) - 65; // A=0 39 + if (col >= 'J') { 40 + x -= 1; // Skip I 41 + } 42 + 43 + // Convert row number to y coordinate (bottom to top) 44 + const y = boardSize - row; 45 + 46 + return { x, y }; 47 + } 48 + 49 + // Get handicap stone positions for a given board size and handicap count 50 + function getHandicapPositions(boardSize: number, handicap: number): Array<{ x: number; y: number }> { 51 + const positions19x19 = ['D4', 'Q16', 'D16', 'Q4', 'D10', 'Q10', 'K16', 'K4', 'K10']; 52 + const positions13x13 = ['D4', 'K10', 'D10', 'K4', 'G7']; 53 + 54 + const notations = boardSize === 19 ? positions19x19 : boardSize === 13 ? positions13x13 : []; 55 + 56 + return notations 57 + .slice(0, handicap) 58 + .map(notation => notationToCoords(notation, boardSize)); 59 + } 60 + 32 61 export const POST: RequestHandler = async (event) => { 33 62 const session = await getSession(event); 34 63 ··· 36 65 throw error(401, 'Not authenticated'); 37 66 } 38 67 39 - const { boardSize = 19, opponentHandle } = await event.request.json(); 68 + const { 69 + boardSize = 19, 70 + opponentHandle, 71 + colorChoice = 'black', 72 + handicap = 0 73 + } = await event.request.json(); 40 74 41 75 if (![5, 7, 9, 13, 19].includes(boardSize)) { 42 76 throw error(400, 'Invalid board size. Supported: 5x5, 7x7, 9x9, 13x13, 19x19'); 77 + } 78 + 79 + // Validate handicap 80 + const maxHandicap = boardSize === 19 ? 9 : boardSize === 13 ? 5 : 0; 81 + if (handicap < 0 || handicap > maxHandicap) { 82 + throw error(400, `Invalid handicap. Max for ${boardSize}x${boardSize} is ${maxHandicap}`); 43 83 } 44 84 45 85 try { ··· 60 100 } 61 101 } 62 102 103 + // Determine player assignments based on color choice and handicap 104 + let playerOne: string; 105 + let playerTwo: string | null; 106 + 107 + if (handicap > 0) { 108 + // With handicap, creator is always white (playerTwo) 109 + playerOne = opponentDid || ''; // Will be filled when opponent joins 110 + playerTwo = session.did; 111 + } else if (colorChoice === 'random') { 112 + // Random assignment 113 + const creatorIsBlack = Math.random() < 0.5; 114 + if (creatorIsBlack) { 115 + playerOne = session.did; 116 + playerTwo = opponentDid; 117 + } else { 118 + playerOne = opponentDid || ''; 119 + playerTwo = session.did; 120 + } 121 + } else if (colorChoice === 'white') { 122 + // Creator wants white 123 + playerOne = opponentDid || ''; 124 + playerTwo = session.did; 125 + } else { 126 + // Creator wants black (default) 127 + playerOne = session.did; 128 + playerTwo = opponentDid; 129 + } 130 + 63 131 const rkey = generateTid(); 64 132 const now = new Date().toISOString(); 65 133 66 134 // Create game record in AT Protocol 67 135 const record: any = { 68 136 $type: 'boo.sky.go.game', 69 - playerOne: session.did, 137 + playerOne: playerOne || undefined, 138 + playerTwo: playerTwo || undefined, 70 139 boardSize, 71 140 status: opponentDid ? 'active' : 'waiting', 72 141 createdAt: now, 73 142 }; 74 143 75 - // Add opponent if provided 76 - if (opponentDid) { 77 - record.playerTwo = opponentDid; 144 + // Add handicap stones if needed 145 + if (handicap > 0) { 146 + const handicapPositions = getHandicapPositions(boardSize, handicap); 147 + record.handicap = handicap; 148 + record.handicapStones = handicapPositions; 78 149 } 79 150 80 151 // Publish to AT Protocol ··· 100 171 .values({ 101 172 id: uri, 102 173 rkey, 103 - player_one: session.did, 104 - player_two: opponentDid, 174 + creator_did: session.did, // The person who created the game owns the ATProto record 175 + player_one: playerOne || null, 176 + player_two: playerTwo || null, 105 177 board_size: boardSize, 106 178 status: opponentDid ? 'active' : 'waiting', 107 179 action_count: 0, 108 180 last_action_type: null, 109 181 winner: null, 182 + handicap: handicap, 110 183 created_at: now, 111 184 updated_at: now, 112 185 })
+18 -7
src/routes/api/games/[id]/join/+server.ts
··· 30 30 throw error(400, 'Game is not waiting for players'); 31 31 } 32 32 33 - if (game.player_one === session.did) { 33 + if (game.player_one === session.did || game.player_two === session.did) { 34 34 throw error(400, 'Cannot join your own game'); 35 35 } 36 36 37 - if (game.player_two) { 37 + // Determine which player slot to fill 38 + const needsPlayerOne = !game.player_one; 39 + const needsPlayerTwo = !game.player_two; 40 + 41 + if (!needsPlayerOne && !needsPlayerTwo) { 38 42 throw error(400, 'Game already has two players'); 39 43 } 40 44 ··· 52 56 // The PDS record will be updated when the game creator's client next loads. 53 57 54 58 const now = new Date().toISOString(); 59 + const updateData: any = { 60 + status: 'active', 61 + updated_at: now, 62 + }; 63 + 64 + if (needsPlayerOne) { 65 + updateData.player_one = session.did; 66 + } else { 67 + updateData.player_two = session.did; 68 + } 69 + 55 70 await db 56 71 .updateTable('games') 57 - .set({ 58 - player_two: session.did, 59 - status: 'active', 60 - updated_at: now, 61 - }) 72 + .set(updateData) 62 73 .where('rkey', '=', rkey) 63 74 .execute(); 64 75
+11 -1
src/routes/api/games/[id]/move/+server.ts
··· 44 44 45 45 // Use action_count for turn validation 46 46 const totalMoves = game.action_count; 47 - const currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 47 + const handicap = game.handicap || 0; 48 + 49 + // With handicap, the turn order is reversed 50 + // Action 0 = white, Action 1 = black, Action 2 = white, etc. 51 + let currentColor: 'black' | 'white'; 52 + if (handicap > 0) { 53 + currentColor = totalMoves % 2 === 0 ? 'white' : 'black'; 54 + } else { 55 + currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 56 + } 57 + 48 58 const expectedPlayer = currentColor === 'black' ? game.player_one : game.player_two; 49 59 50 60 if (session.did !== expectedPlayer) {
+11 -1
src/routes/api/games/[id]/pass/+server.ts
··· 38 38 } 39 39 40 40 const totalMoves = game.action_count; 41 - const currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 41 + const handicap = game.handicap || 0; 42 + 43 + // With handicap, the turn order is reversed 44 + // Action 0 = white, Action 1 = black, Action 2 = white, etc. 45 + let currentColor: 'black' | 'white'; 46 + if (handicap > 0) { 47 + currentColor = totalMoves % 2 === 0 ? 'white' : 'black'; 48 + } else { 49 + currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 50 + } 51 + 42 52 const expectedPlayer = currentColor === 'black' ? game.player_one : game.player_two; 43 53 44 54 if (session.did !== expectedPlayer) {
+7 -2
src/routes/api/games/[id]/score/+server.ts
··· 189 189 const now = new Date().toISOString(); 190 190 const winner = blackScore > whiteScore ? game.player_one : game.player_two; 191 191 192 + // Extract creator DID from AT URI (format: at://did:plc:xxx/collection/rkey) 193 + const atUriMatch = game.id.match(/^at:\/\/(did:[^/]+)\//); 194 + const creatorDid = atUriMatch ? atUriMatch[1] : game.creator_did || game.player_one; 195 + 192 196 // Update DB index status (scores live in PDS game record) 193 197 await db 194 198 .updateTable('games') ··· 200 204 .execute(); 201 205 202 206 // Update AT Protocol record with scores 203 - if (session.did === game.player_one) { 207 + // Only the game record owner can update it 208 + if (session.did === creatorDid) { 204 209 const agent = await getAgent(event); 205 210 if (agent) { 206 211 try { ··· 221 226 222 227 const updateResult = await (agent as any).post('com.atproto.repo.putRecord', { 223 228 input: { 224 - repo: game.player_one, 229 + repo: creatorDid, 225 230 collection: 'boo.sky.go.game', 226 231 rkey: game.rkey, 227 232 record: updatedGameRecord,
+6 -2
src/routes/game/[id]/+page.server.ts
··· 11 11 12 12 const game = await db 13 13 .selectFrom('games') 14 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 14 + .select(['rkey', 'id', 'creator_did', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 15 15 .where('rkey', '=', rkey) 16 16 .executeTakeFirst(); 17 17 ··· 19 19 throw error(404, 'Game not found'); 20 20 } 21 21 22 + // Extract creator DID from AT URI (format: at://did:plc:xxx/collection/rkey) 23 + const atUriMatch = game.id.match(/^at:\/\/(did:[^/]+)\//); 24 + const creatorDidFromUri = atUriMatch ? atUriMatch[1] : null; 25 + 22 26 return { 23 27 session, 24 28 gameRkey: game.rkey, 25 - creatorDid: game.player_one, 29 + creatorDid: creatorDidFromUri || game.creator_did || game.player_one, 26 30 playerTwoDid: game.player_two, 27 31 gameAtUri: game.id, 28 32 boardSize: game.board_size,
+179 -56
src/routes/game/[id]/+page.svelte
··· 23 23 import { formatElapsedTime, isStale } from '$lib/time-utils'; 24 24 import { calculateScore, calculateTerritory, buildBoardState } from '$lib/client-scoring'; 25 25 import type { TerritoryMap } from '$lib/client-scoring'; 26 + import { GameNotifications, onVisibilityChange } from '$lib/notifications'; 27 + import { getSoundManager } from '$lib/sound-manager'; 26 28 27 29 let { data }: { data: PageData } = $props(); 30 + 31 + let notifications: GameNotifications | null = null; 32 + const soundManager = getSoundManager(); 28 33 29 34 let boardRef: any = $state(null); 30 35 let isSubmitting = $state(false); ··· 63 68 let moves = $state<MoveRecord[]>([]); 64 69 let passes = $state<PassRecord[]>([]); 65 70 let resigns = $state<ResignRecord[]>([]); 66 - let playerOneHandle = $state<string>(data.creatorDid); 67 - let playerTwoHandle = $state<string | null>(data.playerTwoDid); 71 + let playerOneHandle = $state<string>(''); 72 + let playerTwoHandle = $state<string | null>(null); 68 73 let playerOneProfile = $state<UserProfile | null>(null); 69 74 let playerTwoProfile = $state<UserProfile | null>(null); 70 75 let playerOneCloudGoProfile = $state<ProfileRecord | null>(null); ··· 99 104 100 105 // Format move coordinates as board notation (e.g., A1, B2, T19) 101 106 function formatMoveCoords(x: number, y: number): string { 102 - const col = String.fromCharCode(65 + x); // 0=A, 1=B 103 - const row = (y + 1).toString().padStart(2, '0'); // 0-indexed to 1-indexed, zero-padded 107 + // Skip letter 'I' in Go notation: A B C D E F G H J K L M N O P Q R S T 108 + let col = String.fromCharCode(65 + x); // 0=A, 1=B, etc. 109 + if (x >= 8) { 110 + col = String.fromCharCode(65 + x + 1); // Skip 'I', so 8=J, 9=K, etc. 111 + } 112 + 113 + // Rows are numbered from bottom to top, so reverse the y coordinate 114 + const row = (gameBoardSize - y).toString().padStart(2, '0'); 104 115 return `${col}${row}`; 105 116 } 106 117 107 - // DB index is authoritative for status and playerTwo (set by join endpoint, 108 - // which can't write to player one's PDS repo). PDS is authoritative for 109 - // scores and winner (written by game creator). 118 + // DB index is authoritative for status. PDS game record is authoritative for 119 + // player assignments, scores, and winner (written by game creator). 110 120 const gameStatus = $derived(data.status); 111 121 const gameBoardSize = $derived(gameRecord?.boardSize ?? data.boardSize); 112 - const gamePlayerOne = $derived(data.creatorDid); 113 - const gamePlayerTwo = $derived(data.playerTwoDid); 122 + const gamePlayerOne = $derived(gameRecord?.playerOne ?? data.creatorDid); 123 + const gamePlayerTwo = $derived(gameRecord?.playerTwo ?? data.playerTwoDid); 114 124 const gameWinner = $derived(gameRecord?.winner ?? null); 115 125 const gameBlackScore = $derived(gameRecord?.blackScore ?? null); 116 126 const gameWhiteScore = $derived(gameRecord?.whiteScore ?? null); ··· 124 134 const currentTurn = $derived(() => { 125 135 // Combine moves and passes, sort by moveNumber to get the last action 126 136 const allActions = [...moves, ...passes].sort((a, b) => b.moveNumber - a.moveNumber); 137 + const handicap = gameRecord?.handicap || 0; 127 138 128 139 if (allActions.length === 0) { 129 - // No moves yet, black (player_one) goes first 140 + // No moves yet - check if there's a handicap 141 + if (handicap > 0) { 142 + // With handicap, black stones are already placed, white plays first 143 + return 'white'; 144 + } 145 + // No handicap, black (player_one) goes first 130 146 return 'black'; 131 147 } 132 148 ··· 272 288 } 273 289 274 290 async function loadGameData() { 275 - // Resolve handles and fetch profiles (fire and forget) 276 - resolveDidToHandle(data.creatorDid).then((h) => { 277 - playerOneHandle = h; 278 - }); 279 - fetchUserProfile(data.creatorDid).then((p) => { 280 - playerOneProfile = p; 281 - }); 282 - fetchCloudGoProfile(data.creatorDid).then((p) => { 283 - playerOneCloudGoProfile = p; 284 - }); 285 - 286 - if (data.playerTwoDid) { 287 - resolveDidToHandle(data.playerTwoDid).then((h) => { 288 - playerTwoHandle = h; 289 - }); 290 - fetchUserProfile(data.playerTwoDid).then((p) => { 291 - playerTwoProfile = p; 292 - }); 293 - fetchCloudGoProfile(data.playerTwoDid).then((p) => { 294 - playerTwoCloudGoProfile = p; 295 - }); 296 - } 297 - 298 - // Fetch game record from PDS 291 + // Fetch game record from PDS first to get the correct player assignments 299 292 const record = await fetchGameRecord(data.creatorDid, data.gameRkey); 300 293 if (record) { 301 294 gameRecord = record; 302 - // If the PDS record has playerTwo but the DB didn't, resolve that handle and fetch profiles 303 - if (record.playerTwo && !data.playerTwoDid) { 295 + 296 + // Resolve handles and fetch profiles based on the game record's player assignments 297 + if (record.playerOne) { 298 + resolveDidToHandle(record.playerOne).then((h) => { 299 + playerOneHandle = h; 300 + }); 301 + fetchUserProfile(record.playerOne).then((p) => { 302 + playerOneProfile = p; 303 + }); 304 + fetchCloudGoProfile(record.playerOne).then((p) => { 305 + playerOneCloudGoProfile = p; 306 + }); 307 + } 308 + 309 + if (record.playerTwo) { 304 310 resolveDidToHandle(record.playerTwo).then((h) => { 305 311 playerTwoHandle = h; 306 312 }); ··· 315 321 loadingGame = false; 316 322 317 323 // Fetch moves, passes, and resigns from both players' PDS repos. 324 + // Use the actual player DIDs from the game record 325 + const playerOneDid = record?.playerOne || data.creatorDid; 326 + const playerTwoDid = record?.playerTwo || data.playerTwoDid; 327 + 318 328 const result = await fetchGameActionsFromPds( 319 - data.creatorDid, 320 - data.playerTwoDid, 329 + playerOneDid, 330 + playerTwoDid, 321 331 data.gameAtUri 322 332 ); 323 333 moves = result.moves; 324 334 passes = result.passes; 325 335 resigns = result.resigns; 326 336 loadingMoves = false; 337 + 338 + // Play sound when game opens 339 + soundManager.play('opened_game'); 327 340 328 341 // Check for move parameter in URL after moves are loaded 329 342 if (browser && moves.length > 0) { ··· 392 405 createdAt: new Date().toISOString(), 393 406 uri: result.uri, 394 407 }; 395 - moves = [...moves, newMove]; 408 + 409 + // Check for duplicates before adding 410 + const isDuplicate = moveExistsByUri(result.uri, moves) || moveExists({ 411 + moveNumber: newMove.moveNumber, 412 + x: newMove.x, 413 + y: newMove.y 414 + }, moves); 415 + 416 + if (!isDuplicate) { 417 + moves = [...moves, newMove]; 418 + } 396 419 } else { 397 420 alert('Failed to record move'); 398 421 // Reload to reset board state ··· 675 698 onMount(() => { 676 699 loadGameData(); 677 700 701 + // Initialize notifications 702 + notifications = new GameNotifications(); 703 + 704 + // Stop title flash when page becomes visible 705 + const cleanupVisibility = onVisibilityChange((visible) => { 706 + if (visible && notifications) { 707 + notifications.stopTitleFlash(); 708 + } 709 + }); 710 + 678 711 // Keyboard navigation for move history 679 712 const handleKeyPress = (e: KeyboardEvent) => { 680 713 if (moves.length === 0) return; ··· 714 747 }, moves); 715 748 716 749 if (isDuplicateByUri || isDuplicateByCoords) { 717 - console.log('Skipping duplicate move from Jetstream:', update.record.moveNumber); 718 750 return; // Skip this update 719 751 } 720 752 ··· 738 770 setTimeout(() => { 739 771 showMoveNotification = false; 740 772 }, 2000); 773 + 774 + // Check if it's now the current user's turn 775 + const newTurn = update.record.color === 'black' ? 'white' : 'black'; 776 + const isNowMyTurn = (newTurn === 'black' && data.session.did === data.creatorDid) || 777 + (newTurn === 'white' && data.session.did === data.playerTwoDid); 778 + 779 + if (isNowMyTurn && notifications) { 780 + // Get opponent handle for notification 781 + const opponentHandle = update.record.player === data.creatorDid 782 + ? playerOneHandle 783 + : playerTwoHandle; 784 + notifications.notifyYourTurn(opponentHandle || undefined); 785 + } 741 786 } 742 787 } 743 788 } else if (update.type === 'pass') { ··· 777 822 778 823 return () => { 779 824 window.removeEventListener('keydown', handleKeyPress); 825 + cleanupVisibility(); 780 826 }; 781 827 }); 782 828 783 829 onDestroy(() => { 784 830 if (firehose) { 785 831 firehose.disconnect(); 832 + } 833 + if (notifications) { 834 + notifications.cleanup(); 786 835 } 787 836 }); 788 837 ··· 816 865 } 817 866 818 867 // Adapt moves to the format the Board component expects 819 - const boardMoves = $derived(moves.map((m) => ({ 820 - id: '', 821 - rkey: '', 822 - game_id: data.gameAtUri, 823 - player: m.player, 824 - move_number: m.moveNumber, 825 - x: m.x, 826 - y: m.y, 827 - color: m.color, 828 - capture_count: m.captureCount, 829 - created_at: m.createdAt, 830 - }))); 868 + const boardMoves = $derived.by(() => { 869 + const allMoves = []; 870 + 871 + // Add handicap stones first (if any) 872 + if (gameRecord?.handicap && gameRecord?.handicapStones) { 873 + for (const stone of gameRecord.handicapStones) { 874 + allMoves.push({ 875 + id: '', 876 + rkey: '', 877 + game_id: data.gameAtUri, 878 + player: gameRecord.playerOne, // Black player 879 + move_number: 0, // Handicap stones are move 0 880 + x: stone.x, 881 + y: stone.y, 882 + color: 'black' as const, 883 + capture_count: 0, 884 + created_at: gameRecord.createdAt, 885 + }); 886 + } 887 + } 888 + 889 + // Add regular moves 890 + allMoves.push(...moves.map((m) => ({ 891 + id: '', 892 + rkey: '', 893 + game_id: data.gameAtUri, 894 + player: m.player, 895 + move_number: m.moveNumber, 896 + x: m.x, 897 + y: m.y, 898 + color: m.color, 899 + capture_count: m.captureCount, 900 + created_at: m.createdAt, 901 + }))); 902 + 903 + return allMoves; 904 + }); 905 + 906 + // Moves for display in the move history (includes handicap stones) 907 + const displayMoves = $derived.by(() => { 908 + const allMoves = []; 909 + 910 + // Add handicap stones for display 911 + if (gameRecord?.handicap && gameRecord?.handicapStones) { 912 + gameRecord.handicapStones.forEach((stone, index) => { 913 + allMoves.push({ 914 + uri: '', 915 + player: gameRecord.playerOne, 916 + moveNumber: 0, 917 + x: stone.x, 918 + y: stone.y, 919 + color: 'black' as const, 920 + captureCount: 0, 921 + createdAt: gameRecord.createdAt, 922 + isHandicap: true, 923 + handicapIndex: index, 924 + }); 925 + }); 926 + } 927 + 928 + // Add regular moves 929 + allMoves.push(...moves); 930 + 931 + return allMoves; 932 + }); 831 933 832 934 function getShareUrl(): string { 833 935 const gameUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/game/${data.gameRkey}`; ··· 1315 1417 {/if} 1316 1418 1317 1419 <!-- Move History --> 1318 - {#if moves.length > 0 || passes.length > 0 || resigns.length > 0} 1420 + {#if displayMoves.length > 0 || passes.length > 0 || resigns.length > 0} 1319 1421 <div class="move-history cloud-card"> 1320 1422 <div class="move-history-header"> 1321 1423 <h3>Move History</h3> ··· 1326 1428 {/if} 1327 1429 </div> 1328 1430 <div class="moves-list"> 1329 - {#each moves as move, index} 1431 + {#each displayMoves as move, index} 1330 1432 <button 1331 1433 class="move-item" 1332 1434 class:selected={reviewMoveIndex === index} 1435 + class:handicap-move={move.isHandicap} 1333 1436 onclick={() => reviewMove(index)} 1334 1437 type="button" 1335 1438 > 1336 - <span class="move-number">#{move.moveNumber}</span> 1439 + <span class="move-number"> 1440 + {#if move.isHandicap} 1441 + H{move.handicapIndex + 1} 1442 + {:else} 1443 + #{move.moveNumber} 1444 + {/if} 1445 + </span> 1337 1446 <span class="move-coords"> 1338 1447 {move.color === 'black' ? '⚫' : '⚪'} 1339 1448 {formatMoveCoords(move.x, move.y)} ··· 1341 1450 <span class="captures">+{move.captureCount}</span> 1342 1451 {/if} 1343 1452 </span> 1344 - {#if getMoveEmojis(move).length > 0 || getReactionCount(move) > 0} 1453 + {#if !move.isHandicap && (getMoveEmojis(move).length > 0 || getReactionCount(move) > 0)} 1345 1454 <span class="reaction-badge"> 1346 1455 {#each getMoveEmojis(move) as emoji} 1347 1456 <span class="reaction-emoji">{emoji}</span> ··· 2484 2593 background: var(--sky-white); 2485 2594 transform: none; 2486 2595 box-shadow: none; 2596 + } 2597 + 2598 + .move-item.handicap-move { 2599 + background: linear-gradient(135deg, var(--sky-cloud) 0%, var(--sky-white) 100%); 2600 + opacity: 0.8; 2601 + font-style: italic; 2602 + } 2603 + 2604 + .move-item.handicap-move:hover { 2605 + opacity: 1; 2606 + } 2607 + 2608 + .move-item.handicap-move.selected { 2609 + opacity: 1; 2487 2610 } 2488 2611 2489 2612 .move-number {