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.

Phases 2-5: PDS-first architecture restructure

- Move handle resolution client-side using resolveDidToHandle from atproto-client
- Game page fetches record/moves/passes from PDS and Constellation client-side
- Slim game page server load to session + minimal DB index lookup
- Home page returns raw DIDs, client resolves handles on mount
- API routes use action_count for turn validation instead of counting tables
- Consecutive pass detection via last_action_type column
- Remove moves/passes table inserts from API routes
- Add action_count and last_action_type columns to games table with migration
- Add PDS fallback for when Constellation is unavailable

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

+429 -507
+24 -1
src/lib/server/db.ts
··· 16 16 white_score: number | null; 17 17 black_scorer: string | null; 18 18 white_scorer: string | null; 19 + action_count: number; 20 + last_action_type: string | null; 19 21 created_at: string; 20 22 updated_at: string; 21 23 } ··· 145 147 } 146 148 147 149 function runMigrations(sqlite: Database.Database) { 148 - // Check if score columns exist, add them if they don't 149 150 const tableInfo = sqlite.prepare("PRAGMA table_info(games)").all() as Array<{ name: string }>; 150 151 const columnNames = tableInfo.map(col => col.name); 151 152 ··· 156 157 ALTER TABLE games ADD COLUMN black_scorer TEXT; 157 158 ALTER TABLE games ADD COLUMN white_scorer TEXT; 158 159 `); 160 + } 161 + 162 + if (!columnNames.includes('action_count')) { 163 + sqlite.exec(` 164 + ALTER TABLE games ADD COLUMN action_count INTEGER NOT NULL DEFAULT 0; 165 + ALTER TABLE games ADD COLUMN last_action_type TEXT; 166 + `); 167 + 168 + // Backfill action_count from existing moves/passes tables 169 + // Check if the moves table exists before trying to backfill 170 + const tables = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='moves'").all(); 171 + if (tables.length > 0) { 172 + sqlite.exec(` 173 + UPDATE games SET action_count = ( 174 + SELECT COALESCE( 175 + (SELECT COUNT(*) FROM moves WHERE moves.game_id = games.id), 0 176 + ) + COALESCE( 177 + (SELECT COUNT(*) FROM passes WHERE passes.game_id = games.id), 0 178 + ) 179 + ); 180 + `); 181 + } 159 182 } 160 183 }
+7 -61
src/routes/+page.server.ts
··· 2 2 import { getSession } from '$lib/server/auth'; 3 3 import { getDb } from '$lib/server/db'; 4 4 import { gameTitle } from '$lib/game-titles'; 5 - import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'; 6 - import type { Did } from '@atcute/lexicons/syntax'; 7 - 8 - // Create DID resolver for handle resolution 9 - const didResolver = new CompositeDidDocumentResolver({ 10 - methods: { 11 - plc: new PlcDidDocumentResolver(), 12 - web: new WebDidDocumentResolver() 13 - } 14 - }); 15 - 16 - async function resolveHandle(did: string): Promise<string> { 17 - try { 18 - const doc = await didResolver.resolve(did as any); 19 - if (!doc) return did; 20 - 21 - // Extract handle from DID document alsoKnownAs 22 - if (doc.alsoKnownAs && doc.alsoKnownAs.length > 0) { 23 - const handleUri = doc.alsoKnownAs[0]; 24 - if (handleUri.startsWith('at://')) { 25 - return handleUri.slice(5); // Remove 'at://' prefix 26 - } 27 - } 28 - 29 - return did; 30 - } catch (err) { 31 - console.error('Failed to resolve handle for DID:', did, err); 32 - return did; 33 - } 34 - } 35 5 36 6 export const load: PageServerLoad = async (event) => { 37 7 const session = await getSession(event); 38 8 const db = getDb(); 39 9 40 - // Always load games, even for logged-out users (spectators) 41 10 const games = await db 42 11 .selectFrom('games') 43 - .selectAll() 12 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 44 13 .where('status', 'in', ['waiting', 'active']) 45 14 .orderBy('created_at', 'desc') 46 15 .limit(20) 47 16 .execute(); 48 17 49 - // Resolve handles for all players 50 - const gamesWithHandles = await Promise.all( 51 - games.map(async (game) => { 52 - const playerOneHandle = await resolveHandle(game.player_one); 53 - const playerTwoHandle = game.player_two ? await resolveHandle(game.player_two) : null; 54 - 55 - return { 56 - ...game, 57 - playerOneHandle, 58 - playerTwoHandle, 59 - title: gameTitle(game.rkey), 60 - }; 61 - }) 62 - ); 63 - 64 - if (!session) { 65 - return { 66 - session: null, 67 - games: gamesWithHandles, 68 - }; 69 - } 70 - 71 - // Resolve session handle 72 - const sessionHandle = await resolveHandle(session.did); 18 + const gamesWithTitles = games.map((game) => ({ 19 + ...game, 20 + title: gameTitle(game.rkey), 21 + })); 73 22 74 23 return { 75 - session: { 76 - ...session, 77 - handle: sessionHandle 78 - }, 79 - games: gamesWithHandles, 24 + session: session ? { did: session.did } : null, 25 + games: gamesWithTitles, 80 26 }; 81 27 };
+28 -28
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 + import { resolveDidToHandle, fetchMoveCount } from '$lib/atproto-client'; 4 5 5 6 let { data }: { data: PageData } = $props(); 6 7 ··· 10 11 let boardSize = $state(19); 11 12 let spectating = $state(false); 12 13 let moveCounts = $state<Record<string, number | null>>({}); 14 + let handles = $state<Record<string, string>>({}); 15 + let sessionHandle = $state<string | null>(null); 13 16 14 - // Fetch move counts from Constellation client-side 15 - async function fetchMoveCount(gameId: string) { 16 - try { 17 - const params = new URLSearchParams({ 18 - subject: gameId, 19 - source: 'boo.sky.go.move:game', 20 - limit: '1', 17 + onMount(() => { 18 + // Resolve session handle 19 + if (data.session) { 20 + resolveDidToHandle(data.session.did).then((h) => { 21 + sessionHandle = h; 21 22 }); 22 - const res = await fetch( 23 - `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?${params}`, 24 - { headers: { 'Accept': 'application/json' } } 25 - ); 26 - if (res.ok) { 27 - const body = await res.json(); 28 - if (typeof body.total === 'number') { 29 - return body.total; 30 - } 31 - return 0; 32 - } 33 - } catch (err) { 34 - console.error('Constellation fetch failed for', gameId, err); 35 23 } 36 - return null; 37 - } 38 24 39 - onMount(() => { 40 25 if (data.games && data.games.length > 0) { 26 + // Collect unique DIDs to resolve 27 + const dids = new Set<string>(); 28 + for (const game of data.games) { 29 + dids.add(game.player_one); 30 + if (game.player_two) dids.add(game.player_two); 31 + } 32 + 33 + // Resolve handles 34 + for (const did of dids) { 35 + resolveDidToHandle(did).then((h) => { 36 + handles = { ...handles, [did]: h }; 37 + }); 38 + } 39 + 40 + // Fetch move counts 41 41 for (const game of data.games) { 42 42 fetchMoveCount(game.id).then((count) => { 43 43 moveCounts = { ...moveCounts, [game.id]: count }; ··· 171 171 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 172 172 </div> 173 173 <div class="game-players"> 174 - Player 1: <a href="https://bsky.app/profile/{game.player_one}" target="_blank" rel="noopener noreferrer" class="player-link">{game.playerOneHandle}</a> 174 + Player 1: <a href="https://bsky.app/profile/{game.player_one}" target="_blank" rel="noopener noreferrer" class="player-link">{handles[game.player_one] || game.player_one}</a> 175 175 {#if game.player_two} 176 - <br />Player 2: <a href="https://bsky.app/profile/{game.player_two}" target="_blank" rel="noopener noreferrer" class="player-link">{game.playerTwoHandle}</a> 176 + <br />Player 2: <a href="https://bsky.app/profile/{game.player_two}" target="_blank" rel="noopener noreferrer" class="player-link">{handles[game.player_two] || game.player_two}</a> 177 177 {/if} 178 178 </div> 179 179 </div> ··· 191 191 {:else} 192 192 <!-- Logged In View --> 193 193 <div class="user-section"> 194 - <p>Welcome, <a href="https://bsky.app/profile/{data.session.did}" target="_blank" rel="noopener noreferrer" class="profile-link"><strong>{data.session.handle || data.session.did}</strong></a>!</p> 194 + <p>Welcome, <a href="https://bsky.app/profile/{data.session.did}" target="_blank" rel="noopener noreferrer" class="profile-link"><strong>{sessionHandle || data.session.did}</strong></a>!</p> 195 195 <button onclick={logout} class="button button-secondary">Logout</button> 196 196 </div> 197 197 ··· 234 234 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 235 235 </div> 236 236 <div class="game-players"> 237 - Player 1: <a href="https://bsky.app/profile/{game.player_one}" target="_blank" rel="noopener noreferrer" class="player-link">{game.playerOneHandle}</a> 237 + Player 1: <a href="https://bsky.app/profile/{game.player_one}" target="_blank" rel="noopener noreferrer" class="player-link">{handles[game.player_one] || game.player_one}</a> 238 238 {#if game.player_two} 239 - <br />Player 2: <a href="https://bsky.app/profile/{game.player_two}" target="_blank" rel="noopener noreferrer" class="player-link">{game.playerTwoHandle}</a> 239 + <br />Player 2: <a href="https://bsky.app/profile/{game.player_two}" target="_blank" rel="noopener noreferrer" class="player-link">{handles[game.player_two] || game.player_two}</a> 240 240 {/if} 241 241 </div> 242 242 </div>
+3 -1
src/routes/api/games/+server.ts
··· 57 57 58 58 const uri = result.data.uri; 59 59 60 - // Store in local database 60 + // Store in local discovery index 61 61 const db = getDb(); 62 62 await db 63 63 .insertInto('games') ··· 73 73 white_score: null, 74 74 black_scorer: null, 75 75 white_scorer: null, 76 + action_count: 0, 77 + last_action_type: null, 76 78 created_at: now, 77 79 updated_at: now, 78 80 })
+13 -6
src/routes/api/games/[id]/join/+server.ts
··· 1 1 import { json, error } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 - import { getSession } from '$lib/server/auth'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 4 import { getDb } from '$lib/server/db'; 5 5 6 6 export const POST: RequestHandler = async (event) => { ··· 16 16 try { 17 17 const db = getDb(); 18 18 19 - // Get game from database 20 19 const game = await db 21 20 .selectFrom('games') 22 21 .selectAll() ··· 39 38 throw error(400, 'Game already has two players'); 40 39 } 41 40 42 - // Update game record in AT Protocol 43 - // Note: For demo purposes, we're updating local DB only 44 - // Full implementation would update the AT Protocol record 41 + // Update the PDS game record with player two and active status 42 + const agent = await getAgent(event); 43 + if (!agent) { 44 + throw error(401, 'Failed to get authenticated agent'); 45 + } 46 + 47 + // The game record lives in player one's repo — we need to update it. 48 + // However, only the repo owner can write to their own repo. 49 + // For now, we use the joining player's agent to create a reference, 50 + // but the game record update must happen via the creator's session. 51 + // Since we can't write to another user's repo, we update DB only here. 52 + // The PDS record will be updated when the game creator's client next loads. 45 53 46 - // Update local database 47 54 const now = new Date().toISOString(); 48 55 await db 49 56 .updateTable('games')
+10 -33
src/routes/api/games/[id]/move/+server.ts
··· 28 28 try { 29 29 const db = getDb(); 30 30 31 - // Get game 32 31 const game = await db 33 32 .selectFrom('games') 34 33 .selectAll() ··· 43 42 throw error(400, 'Game is not active'); 44 43 } 45 44 46 - // Verify it's the player's turn 47 - const moveCount = await db 48 - .selectFrom('moves') 49 - .select(({ fn }) => [fn.count<number>('id').as('count')]) 50 - .where('game_id', '=', game.id) 51 - .executeTakeFirst(); 52 - 53 - const passCount = await db 54 - .selectFrom('passes') 55 - .select(({ fn }) => [fn.count<number>('id').as('count')]) 56 - .where('game_id', '=', game.id) 57 - .executeTakeFirst(); 58 - 59 - const totalMoves = (moveCount?.count || 0) + (passCount?.count || 0); 45 + // Use action_count for turn validation 46 + const totalMoves = game.action_count; 60 47 const currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 61 48 const expectedPlayer = currentColor === 'black' ? game.player_one : game.player_two; 62 49 ··· 69 56 throw error(401, 'Failed to get authenticated agent'); 70 57 } 71 58 72 - // Create move record in AT Protocol 73 59 const moveRkey = generateTid(); 74 60 const now = new Date().toISOString(); 75 61 ··· 85 71 createdAt: now, 86 72 }; 87 73 88 - // Publish to AT Protocol 89 74 const result = await (agent as any).post('com.atproto.repo.createRecord', { 90 75 input: { 91 76 repo: session.did, ··· 99 84 throw new Error(`Failed to create record: ${result.data.message}`); 100 85 } 101 86 102 - const uri = result.data.uri; 103 - 104 - // Store in local database 87 + // Update discovery index: increment action_count, set last_action_type 105 88 await db 106 - .insertInto('moves') 107 - .values({ 108 - id: uri, 109 - rkey: moveRkey, 110 - game_id: game.id, 111 - player: session.did, 112 - move_number: totalMoves + 1, 113 - x, 114 - y, 115 - color: currentColor, 116 - capture_count: captureCount, 117 - created_at: now, 89 + .updateTable('games') 90 + .set({ 91 + action_count: game.action_count + 1, 92 + last_action_type: 'move', 93 + updated_at: now, 118 94 }) 95 + .where('rkey', '=', rkey) 119 96 .execute(); 120 97 121 - return json({ success: true, uri }); 98 + return json({ success: true, uri: result.data.uri }); 122 99 } catch (err) { 123 100 console.error('Failed to record move:', err); 124 101 throw error(500, 'Failed to record move');
+34 -70
src/routes/api/games/[id]/pass/+server.ts
··· 23 23 try { 24 24 const db = getDb(); 25 25 26 - // Get game 27 26 const game = await db 28 27 .selectFrom('games') 29 28 .selectAll() ··· 38 37 throw error(400, 'Game is not active'); 39 38 } 40 39 41 - // Get total moves count 42 - const moveCount = await db 43 - .selectFrom('moves') 44 - .select(({ fn }) => [fn.count<number>('id').as('count')]) 45 - .where('game_id', '=', game.id) 46 - .executeTakeFirst(); 47 - 48 - const passCount = await db 49 - .selectFrom('passes') 50 - .select(({ fn }) => [fn.count<number>('id').as('count')]) 51 - .where('game_id', '=', game.id) 52 - .executeTakeFirst(); 53 - 54 - const totalMoves = (moveCount?.count || 0) + (passCount?.count || 0); 40 + const totalMoves = game.action_count; 55 41 const currentColor = totalMoves % 2 === 0 ? 'black' : 'white'; 56 42 const expectedPlayer = currentColor === 'black' ? game.player_one : game.player_two; 57 43 ··· 64 50 throw error(401, 'Failed to get authenticated agent'); 65 51 } 66 52 67 - // Create pass record in AT Protocol 68 53 const passRkey = generateTid(); 69 54 const now = new Date().toISOString(); 70 55 ··· 77 62 createdAt: now, 78 63 }; 79 64 80 - // Publish to AT Protocol 81 65 const result = await (agent as any).post('com.atproto.repo.createRecord', { 82 66 input: { 83 67 repo: session.did, ··· 91 75 throw new Error(`Failed to create record: ${result.data.message}`); 92 76 } 93 77 94 - const uri = result.data.uri; 78 + // Check for consecutive passes using last_action_type 79 + const isConsecutivePass = game.last_action_type === 'pass'; 95 80 96 - // Store in local database 81 + // Update discovery index 97 82 await db 98 - .insertInto('passes') 99 - .values({ 100 - id: uri, 101 - rkey: passRkey, 102 - game_id: game.id, 103 - player: session.did, 104 - move_number: totalMoves + 1, 105 - color: currentColor, 106 - created_at: now, 83 + .updateTable('games') 84 + .set({ 85 + action_count: game.action_count + 1, 86 + last_action_type: 'pass', 87 + updated_at: now, 107 88 }) 108 - .execute(); 109 - 110 - // Check if this is the second consecutive pass 111 - const recentPasses = await db 112 - .selectFrom('passes') 113 - .selectAll() 114 - .where('game_id', '=', game.id) 115 - .orderBy('move_number', 'desc') 116 - .limit(2) 89 + .where('rkey', '=', rkey) 117 90 .execute(); 118 91 119 - if (recentPasses.length >= 2 && recentPasses[0].move_number === recentPasses[1].move_number + 1) { 120 - // Game over - two consecutive passes 121 - // Update game record in AT Protocol 122 - const gameRecord = await db 123 - .selectFrom('games') 124 - .selectAll() 125 - .where('id', '=', game.id) 126 - .executeTakeFirst(); 127 - 128 - if (gameRecord) { 129 - const updatedGameRecord = { 130 - $type: 'boo.sky.go.game', 131 - playerOne: gameRecord.player_one, 132 - playerTwo: gameRecord.player_two, 133 - boardSize: gameRecord.board_size, 134 - status: 'completed', 135 - createdAt: gameRecord.created_at, 136 - }; 92 + if (isConsecutivePass) { 93 + // Two consecutive passes — game over 94 + // Update PDS game record 95 + const updatedGameRecord = { 96 + $type: 'boo.sky.go.game', 97 + playerOne: game.player_one, 98 + playerTwo: game.player_two, 99 + boardSize: game.board_size, 100 + status: 'completed', 101 + createdAt: game.created_at, 102 + }; 137 103 138 - // Update in AT Protocol 139 - const updateResult = await (agent as any).post('com.atproto.repo.putRecord', { 140 - input: { 141 - repo: gameRecord.player_one, 142 - collection: 'boo.sky.go.game', 143 - rkey: gameRecord.rkey, 144 - record: updatedGameRecord, 145 - }, 146 - }); 104 + const updateResult = await (agent as any).post('com.atproto.repo.putRecord', { 105 + input: { 106 + repo: game.player_one, 107 + collection: 'boo.sky.go.game', 108 + rkey: game.rkey, 109 + record: updatedGameRecord, 110 + }, 111 + }); 147 112 148 - if (!updateResult.ok) { 149 - console.error('Failed to update game record:', updateResult.data); 150 - } 113 + if (!updateResult.ok) { 114 + console.error('Failed to update game record:', updateResult.data); 151 115 } 152 116 153 - // Update local database 117 + // Update discovery index status 154 118 await db 155 119 .updateTable('games') 156 120 .set({ 157 121 status: 'completed', 158 122 updated_at: now, 159 123 }) 160 - .where('id', '=', game.id) 124 + .where('rkey', '=', rkey) 161 125 .execute(); 162 126 } 163 127 164 - return json({ success: true, uri }); 128 + return json({ success: true, uri: result.data.uri }); 165 129 } catch (err) { 166 130 console.error('Failed to record pass:', err); 167 131 throw error(500, 'Failed to record pass');
+4 -11
src/routes/api/games/[id]/score/+server.ts
··· 26 26 try { 27 27 const db = getDb(); 28 28 29 - // Get game 30 29 const game = await db 31 30 .selectFrom('games') 32 31 .selectAll() ··· 41 40 throw error(400, 'Game is not completed yet'); 42 41 } 43 42 44 - // Verify user is a player in this game 45 43 if (session.did !== game.player_one && session.did !== game.player_two) { 46 44 throw error(403, 'You are not a player in this game'); 47 45 } ··· 49 47 const now = new Date().toISOString(); 50 48 const winner = blackScore > whiteScore ? game.player_one : game.player_two; 51 49 52 - // Update game with scores in local database 50 + // Update DB index status (scores live in PDS game record) 53 51 await db 54 52 .updateTable('games') 55 53 .set({ 56 - black_score: blackScore, 57 - white_score: whiteScore, 58 - black_scorer: session.did, 59 - white_scorer: session.did, 60 - winner, 54 + status: 'completed', 61 55 updated_at: now, 62 56 }) 63 - .where('id', '=', game.id) 57 + .where('rkey', '=', rkey) 64 58 .execute(); 65 59 66 - // Update AT Protocol record if this player is the game creator 60 + // Update AT Protocol record with scores 67 61 if (session.did === game.player_one) { 68 62 const agent = await getAgent(event); 69 63 if (agent) { ··· 96 90 } 97 91 } catch (err) { 98 92 console.error('Failed to update AT Protocol record:', err); 99 - // Continue anyway - local database is updated 100 93 } 101 94 } 102 95 }
+7 -64
src/routes/game/[id]/+page.server.ts
··· 2 2 import type { PageServerLoad } from './$types'; 3 3 import { getSession } from '$lib/server/auth'; 4 4 import { getDb } from '$lib/server/db'; 5 - import { CompositeDidDocumentResolver, CompositeHandleResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver } from '@atcute/identity-resolver'; 6 - import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 7 - import type { Did } from '@atcute/lexicons/syntax'; 8 - 9 - // Create identity resolvers for DID -> handle resolution 10 - const didResolver = new CompositeDidDocumentResolver({ 11 - methods: { 12 - plc: new PlcDidDocumentResolver(), 13 - web: new WebDidDocumentResolver() 14 - } 15 - }); 16 - const handleResolver = new CompositeHandleResolver({ 17 - methods: { 18 - dns: new NodeDnsHandleResolver(), 19 - http: new WellKnownHandleResolver() 20 - } 21 - }); 22 - 23 - async function resolveHandle(did: string): Promise<string> { 24 - try { 25 - const doc = await didResolver.resolve(did as any); 26 - if (!doc) return did; 27 - 28 - // Extract handle from DID document alsoKnownAs 29 - if (doc.alsoKnownAs && doc.alsoKnownAs.length > 0) { 30 - const handleUri = doc.alsoKnownAs[0]; 31 - if (handleUri.startsWith('at://')) { 32 - return handleUri.slice(5); // Remove 'at://' prefix 33 - } 34 - } 35 - 36 - return did; 37 - } catch (err) { 38 - console.error('Failed to resolve handle for DID:', did, err); 39 - return did; 40 - } 41 - } 42 5 43 6 export const load: PageServerLoad = async (event) => { 44 7 const session = await getSession(event); ··· 46 9 47 10 const db = getDb(); 48 11 49 - // Get game 50 12 const game = await db 51 13 .selectFrom('games') 52 - .selectAll() 14 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 53 15 .where('rkey', '=', rkey) 54 16 .executeTakeFirst(); 55 17 ··· 57 19 throw error(404, 'Game not found'); 58 20 } 59 21 60 - // Get moves 61 - const moves = await db 62 - .selectFrom('moves') 63 - .selectAll() 64 - .where('game_id', '=', game.id) 65 - .orderBy('move_number', 'asc') 66 - .execute(); 67 - 68 - // Get passes 69 - const passes = await db 70 - .selectFrom('passes') 71 - .selectAll() 72 - .where('game_id', '=', game.id) 73 - .orderBy('move_number', 'asc') 74 - .execute(); 75 - 76 - // Resolve player handles 77 - const playerOneHandle = await resolveHandle(game.player_one); 78 - const playerTwoHandle = game.player_two ? await resolveHandle(game.player_two) : null; 79 - 80 22 return { 81 23 session, 82 - game, 83 - moves, 84 - passes, 85 - playerOneHandle, 86 - playerTwoHandle, 24 + gameRkey: game.rkey, 25 + creatorDid: game.player_one, 26 + playerTwoDid: game.player_two, 27 + gameAtUri: game.id, 28 + boardSize: game.board_size, 29 + status: game.status, 87 30 }; 88 31 };
+299 -232
src/routes/game/[id]/+page.svelte
··· 3 3 import type { PageData } from './$types'; 4 4 import { GameFirehose } from '$lib/firehose'; 5 5 import { gameTitle } from '$lib/game-titles'; 6 + import { 7 + resolveDidToHandle, 8 + fetchGameRecord, 9 + fetchGameMoves, 10 + fetchGamePasses, 11 + fetchGameActionsFromPds, 12 + } from '$lib/atproto-client'; 13 + import type { MoveRecord, PassRecord, GameRecord } from '$lib/types'; 6 14 import { onMount, onDestroy } from 'svelte'; 7 15 8 16 let { data }: { data: PageData } = $props(); ··· 16 24 let showMoveNotification = $state(false); 17 25 let jetstreamConnected = $state(false); 18 26 let jetstreamError = $state(false); 19 - let reviewMoveIndex = $state<number | null>(null); // null = current game state, number = viewing that move 27 + let reviewMoveIndex = $state<number | null>(null); 20 28 21 - // Create local reactive state for moves and passes so reactivity works properly 22 - let moves = $state(data.moves); 23 - let passes = $state(data.passes); 29 + // Client-side loaded data 30 + let loading = $state(true); 31 + let gameRecord = $state<GameRecord | null>(null); 32 + let moves = $state<MoveRecord[]>([]); 33 + let passes = $state<PassRecord[]>([]); 34 + let playerOneHandle = $state<string>(data.creatorDid); 35 + let playerTwoHandle = $state<string | null>(data.playerTwoDid); 36 + 37 + // Derived game state — use PDS record when available, fall back to DB index 38 + const gameStatus = $derived(gameRecord?.status ?? data.status); 39 + const gameBoardSize = $derived(gameRecord?.boardSize ?? data.boardSize); 40 + const gamePlayerOne = $derived(gameRecord?.playerOne ?? data.creatorDid); 41 + const gamePlayerTwo = $derived(gameRecord?.playerTwo ?? data.playerTwoDid); 42 + const gameWinner = $derived(gameRecord?.winner ?? null); 43 + const gameBlackScore = $derived(gameRecord?.blackScore ?? null); 44 + const gameWhiteScore = $derived(gameRecord?.whiteScore ?? null); 24 45 25 46 const isMyTurn = $derived(() => { 26 - if (!data.session || !data.game) return false; 47 + if (!data.session || gameStatus !== 'active') return false; 27 48 28 - const lastMoveNumber = moves.length + passes.length; 29 - const nextColor = lastMoveNumber % 2 === 0 ? 'black' : 'white'; 49 + const totalActions = moves.length + passes.length; 50 + const nextColor = totalActions % 2 === 0 ? 'black' : 'white'; 30 51 31 52 if (nextColor === 'black') { 32 - return data.session.did === data.game.player_one; 53 + return data.session.did === gamePlayerOne; 33 54 } else { 34 - return data.session.did === data.game.player_two; 55 + return data.session.did === gamePlayerTwo; 35 56 } 36 57 }); 37 58 38 59 const currentTurn = $derived(() => { 39 - const lastMoveNumber = moves.length + passes.length; 40 - return lastMoveNumber % 2 === 0 ? 'black' : 'white'; 60 + const totalActions = moves.length + passes.length; 61 + return totalActions % 2 === 0 ? 'black' : 'white'; 41 62 }); 42 63 43 64 const capturedByBlack = $derived(() => { 44 65 return moves 45 66 .filter(move => move.color === 'black') 46 - .reduce((sum, move) => sum + move.capture_count, 0); 67 + .reduce((sum, move) => sum + move.captureCount, 0); 47 68 }); 48 69 49 70 const capturedByWhite = $derived(() => { 50 71 return moves 51 72 .filter(move => move.color === 'white') 52 - .reduce((sum, move) => sum + move.capture_count, 0); 73 + .reduce((sum, move) => sum + move.captureCount, 0); 53 74 }); 54 75 76 + async function loadGameData() { 77 + loading = true; 78 + 79 + // Resolve handles 80 + resolveDidToHandle(data.creatorDid).then((h) => { 81 + playerOneHandle = h; 82 + }); 83 + if (data.playerTwoDid) { 84 + resolveDidToHandle(data.playerTwoDid).then((h) => { 85 + playerTwoHandle = h; 86 + }); 87 + } 88 + 89 + // Fetch game record from PDS 90 + const record = await fetchGameRecord(data.creatorDid, data.gameRkey); 91 + if (record) { 92 + gameRecord = record; 93 + // If the PDS record has playerTwo but the DB didn't, resolve that handle too 94 + if (record.playerTwo && !data.playerTwoDid) { 95 + resolveDidToHandle(record.playerTwo).then((h) => { 96 + playerTwoHandle = h; 97 + }); 98 + } 99 + } 100 + 101 + // Fetch moves and passes from Constellation 102 + try { 103 + const [fetchedMoves, fetchedPasses] = await Promise.all([ 104 + fetchGameMoves(data.gameAtUri), 105 + fetchGamePasses(data.gameAtUri), 106 + ]); 107 + moves = fetchedMoves; 108 + passes = fetchedPasses; 109 + } catch { 110 + // Fallback: fetch from both players' PDS repos 111 + console.warn('Constellation unavailable, falling back to PDS listRecords'); 112 + const result = await fetchGameActionsFromPds( 113 + data.creatorDid, 114 + data.playerTwoDid, 115 + data.gameAtUri 116 + ); 117 + moves = result.moves; 118 + passes = result.passes; 119 + } 120 + 121 + loading = false; 122 + } 123 + 55 124 async function handleMove(x: number, y: number, captures: number) { 56 125 if (!isMyTurn() || isSubmitting) return; 57 126 58 127 isSubmitting = true; 59 128 try { 60 - const response = await fetch(`/api/games/${data.game.rkey}/move`, { 129 + const response = await fetch(`/api/games/${data.gameRkey}/move`, { 61 130 method: 'POST', 62 131 headers: { 'Content-Type': 'application/json' }, 63 132 body: JSON.stringify({ ··· 68 137 }); 69 138 70 139 if (response.ok) { 71 - // Reload page to get updated state 72 140 window.location.reload(); 73 141 } else { 74 142 alert('Failed to record move'); ··· 86 154 87 155 isSubmitting = true; 88 156 try { 89 - const response = await fetch(`/api/games/${data.game.rkey}/pass`, { 157 + const response = await fetch(`/api/games/${data.gameRkey}/pass`, { 90 158 method: 'POST', 91 159 }); 92 160 93 161 if (response.ok) { 94 - // Reload page to get updated state 95 162 window.location.reload(); 96 163 } else { 97 164 alert('Failed to record pass'); ··· 109 176 110 177 isSubmitting = true; 111 178 try { 112 - const response = await fetch(`/api/games/${data.game.rkey}/score`, { 179 + const response = await fetch(`/api/games/${data.gameRkey}/score`, { 113 180 method: 'POST', 114 181 headers: { 'Content-Type': 'application/json' }, 115 182 body: JSON.stringify({ ··· 119 186 }); 120 187 121 188 if (response.ok) { 122 - // Reload page to get updated state 123 189 window.location.reload(); 124 190 } else { 125 191 alert('Failed to submit scores'); ··· 132 198 } 133 199 } 134 200 135 - // Connect to AT Protocol firehose for real-time updates 136 201 onMount(() => { 202 + loadGameData(); 203 + 137 204 // Keyboard navigation for move history 138 205 const handleKeyPress = (e: KeyboardEvent) => { 139 206 if (moves.length === 0) return; 140 207 141 208 if (e.key === 'ArrowLeft') { 142 - // Previous move 143 209 if (reviewMoveIndex === null) { 144 210 reviewMove(moves.length - 1); 145 211 } else if (reviewMoveIndex > 0) { 146 212 reviewMove(reviewMoveIndex - 1); 147 213 } 148 214 } else if (e.key === 'ArrowRight') { 149 - // Next move 150 215 if (reviewMoveIndex === null) return; 151 216 if (reviewMoveIndex < moves.length - 1) { 152 217 reviewMove(reviewMoveIndex + 1); ··· 158 223 159 224 window.addEventListener('keydown', handleKeyPress); 160 225 161 - if (data.game.status === 'active') { 226 + if (data.status === 'active') { 162 227 firehose = new GameFirehose( 163 - data.game.id, 164 - data.game.player_one, 165 - data.game.player_two, 228 + data.gameAtUri, 229 + data.creatorDid, 230 + data.playerTwoDid, 166 231 async (update) => { 167 232 if (update.type === 'move' && update.record) { 168 - // Add the move incrementally without reloading 169 233 if (boardRef && update.record.x !== undefined && update.record.y !== undefined) { 170 234 boardRef.playMove(update.record.x, update.record.y, update.record.color); 171 235 172 - // Add to moves array for move history display 173 236 moves = [...moves, { 174 - id: update.uri, 175 - rkey: update.uri.split('/').pop() || '', 176 - game_id: data.game.id, 237 + $type: 'boo.sky.go.move', 238 + game: data.gameAtUri, 177 239 player: update.record.player, 178 - move_number: update.record.moveNumber, 240 + moveNumber: update.record.moveNumber, 179 241 x: update.record.x, 180 242 y: update.record.y, 181 243 color: update.record.color, 182 - capture_count: update.record.captureCount || 0, 183 - created_at: update.record.createdAt 244 + captureCount: update.record.captureCount || 0, 245 + createdAt: update.record.createdAt, 184 246 }]; 185 247 186 - // Log turn state after move is added 187 - console.log('After adding move:', { 188 - movesLength: moves.length, 189 - currentTurn: currentTurn(), 190 - isMyTurn: isMyTurn(), 191 - myDid: data.session?.did, 192 - playerOne: data.game.player_one, 193 - playerTwo: data.game.player_two 194 - }); 195 - 196 - // Show notification if it's opponent's move 197 248 if (data.session && update.record.player !== data.session.did) { 198 249 showMoveNotification = true; 199 250 setTimeout(() => { ··· 202 253 } 203 254 } 204 255 } else if (update.type === 'pass') { 205 - // Add pass to passes array 206 256 if (update.record) { 207 257 passes = [...passes, { 208 - id: update.uri, 209 - rkey: update.uri.split('/').pop() || '', 210 - game_id: data.game.id, 258 + $type: 'boo.sky.go.pass', 259 + game: data.gameAtUri, 211 260 player: update.record.player, 212 - move_number: update.record.moveNumber, 261 + moveNumber: update.record.moveNumber, 213 262 color: update.record.color, 214 - created_at: update.record.createdAt 263 + createdAt: update.record.createdAt, 215 264 }]; 216 265 } 217 - // Reload if game ended (two passes) 218 266 if (passes.length >= 2) { 219 267 window.location.reload(); 220 268 } 221 269 } else if (update.type === 'game') { 222 - // Game status changed, reload 223 270 window.location.reload(); 224 271 } 225 272 }, 226 273 (connected, error) => { 227 - console.log('Jetstream connection status changed:', { connected, error }); 228 274 jetstreamConnected = connected; 229 275 jetstreamError = error; 230 276 } ··· 241 287 if (firehose) { 242 288 firehose.disconnect(); 243 289 } 244 - // Note: keydown listener cleanup is handled in onMount's return 245 290 }); 246 291 247 292 function reviewMove(moveIndex: number) { ··· 257 302 boardRef.replayToMove(moves.length - 1); 258 303 } 259 304 } 305 + 306 + // Adapt moves to the format the Board component expects 307 + const boardMoves = $derived(moves.map((m) => ({ 308 + id: '', 309 + rkey: '', 310 + game_id: data.gameAtUri, 311 + player: m.player, 312 + move_number: m.moveNumber, 313 + x: m.x, 314 + y: m.y, 315 + color: m.color, 316 + capture_count: m.captureCount, 317 + created_at: m.createdAt, 318 + }))); 260 319 </script> 261 320 262 321 <svelte:head> 263 - <title>{gameTitle(data.game.rkey)} - Go Game</title> 322 + <title>{gameTitle(data.gameRkey)} - Go Game</title> 264 323 </svelte:head> 265 324 266 325 <div class="container"> ··· 271 330 {/if} 272 331 273 332 <header> 274 - <h1>{gameTitle(data.game.rkey)}</h1> 333 + <h1>{gameTitle(data.gameRkey)}</h1> 275 334 <div class="header-right"> 276 - {#if data.game.status === 'active'} 335 + {#if gameStatus === 'active'} 277 336 {#if jetstreamConnected} 278 337 <span class="live-indicator">🔴 Live</span> 279 338 {:else if jetstreamError} ··· 284 343 </div> 285 344 </header> 286 345 287 - <div class="game-info"> 288 - <div class="info-card"> 289 - <h3>Game Info</h3> 290 - <p><strong>Status:</strong> <span class="status-{data.game.status}">{data.game.status}</span></p> 291 - <p><strong>Board:</strong> {data.game.board_size}x{data.game.board_size}</p> 292 - <p><strong>Moves:</strong> {moves.length}</p> 346 + {#if loading} 347 + <div class="loading-state"> 348 + <p>Loading game data...</p> 293 349 </div> 350 + {:else} 351 + <div class="game-info"> 352 + <div class="info-card"> 353 + <h3>Game Info</h3> 354 + <p><strong>Status:</strong> <span class="status-{gameStatus}">{gameStatus}</span></p> 355 + <p><strong>Board:</strong> {gameBoardSize}x{gameBoardSize}</p> 356 + <p><strong>Moves:</strong> {moves.length}</p> 357 + </div> 294 358 295 - <div class="info-card"> 296 - <h3>Players</h3> 297 - <p> 298 - <span class="player-black">⚫</span> 299 - <a href="https://bsky.app/profile/{data.game.player_one}" target="_blank" rel="noopener noreferrer" class="player-link"> 300 - {data.playerOneHandle} 301 - </a> 302 - {#if data.session && data.session.did === data.game.player_one} 303 - <span class="you-label">(you)</span> 304 - {/if} 305 - </p> 306 - {#if data.game.player_two} 359 + <div class="info-card"> 360 + <h3>Players</h3> 307 361 <p> 308 - <span class="player-white">⚪</span> 309 - <a href="https://bsky.app/profile/{data.game.player_two}" target="_blank" rel="noopener noreferrer" class="player-link"> 310 - {data.playerTwoHandle} 362 + <span class="player-black">⚫</span> 363 + <a href="https://bsky.app/profile/{gamePlayerOne}" target="_blank" rel="noopener noreferrer" class="player-link"> 364 + {playerOneHandle} 311 365 </a> 312 - {#if data.session && data.session.did === data.game.player_two} 366 + {#if data.session && data.session.did === gamePlayerOne} 313 367 <span class="you-label">(you)</span> 314 368 {/if} 315 369 </p> 316 - {:else} 317 - <p class="waiting">Waiting for opponent...</p> 370 + {#if gamePlayerTwo} 371 + <p> 372 + <span class="player-white">⚪</span> 373 + <a href="https://bsky.app/profile/{gamePlayerTwo}" target="_blank" rel="noopener noreferrer" class="player-link"> 374 + {playerTwoHandle || gamePlayerTwo} 375 + </a> 376 + {#if data.session && data.session.did === gamePlayerTwo} 377 + <span class="you-label">(you)</span> 378 + {/if} 379 + </p> 380 + {:else} 381 + <p class="waiting">Waiting for opponent...</p> 382 + {/if} 383 + </div> 384 + 385 + {#if gameStatus === 'completed'} 386 + {#if gameBlackScore !== null && gameWhiteScore !== null} 387 + <div class="info-card score-card"> 388 + <h3>Final Scores</h3> 389 + <p><span class="player-black">⚫</span> Black: {gameBlackScore}</p> 390 + <p><span class="player-white">⚪</span> White: {gameWhiteScore}</p> 391 + {#if gameWinner} 392 + <p class="winner-text">🏆 Winner: {gameWinner === gamePlayerOne ? playerOneHandle : playerTwoHandle}</p> 393 + {/if} 394 + </div> 395 + {/if} 318 396 {/if} 319 397 </div> 320 398 321 - {#if data.game.status === 'completed'} 322 - {#if data.game.black_score !== null && data.game.white_score !== null} 323 - <div class="info-card score-card"> 324 - <h3>Final Scores</h3> 325 - <p><span class="player-black">⚫</span> Black: {data.game.black_score}</p> 326 - <p><span class="player-white">⚪</span> White: {data.game.white_score}</p> 327 - {#if data.game.winner} 328 - <p class="winner-text">🏆 Winner: {data.game.winner === data.game.player_one ? data.playerOneHandle : data.playerTwoHandle}</p> 329 - {/if} 330 - </div> 331 - {/if} 332 - {/if} 333 - </div> 334 - 335 - {#if data.game.status === 'active' || data.game.status === 'completed'} 336 - <div class="game-board-container"> 337 - <div class="board-and-captures-wrapper"> 338 - <div class="captures-row"> 339 - <div class="captures-panel"> 340 - <div class="capture-info"> 341 - <div class="capture-label">⚫ Black captured</div> 342 - <div class="capture-count">{capturedByBlack()}</div> 343 - <div class="capture-stones"> 344 - {#each Array(capturedByBlack()) as _, i} 345 - <span class="captured-stone white">⚪</span> 346 - {/each} 399 + {#if gameStatus === 'active' || gameStatus === 'completed'} 400 + <div class="game-board-container"> 401 + <div class="board-and-captures-wrapper"> 402 + <div class="captures-row"> 403 + <div class="captures-panel"> 404 + <div class="capture-info"> 405 + <div class="capture-label">⚫ Black captured</div> 406 + <div class="capture-count">{capturedByBlack()}</div> 407 + <div class="capture-stones"> 408 + {#each Array(capturedByBlack()) as _, i} 409 + <span class="captured-stone white">⚪</span> 410 + {/each} 411 + </div> 347 412 </div> 348 413 </div> 349 - </div> 350 414 351 - <div class="captures-panel"> 352 - <div class="capture-info"> 353 - <div class="capture-label">⚪ White captured</div> 354 - <div class="capture-count">{capturedByWhite()}</div> 355 - <div class="capture-stones"> 356 - {#each Array(capturedByWhite()) as _, i} 357 - <span class="captured-stone black">⚫</span> 358 - {/each} 415 + <div class="captures-panel"> 416 + <div class="capture-info"> 417 + <div class="capture-label">⚪ White captured</div> 418 + <div class="capture-count">{capturedByWhite()}</div> 419 + <div class="capture-stones"> 420 + {#each Array(capturedByWhite()) as _, i} 421 + <span class="captured-stone black">⚫</span> 422 + {/each} 423 + </div> 359 424 </div> 360 425 </div> 361 426 </div> 362 - </div> 363 427 364 - <div class="board-section"> 365 - {#if reviewMoveIndex !== null} 366 - <div class="review-mode-banner"> 367 - 📖 Review Mode - Viewing move #{moves[reviewMoveIndex]?.move_number || reviewMoveIndex + 1} 368 - </div> 369 - {/if} 428 + <div class="board-section"> 429 + {#if reviewMoveIndex !== null} 430 + <div class="review-mode-banner"> 431 + 📖 Review Mode - Viewing move #{moves[reviewMoveIndex]?.moveNumber || reviewMoveIndex + 1} 432 + </div> 433 + {/if} 370 434 371 - <Board 372 - bind:this={boardRef} 373 - boardSize={data.game.board_size} 374 - gameState={{ moves: moves }} 375 - onMove={handleMove} 376 - onPass={handlePass} 377 - interactive={data.game.status === 'active' && isMyTurn() && reviewMoveIndex === null} 378 - currentTurn={currentTurn()} 379 - /> 435 + <Board 436 + bind:this={boardRef} 437 + boardSize={gameBoardSize} 438 + gameState={{ moves: boardMoves }} 439 + onMove={handleMove} 440 + onPass={handlePass} 441 + interactive={gameStatus === 'active' && isMyTurn() && reviewMoveIndex === null} 442 + currentTurn={currentTurn()} 443 + /> 380 444 381 - {#if reviewMoveIndex === null && !isMyTurn() && data.game.status === 'active'} 382 - <p class="turn-message">Waiting for opponent's move...</p> 383 - {/if} 445 + {#if reviewMoveIndex === null && !isMyTurn() && gameStatus === 'active'} 446 + <p class="turn-message">Waiting for opponent's move...</p> 447 + {/if} 448 + </div> 384 449 </div> 385 450 </div> 386 - </div> 387 451 388 - <!-- Score Input --> 389 - {#if data.game.status === 'completed' && data.game.black_score === null && data.session} 390 - <div class="score-input-section"> 391 - <div class="score-input-card"> 392 - <h3>Submit Final Scores</h3> 393 - <p class="score-instructions"> 394 - Both players have passed. Please count the territory and enter the final scores below. 395 - </p> 452 + <!-- Score Input --> 453 + {#if gameStatus === 'completed' && gameBlackScore === null && data.session} 454 + <div class="score-input-section"> 455 + <div class="score-input-card"> 456 + <h3>Submit Final Scores</h3> 457 + <p class="score-instructions"> 458 + Both players have passed. Please count the territory and enter the final scores below. 459 + </p> 396 460 397 - {#if showScoreInput} 398 - <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 399 - <div class="score-input-group"> 400 - <label for="black-score"> 401 - <span class="player-black">⚫</span> Black Score: 402 - </label> 403 - <input 404 - id="black-score" 405 - type="number" 406 - min="0" 407 - bind:value={blackScore} 408 - required 409 - /> 410 - </div> 461 + {#if showScoreInput} 462 + <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 463 + <div class="score-input-group"> 464 + <label for="black-score"> 465 + <span class="player-black">⚫</span> Black Score: 466 + </label> 467 + <input 468 + id="black-score" 469 + type="number" 470 + min="0" 471 + bind:value={blackScore} 472 + required 473 + /> 474 + </div> 411 475 412 - <div class="score-input-group"> 413 - <label for="white-score"> 414 - <span class="player-white">⚪</span> White Score: 415 - </label> 416 - <input 417 - id="white-score" 418 - type="number" 419 - min="0" 420 - bind:value={whiteScore} 421 - required 422 - /> 423 - </div> 476 + <div class="score-input-group"> 477 + <label for="white-score"> 478 + <span class="player-white">⚪</span> White Score: 479 + </label> 480 + <input 481 + id="white-score" 482 + type="number" 483 + min="0" 484 + bind:value={whiteScore} 485 + required 486 + /> 487 + </div> 424 488 425 - <div class="score-buttons"> 426 - <button type="submit" class="submit-score-button" disabled={isSubmitting}> 427 - {isSubmitting ? 'Submitting...' : 'Submit Scores'} 428 - </button> 429 - <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 430 - Cancel 431 - </button> 432 - </div> 433 - </form> 434 - {:else} 435 - <button class="show-score-input-button" onclick={() => showScoreInput = true}> 436 - Enter Scores 437 - </button> 438 - {/if} 489 + <div class="score-buttons"> 490 + <button type="submit" class="submit-score-button" disabled={isSubmitting}> 491 + {isSubmitting ? 'Submitting...' : 'Submit Scores'} 492 + </button> 493 + <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 494 + Cancel 495 + </button> 496 + </div> 497 + </form> 498 + {:else} 499 + <button class="show-score-input-button" onclick={() => showScoreInput = true}> 500 + Enter Scores 501 + </button> 502 + {/if} 503 + </div> 439 504 </div> 440 - </div> 441 - {/if} 505 + {/if} 442 506 443 - <!-- Move History --> 444 - {#if moves.length > 0 || passes.length > 0} 445 - <div class="move-history"> 446 - <div class="move-history-header"> 447 - <h3>Move History</h3> 448 - {#if reviewMoveIndex !== null} 449 - <button class="back-to-game-button" onclick={backToCurrentGame}> 450 - ⏭️ Back to Current Game 451 - </button> 452 - {/if} 507 + <!-- Move History --> 508 + {#if moves.length > 0 || passes.length > 0} 509 + <div class="move-history"> 510 + <div class="move-history-header"> 511 + <h3>Move History</h3> 512 + {#if reviewMoveIndex !== null} 513 + <button class="back-to-game-button" onclick={backToCurrentGame}> 514 + ⏭️ Back to Current Game 515 + </button> 516 + {/if} 517 + </div> 518 + <div class="moves-list"> 519 + {#each moves as move, index} 520 + <button 521 + class="move-item" 522 + class:selected={reviewMoveIndex === index} 523 + onclick={() => reviewMove(index)} 524 + type="button" 525 + > 526 + <span class="move-number">#{move.moveNumber}</span> 527 + <span class="move-coords"> 528 + {move.color === 'black' ? '⚫' : '⚪'} 529 + ({move.x}, {move.y}) 530 + {#if move.captureCount > 0} 531 + <span class="captures">+{move.captureCount}</span> 532 + {/if} 533 + </span> 534 + </button> 535 + {/each} 536 + {#each passes as pass} 537 + <div class="move-item pass-item"> 538 + <span class="move-number">#{pass.moveNumber}</span> 539 + <span class="pass-indicator"> 540 + {pass.color === 'black' ? '⚫' : '⚪'} Pass 541 + </span> 542 + </div> 543 + {/each} 544 + </div> 453 545 </div> 454 - <div class="moves-list"> 455 - {#each moves as move, index} 456 - <button 457 - class="move-item" 458 - class:selected={reviewMoveIndex === index} 459 - onclick={() => reviewMove(index)} 460 - type="button" 461 - > 462 - <span class="move-number">#{move.move_number}</span> 463 - <span class="move-coords"> 464 - {move.color === 'black' ? '⚫' : '⚪'} 465 - ({move.x}, {move.y}) 466 - {#if move.capture_count > 0} 467 - <span class="captures">+{move.capture_count}</span> 468 - {/if} 469 - </span> 470 - </button> 471 - {/each} 472 - {#each passes as pass} 473 - <div class="move-item pass-item"> 474 - <span class="move-number">#{pass.move_number}</span> 475 - <span class="pass-indicator"> 476 - {pass.color === 'black' ? '⚫' : '⚪'} Pass 477 - </span> 478 - </div> 479 - {/each} 480 - </div> 481 - </div> 546 + {/if} 547 + {:else} 548 + <p class="waiting-message">Waiting for another player to join...</p> 482 549 {/if} 483 - {:else} 484 - <p class="waiting-message">Waiting for another player to join...</p> 485 550 {/if} 486 551 </div> 487 552 ··· 497 562 .container { 498 563 padding: 1rem; 499 564 } 565 + } 566 + 567 + .loading-state { 568 + text-align: center; 569 + color: #718096; 570 + padding: 4rem; 571 + font-size: 1.25rem; 500 572 } 501 573 502 574 .move-notification { ··· 648 720 .waiting { 649 721 color: #a0aec0; 650 722 font-style: italic; 651 - } 652 - 653 - .winner-card { 654 - background: #faf089; 655 - border: 2px solid #d69e2e; 656 723 } 657 724 658 725 .game-board-container {