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.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 {