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 resign feature and redesign homepage sections

- Add boo.sky.go.resign lexicon for resign records
- Each player writes resign to own PDS (respects ownership)
- Add /api/games/[id]/cancel endpoint
- Update OAuth scope for resign collection
- Homepage: 3 sections (Current/Waiting/Archive)
- Game page: resign button, display in move history
- Firehose: listen for resign events
- Fix OAuth callback redirect handling with isRedirect()

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

+635 -58
+34
lexicons/boo.sky.go.resign.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "boo.sky.go.resign", 4 + "description": "A resignation or archive action in a Go game", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["game", "player", "color", "createdAt"], 12 + "properties": { 13 + "game": { 14 + "type": "string", 15 + "description": "AT URI of the game record" 16 + }, 17 + "player": { 18 + "type": "string", 19 + "description": "DID of the player who resigned/archived" 20 + }, 21 + "color": { 22 + "type": "string", 23 + "enum": ["black", "white"], 24 + "description": "Color of the resigning player" 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+28 -4
src/lib/atproto-client.ts
··· 1 - import type { GameRecord, MoveRecord, PassRecord } from './types'; 1 + import type { GameRecord, MoveRecord, PassRecord, ResignRecord } from './types'; 2 2 3 3 const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; 4 4 const PLC_DIRECTORY = 'https://plc.directory'; ··· 203 203 } 204 204 205 205 /** 206 - * Fallback: fetch moves/passes by listing records from both players' PDS repos. 206 + * Fallback: fetch moves/passes/resigns by listing records from both players' PDS repos. 207 207 * Used when Constellation is unavailable. 208 208 */ 209 209 export async function fetchGameActionsFromPds( 210 210 playerOneDid: string, 211 211 playerTwoDid: string | null, 212 212 gameAtUri: string 213 - ): Promise<{ moves: MoveRecord[]; passes: PassRecord[] }> { 213 + ): Promise<{ moves: MoveRecord[]; passes: PassRecord[]; resigns: ResignRecord[] }> { 214 214 const moves: MoveRecord[] = []; 215 215 const passes: PassRecord[] = []; 216 + const resigns: ResignRecord[] = []; 216 217 217 218 const dids = [playerOneDid]; 218 219 if (playerTwoDid) dids.push(playerTwoDid); ··· 264 265 } catch (err) { 265 266 console.error('Failed to list pass records from PDS for', did, err); 266 267 } 268 + 269 + // Fetch resigns 270 + try { 271 + const resignParams = new URLSearchParams({ 272 + repo: did, 273 + collection: 'boo.sky.go.resign', 274 + limit: '100', 275 + }); 276 + const resignRes = await fetch( 277 + `${pds}/xrpc/com.atproto.repo.listRecords?${resignParams}` 278 + ); 279 + if (resignRes.ok) { 280 + const data = await resignRes.json(); 281 + for (const rec of data.records || []) { 282 + if (rec.value?.game === gameAtUri) { 283 + resigns.push(rec.value as ResignRecord); 284 + } 285 + } 286 + } 287 + } catch (err) { 288 + console.error('Failed to list resign records from PDS for', did, err); 289 + } 267 290 } 268 291 269 292 moves.sort((a, b) => a.moveNumber - b.moveNumber); 270 293 passes.sort((a, b) => a.moveNumber - b.moveNumber); 294 + resigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); 271 295 272 - return { moves, passes }; 296 + return { moves, passes, resigns }; 273 297 }
+12 -1
src/lib/firehose.ts
··· 13 13 } 14 14 15 15 export interface FirehoseUpdate { 16 - type: 'move' | 'pass' | 'game'; 16 + type: 'move' | 'pass' | 'game' | 'resign'; 17 17 record: any; 18 18 uri: string; 19 19 } ··· 64 64 jetstreamUrl.searchParams.append('wantedCollections', 'boo.sky.go.move'); 65 65 jetstreamUrl.searchParams.append('wantedCollections', 'boo.sky.go.pass'); 66 66 jetstreamUrl.searchParams.append('wantedCollections', 'boo.sky.go.game'); 67 + jetstreamUrl.searchParams.append('wantedCollections', 'boo.sky.go.resign'); 67 68 68 69 // Filter by the DIDs of the players 69 70 jetstreamUrl.searchParams.append('wantedDids', this.playerOneDid); ··· 168 169 type, 169 170 record: commit.record, 170 171 uri: recordUri 172 + }); 173 + } 174 + } else if (commit.collection === 'boo.sky.go.resign') { 175 + // Check if this resignation belongs to our game 176 + if (commit.record?.game === this.gameUri) { 177 + console.log('Resignation detected from Jetstream:', commit.record); 178 + this.onUpdate({ 179 + type: 'resign', 180 + record: commit.record, 181 + uri: `at://${event.did}/${commit.collection}/${commit.rkey}` 171 182 }); 172 183 } 173 184 }
+1 -1
src/lib/server/auth.ts
··· 46 46 client_id: new URL('/oauth-client-metadata.json', publicUrl).href, 47 47 client_name: 'ATProtoGo - Decentralized Go Game', 48 48 redirect_uris: [new URL('/auth/callback', publicUrl).href], 49 - scope: 'atproto repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create', 49 + scope: 'atproto repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create', 50 50 jwks_uri: new URL('/jwks.json', publicUrl).href, 51 51 }, 52 52
+8
src/lib/types.ts
··· 34 34 color: 'black' | 'white'; 35 35 createdAt: string; 36 36 } 37 + 38 + export interface ResignRecord { 39 + $type: 'boo.sky.go.resign'; 40 + game: string; // AT URI of the game 41 + player: string; 42 + color: 'black' | 'white'; 43 + createdAt: string; 44 + }
+4 -4
src/routes/+page.server.ts
··· 7 7 const session = await getSession(event); 8 8 const db = getDb(); 9 9 10 + // Fetch all games from index (filtering/sorting done client-side) 10 11 const games = await db 11 12 .selectFrom('games') 12 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 13 - .where('status', 'in', ['waiting', 'active']) 14 - .orderBy('created_at', 'desc') 15 - .limit(20) 13 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type']) 14 + .orderBy('updated_at', 'desc') 15 + .limit(100) 16 16 .execute(); 17 17 18 18 const gamesWithTitles = games.map((game) => ({
+293 -36
src/routes/+page.svelte
··· 13 13 let moveCounts = $state<Record<string, number | null>>({}); 14 14 let handles = $state<Record<string, string>>({}); 15 15 let sessionHandle = $state<string | null>(null); 16 + let showMyGamesOnly = $state(false); 17 + 18 + // Split games by status 19 + const currentGames = $derived( 20 + (data.games || []) 21 + .filter((g) => g.status === 'active') 22 + .filter((g) => !showMyGamesOnly || !data.session || g.player_one === data.session.did || g.player_two === data.session.did) 23 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 24 + ); 25 + 26 + const waitingGames = $derived( 27 + (data.games || []) 28 + .filter((g) => g.status === 'waiting') 29 + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) 30 + ); 31 + 32 + const archivedGames = $derived( 33 + (data.games || []) 34 + .filter((g) => g.status === 'completed') 35 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 36 + ); 37 + 38 + // Helper to extract resigned info from last_action_type (format: "resigned:black" or "resigned:white") 39 + function getResignedBy(game: typeof data.games[0]): 'black' | 'white' | null { 40 + if (game.last_action_type?.startsWith('resigned:')) { 41 + return game.last_action_type.split(':')[1] as 'black' | 'white'; 42 + } 43 + return null; 44 + } 16 45 17 46 onMount(() => { 18 47 // Resolve session handle ··· 132 161 {#if !spectating} 133 162 <!-- Login Form --> 134 163 <div class="login-card"> 135 - <h2>Login with Bluesky</h2> 164 + <h2>Login with @proto</h2> 136 165 <form onsubmit={(e) => { e.preventDefault(); login(); }}> 137 166 <input 138 167 type="text" ··· 155 184 <span>Want to play? <button class="login-banner-link" onclick={() => spectating = false}>Login with Bluesky</button></span> 156 185 </div> 157 186 158 - <div class="card" id="games"> 159 - <h2>Active Games</h2> 160 - {#if data.games && data.games.length > 0} 187 + <div class="games-layout"> 188 + <!-- Current Games --> 189 + <div class="card current-games"> 190 + <h2>Current Games</h2> 191 + {#if currentGames.length > 0} 192 + <div class="games-list"> 193 + {#each currentGames as game} 194 + <div class="game-item"> 195 + <div class="game-info"> 196 + <div class="game-title">{game.title}</div> 197 + <div> 198 + <strong>{game.board_size}x{game.board_size}</strong> board 199 + <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 200 + </div> 201 + <div class="game-players"> 202 + 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> 203 + {#if game.player_two} 204 + <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> 205 + {/if} 206 + </div> 207 + </div> 208 + <a href="/game/{game.rkey}" class="button button-secondary button-sm"> 209 + Watch 210 + </a> 211 + </div> 212 + {/each} 213 + </div> 214 + {:else} 215 + <p class="empty-state">No active games right now.</p> 216 + {/if} 217 + </div> 218 + 219 + <!-- Waiting Games --> 220 + <div class="card waiting-games"> 221 + <h2>Waiting for Players</h2> 222 + {#if waitingGames.length > 0} 223 + <div class="waiting-games-grid"> 224 + {#each waitingGames as game} 225 + <div class="game-item-compact"> 226 + <div class="game-title">{game.title}</div> 227 + <div class="game-meta"> 228 + <strong>{game.board_size}x{game.board_size}</strong> 229 + <span class="player-link-small">by {handles[game.player_one] || game.player_one.slice(0, 20)}</span> 230 + </div> 231 + <a href="/game/{game.rkey}" class="button button-secondary button-sm">Watch</a> 232 + </div> 233 + {/each} 234 + </div> 235 + {:else} 236 + <p class="empty-state">No games waiting for players.</p> 237 + {/if} 238 + </div> 239 + </div> 240 + 241 + <!-- Archive --> 242 + {#if archivedGames.length > 0} 243 + <div class="card archive-section"> 244 + <h2>Archive</h2> 161 245 <div class="games-list"> 162 - {#each data.games as game} 246 + {#each archivedGames as game} 247 + {@const resignedBy = getResignedBy(game)} 163 248 <div class="game-item"> 164 249 <div class="game-info"> 165 250 <div class="game-title">{game.title}</div> 166 - <div class="game-status game-status-{game.status}"> 167 - {game.status} 251 + <div class="game-badges"> 252 + {#if resignedBy} 253 + <span class="game-status game-status-cancelled"> 254 + {resignedBy === 'black' ? '⚫' : '⚪'} resigned 255 + </span> 256 + {:else} 257 + <span class="game-status game-status-completed">completed</span> 258 + {/if} 168 259 </div> 169 260 <div> 170 261 <strong>{game.board_size}x{game.board_size}</strong> board ··· 178 269 </div> 179 270 </div> 180 271 <a href="/game/{game.rkey}" class="button button-secondary button-sm"> 181 - Watch 272 + View 182 273 </a> 183 274 </div> 184 275 {/each} 185 276 </div> 186 - {:else} 187 - <p class="empty-state">No active games right now.</p> 188 - {/if} 189 - </div> 277 + </div> 278 + {/if} 190 279 {/if} 191 280 {:else} 192 281 <!-- Logged In View --> ··· 217 306 </div> 218 307 </div> 219 308 220 - <!-- Active Games List --> 221 - <div class="card"> 222 - <h2>Available Games</h2> 223 - {#if data.games && data.games.length > 0} 309 + <div class="games-layout"> 310 + <!-- Current Games --> 311 + <div class="card current-games"> 312 + <div class="section-header"> 313 + <h2>Current Games</h2> 314 + <label class="toggle-label"> 315 + <input type="checkbox" bind:checked={showMyGamesOnly} /> 316 + My games only 317 + </label> 318 + </div> 319 + {#if currentGames.length > 0} 320 + <div class="games-list"> 321 + {#each currentGames as game} 322 + <div class="game-item"> 323 + <div class="game-info"> 324 + <div class="game-title">{game.title}</div> 325 + <div> 326 + <strong>{game.board_size}x{game.board_size}</strong> board 327 + <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 328 + </div> 329 + <div class="game-players"> 330 + 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> 331 + {#if game.player_two} 332 + <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> 333 + {/if} 334 + </div> 335 + </div> 336 + {#if game.player_one === data.session.did || game.player_two === data.session.did} 337 + <a href="/game/{game.rkey}" class="button button-primary button-sm"> 338 + Play 339 + </a> 340 + {:else} 341 + <a href="/game/{game.rkey}" class="button button-secondary button-sm"> 342 + Watch 343 + </a> 344 + {/if} 345 + </div> 346 + {/each} 347 + </div> 348 + {:else} 349 + <p class="empty-state">{showMyGamesOnly ? 'No active games you\'re in.' : 'No active games right now.'}</p> 350 + {/if} 351 + </div> 352 + 353 + <!-- Waiting Games --> 354 + <div class="card waiting-games"> 355 + <h2>Waiting for Players</h2> 356 + {#if waitingGames.length > 0} 357 + <div class="waiting-games-grid"> 358 + {#each waitingGames as game} 359 + <div class="game-item-compact"> 360 + <div class="game-title">{game.title}</div> 361 + <div class="game-meta"> 362 + <strong>{game.board_size}x{game.board_size}</strong> 363 + <span class="player-link-small">by {handles[game.player_one] || game.player_one.slice(0, 20)}</span> 364 + </div> 365 + {#if game.player_one === data.session.did} 366 + <a href="/game/{game.rkey}" class="button button-secondary button-sm">View</a> 367 + {:else} 368 + <button onclick={() => joinGame(game.rkey)} class="button button-primary button-sm">Join</button> 369 + {/if} 370 + </div> 371 + {/each} 372 + </div> 373 + {:else} 374 + <p class="empty-state">No games waiting for players.</p> 375 + {/if} 376 + </div> 377 + </div> 378 + 379 + <!-- Archive --> 380 + {#if archivedGames.length > 0} 381 + <div class="card archive-section"> 382 + <h2>Archive</h2> 224 383 <div class="games-list"> 225 - {#each data.games as game} 384 + {#each archivedGames as game} 385 + {@const resignedBy = getResignedBy(game)} 226 386 <div class="game-item"> 227 387 <div class="game-info"> 228 388 <div class="game-title">{game.title}</div> 229 - <div class="game-status game-status-{game.status}"> 230 - {game.status} 389 + <div class="game-badges"> 390 + {#if resignedBy} 391 + <span class="game-status game-status-cancelled"> 392 + {resignedBy === 'black' ? '⚫' : '⚪'} resigned 393 + </span> 394 + {:else} 395 + <span class="game-status game-status-completed">completed</span> 396 + {/if} 231 397 </div> 232 398 <div> 233 399 <strong>{game.board_size}x{game.board_size}</strong> board ··· 240 406 {/if} 241 407 </div> 242 408 </div> 243 - {#if game.status === 'waiting' && game.player_one !== data.session.did} 244 - <button 245 - onclick={() => joinGame(game.rkey)} 246 - class="button button-primary button-sm" 247 - > 248 - Join 249 - </button> 250 - {:else if game.player_one === data.session.did || game.player_two === data.session.did} 251 - <a href="/game/{game.rkey}" class="button button-secondary button-sm"> 252 - View 253 - </a> 254 - {/if} 409 + <a href="/game/{game.rkey}" class="button button-secondary button-sm"> 410 + View 411 + </a> 255 412 </div> 256 413 {/each} 257 414 </div> 258 - {:else} 259 - <p class="empty-state">No games available. Create one to get started!</p> 260 - {/if} 261 - </div> 415 + </div> 416 + {/if} 262 417 {/if} 263 418 </div> 264 419 265 420 <style> 266 421 .container { 267 - max-width: 800px; 422 + max-width: 1200px; 268 423 margin: 0 auto; 269 424 padding: 2rem; 270 425 } ··· 424 579 color: #4a5568; 425 580 } 426 581 582 + .game-status-cancelled { 583 + background: #fed7d7; 584 + color: #c53030; 585 + } 586 + 587 + .game-badges { 588 + display: flex; 589 + gap: 0.5rem; 590 + flex-wrap: wrap; 591 + } 592 + 427 593 .game-players { 428 594 font-size: 0.875rem; 429 595 color: #718096; ··· 506 672 color: #718096; 507 673 font-size: 0.875rem; 508 674 margin-left: 0.5rem; 675 + } 676 + 677 + .games-layout { 678 + display: grid; 679 + grid-template-columns: 1fr 1fr; 680 + gap: 1.5rem; 681 + margin-bottom: 1.5rem; 682 + } 683 + 684 + @media (max-width: 768px) { 685 + .games-layout { 686 + grid-template-columns: 1fr; 687 + } 688 + } 689 + 690 + .current-games, 691 + .waiting-games { 692 + min-height: 200px; 693 + } 694 + 695 + .section-header { 696 + display: flex; 697 + justify-content: space-between; 698 + align-items: center; 699 + margin-bottom: 1rem; 700 + } 701 + 702 + .section-header h2 { 703 + margin: 0; 704 + } 705 + 706 + .toggle-label { 707 + display: flex; 708 + align-items: center; 709 + gap: 0.5rem; 710 + font-size: 0.875rem; 711 + color: #718096; 712 + cursor: pointer; 713 + } 714 + 715 + .toggle-label input { 716 + cursor: pointer; 717 + } 718 + 719 + .waiting-games-grid { 720 + display: grid; 721 + grid-template-columns: 1fr; 722 + gap: 0.75rem; 723 + } 724 + 725 + .game-item-compact { 726 + display: flex; 727 + flex-wrap: wrap; 728 + align-items: center; 729 + gap: 0.5rem; 730 + padding: 0.75rem; 731 + border: 1px solid #e2e8f0; 732 + border-radius: 0.375rem; 733 + } 734 + 735 + .game-item-compact .game-title { 736 + flex: 1; 737 + min-width: 120px; 738 + } 739 + 740 + .game-meta { 741 + display: flex; 742 + flex-direction: column; 743 + gap: 0.25rem; 744 + font-size: 0.875rem; 745 + color: #718096; 746 + flex: 1; 747 + min-width: 100px; 748 + } 749 + 750 + .player-link-small { 751 + font-size: 0.75rem; 752 + color: #a0aec0; 753 + overflow: hidden; 754 + text-overflow: ellipsis; 755 + white-space: nowrap; 756 + } 757 + 758 + .archive-section { 759 + margin-top: 2rem; 760 + border-top: 2px solid #e2e8f0; 761 + padding-top: 1rem; 762 + } 763 + 764 + .archive-section h2 { 765 + color: #718096; 509 766 } 510 767 </style>
+100
src/routes/api/games/[id]/cancel/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 + import { getDb } from '$lib/server/db'; 5 + 6 + function generateTid(): string { 7 + const timestamp = Date.now() * 1000; 8 + const clockid = Math.floor(Math.random() * 1024); 9 + const tid = timestamp.toString(32).padStart(11, '0') + clockid.toString(32).padStart(2, '0'); 10 + return tid; 11 + } 12 + 13 + export const POST: RequestHandler = async (event) => { 14 + const session = await getSession(event); 15 + const { params } = event; 16 + 17 + if (!session) { 18 + throw error(401, 'Not authenticated'); 19 + } 20 + 21 + const { id: rkey } = params; 22 + 23 + try { 24 + const db = getDb(); 25 + 26 + const game = await db 27 + .selectFrom('games') 28 + .selectAll() 29 + .where('rkey', '=', rkey) 30 + .executeTakeFirst(); 31 + 32 + if (!game) { 33 + throw error(404, 'Game not found'); 34 + } 35 + 36 + if (game.status !== 'active') { 37 + throw error(400, 'Game is not active'); 38 + } 39 + 40 + // Check if the user is a player in this game 41 + const isPlayerOne = session.did === game.player_one; 42 + const isPlayerTwo = session.did === game.player_two; 43 + 44 + if (!isPlayerOne && !isPlayerTwo) { 45 + throw error(403, 'You are not a player in this game'); 46 + } 47 + 48 + // Determine which color the resigning player is 49 + const color = isPlayerOne ? 'black' : 'white'; 50 + 51 + const agent = await getAgent(event); 52 + if (!agent) { 53 + throw error(401, 'Failed to get authenticated agent'); 54 + } 55 + 56 + const resignRkey = generateTid(); 57 + const now = new Date().toISOString(); 58 + 59 + // Create resign record in the player's own PDS 60 + const resignRecord = { 61 + $type: 'boo.sky.go.resign', 62 + game: game.id, 63 + player: session.did, 64 + color, 65 + createdAt: now, 66 + }; 67 + 68 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 69 + input: { 70 + repo: session.did, 71 + collection: 'boo.sky.go.resign', 72 + rkey: resignRkey, 73 + record: resignRecord, 74 + }, 75 + }); 76 + 77 + if (!result.ok) { 78 + throw new Error(`Failed to create resign record: ${result.data.message}`); 79 + } 80 + 81 + // Update discovery index 82 + await db 83 + .updateTable('games') 84 + .set({ 85 + status: 'completed', 86 + last_action_type: `resigned:${color}`, 87 + updated_at: now, 88 + }) 89 + .where('rkey', '=', rkey) 90 + .execute(); 91 + 92 + return json({ success: true, color, uri: result.data.uri }); 93 + } catch (err) { 94 + console.error('Failed to cancel game:', err); 95 + if (err && typeof err === 'object' && 'status' in err) { 96 + throw err; 97 + } 98 + throw error(500, 'Failed to cancel game'); 99 + } 100 + };
+7 -7
src/routes/auth/callback/+server.ts
··· 1 - import { redirect, error } from '@sveltejs/kit'; 1 + import { redirect, error, isRedirect } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 3 import { getOAuthClient, setSession } from '$lib/server/auth'; 4 4 import { OAuthCallbackError } from '@atcute/oauth-node-client'; ··· 15 15 await setSession(event, { did: session.did }); 16 16 17 17 // Redirect to home page on success 18 - throw redirect(303, '/'); 18 + return redirect(303, '/'); 19 19 } catch (err) { 20 + // If it's already a redirect, rethrow it 21 + if (isRedirect(err)) { 22 + throw err; 23 + } 24 + 20 25 console.error('OAuth callback error:', err); 21 26 22 27 // Handle specific OAuth callback errors 23 28 if (err instanceof OAuthCallbackError) { 24 29 const errorMsg = encodeURIComponent(err.error); 25 30 throw redirect(303, `/?error=${errorMsg}`); 26 - } 27 - 28 - // If it's already a redirect, rethrow it 29 - if (err instanceof Response) { 30 - throw err; 31 31 } 32 32 33 33 // For other errors, redirect with generic error
+147 -4
src/routes/game/[id]/+page.svelte
··· 8 8 fetchGameRecord, 9 9 fetchGameActionsFromPds, 10 10 } from '$lib/atproto-client'; 11 - import type { MoveRecord, PassRecord, GameRecord } from '$lib/types'; 11 + import type { MoveRecord, PassRecord, GameRecord, ResignRecord } from '$lib/types'; 12 12 import { onMount, onDestroy } from 'svelte'; 13 13 14 14 let { data }: { data: PageData } = $props(); ··· 29 29 let gameRecord = $state<GameRecord | null>(null); 30 30 let moves = $state<MoveRecord[]>([]); 31 31 let passes = $state<PassRecord[]>([]); 32 + let resigns = $state<ResignRecord[]>([]); 32 33 let playerOneHandle = $state<string>(data.creatorDid); 33 34 let playerTwoHandle = $state<string | null>(data.playerTwoDid); 34 35 ··· 42 43 const gameWinner = $derived(gameRecord?.winner ?? null); 43 44 const gameBlackScore = $derived(gameRecord?.blackScore ?? null); 44 45 const gameWhiteScore = $derived(gameRecord?.whiteScore ?? null); 46 + // Get cancellation info from resign records (each player writes to their own PDS) 47 + const gameCancelledBy = $derived(resigns.length > 0 ? resigns[0].color : null); 48 + 49 + const isPlayer = $derived( 50 + data.session && (data.session.did === gamePlayerOne || data.session.did === gamePlayerTwo) 51 + ); 45 52 46 53 const isMyTurn = $derived(() => { 47 54 if (!data.session || gameStatus !== 'active') return false; ··· 98 105 } 99 106 } 100 107 101 - // Fetch moves and passes from both players' PDS repos. 108 + // Fetch moves, passes, and resigns from both players' PDS repos. 102 109 // Constellation backlinks don't embed record values, so we use 103 110 // listRecords on each player's PDS and filter by game URI. 104 111 const result = await fetchGameActionsFromPds( ··· 108 115 ); 109 116 moves = result.moves; 110 117 passes = result.passes; 118 + resigns = result.resigns; 111 119 112 120 loading = false; 113 121 } ··· 184 192 } catch (err) { 185 193 console.error('Failed to submit scores:', err); 186 194 alert('Failed to submit scores'); 195 + } finally { 196 + isSubmitting = false; 197 + } 198 + } 199 + 200 + async function handleCancel() { 201 + if (isSubmitting) return; 202 + 203 + const action = isMyTurn() ? 'resign from' : 'archive'; 204 + const confirmed = confirm(`Are you sure you want to ${action} this game? This cannot be undone.`); 205 + if (!confirmed) return; 206 + 207 + isSubmitting = true; 208 + try { 209 + const response = await fetch(`/api/games/${data.gameRkey}/cancel`, { 210 + method: 'POST', 211 + }); 212 + 213 + if (response.ok) { 214 + window.location.reload(); 215 + } else { 216 + alert('Failed to cancel game'); 217 + } 218 + } catch (err) { 219 + console.error('Failed to cancel game:', err); 220 + alert('Failed to cancel game'); 187 221 } finally { 188 222 isSubmitting = false; 189 223 } ··· 259 293 } 260 294 } else if (update.type === 'game') { 261 295 window.location.reload(); 296 + } else if (update.type === 'resign') { 297 + // Someone resigned - reload to show updated state 298 + window.location.reload(); 262 299 } 263 300 }, 264 301 (connected, error) => { ··· 374 411 </div> 375 412 376 413 {#if gameStatus === 'completed'} 377 - {#if gameBlackScore !== null && gameWhiteScore !== null} 414 + {#if resigns.length > 0} 415 + {@const resign = resigns[0]} 416 + <div class="info-card cancelled-card"> 417 + <h3>Game Ended</h3> 418 + <p class="cancelled-text"> 419 + {resign.color === 'black' ? '⚫' : '⚪'} 420 + {resign.color === 'black' ? playerOneHandle : playerTwoHandle} 421 + {resign.player === data.session?.did ? ' (you)' : ''} 422 + resigned 423 + </p> 424 + </div> 425 + {:else if gameBlackScore !== null && gameWhiteScore !== null} 378 426 <div class="info-card score-card"> 379 427 <h3>Final Scores</h3> 380 428 <p><span class="player-black">⚫</span> Black: {gameBlackScore}</p> ··· 435 483 436 484 {#if reviewMoveIndex === null && !isMyTurn() && gameStatus === 'active'} 437 485 <p class="turn-message">Waiting for opponent's move...</p> 486 + {/if} 487 + 488 + {#if gameStatus === 'active' && isPlayer && reviewMoveIndex === null} 489 + <div class="game-controls"> 490 + {#if isMyTurn()} 491 + <button 492 + onclick={handleCancel} 493 + disabled={isSubmitting} 494 + class="cancel-game-button resign-button" 495 + > 496 + Resign 497 + </button> 498 + {:else} 499 + <button 500 + onclick={handleCancel} 501 + disabled={isSubmitting} 502 + class="cancel-game-button archive-button" 503 + > 504 + Archive Game 505 + </button> 506 + {/if} 507 + </div> 438 508 {/if} 439 509 </div> 440 510 </div> ··· 496 566 {/if} 497 567 498 568 <!-- Move History --> 499 - {#if moves.length > 0 || passes.length > 0} 569 + {#if moves.length > 0 || passes.length > 0 || resigns.length > 0} 500 570 <div class="move-history"> 501 571 <div class="move-history-header"> 502 572 <h3>Move History</h3> ··· 529 599 <span class="move-number">#{pass.moveNumber}</span> 530 600 <span class="pass-indicator"> 531 601 {pass.color === 'black' ? '⚫' : '⚪'} Pass 602 + </span> 603 + </div> 604 + {/each} 605 + {#each resigns as resign} 606 + <div class="move-item resign-item"> 607 + <span class="resign-indicator"> 608 + {resign.color === 'black' ? '⚫' : '⚪'} Resigned 532 609 </span> 533 610 </div> 534 611 {/each} ··· 1035 1112 .pass-indicator { 1036 1113 color: #718096; 1037 1114 font-style: italic; 1115 + } 1116 + 1117 + .resign-item { 1118 + background: #fff5f5; 1119 + border-color: #fc8181; 1120 + cursor: default; 1121 + } 1122 + 1123 + .resign-item:hover { 1124 + background: #fff5f5; 1125 + transform: none; 1126 + box-shadow: none; 1127 + } 1128 + 1129 + .resign-indicator { 1130 + color: #c53030; 1131 + font-weight: 600; 1132 + } 1133 + 1134 + .game-controls { 1135 + margin-top: 1rem; 1136 + display: flex; 1137 + justify-content: center; 1138 + } 1139 + 1140 + .cancel-game-button { 1141 + padding: 0.5rem 1rem; 1142 + font-size: 0.875rem; 1143 + border: none; 1144 + border-radius: 0.375rem; 1145 + cursor: pointer; 1146 + font-weight: 600; 1147 + transition: all 0.2s; 1148 + } 1149 + 1150 + .cancel-game-button:disabled { 1151 + opacity: 0.5; 1152 + cursor: not-allowed; 1153 + } 1154 + 1155 + .resign-button { 1156 + background: #fc8181; 1157 + color: #742a2a; 1158 + } 1159 + 1160 + .resign-button:hover:not(:disabled) { 1161 + background: #f56565; 1162 + } 1163 + 1164 + .archive-button { 1165 + background: #e2e8f0; 1166 + color: #4a5568; 1167 + } 1168 + 1169 + .archive-button:hover:not(:disabled) { 1170 + background: #cbd5e0; 1171 + } 1172 + 1173 + .cancelled-card { 1174 + background: #fff5f5; 1175 + border: 2px solid #fc8181; 1176 + } 1177 + 1178 + .cancelled-text { 1179 + color: #c53030; 1180 + font-weight: 600; 1038 1181 } 1039 1182 </style>
+1 -1
src/routes/oauth-client-metadata.json/+server.ts
··· 10 10 'Access-Control-Allow-Origin': '*', 11 11 'Access-Control-Allow-Methods': 'GET, OPTIONS', 12 12 'Access-Control-Allow-Headers': 'Content-Type', 13 - 'Cache-Control': 'public, max-age=3600', 13 + 'Cache-Control': 'public, max-age=60', 14 14 }); 15 15 16 16 return json(oauth.metadata);