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 ATProtocol data fetching functions for database migration

- Add fetchGameResigns() to get resign records from Constellation
- Add calculateGameMetadata() to compute action counts and last action
- Add listPlayerGames() to fetch all games from a player's PDS
- Add fetchAllGames() to aggregate games from multiple players
- Add GameWithMetadata interface with computed fields

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

+264 -9
+264 -9
src/lib/atproto-client.ts
··· 1 - import type { GameRecord, MoveRecord, PassRecord, ResignRecord, ReactionRecord } from './types'; 1 + import type { GameRecord, MoveRecord, PassRecord, ResignRecord, ReactionRecord, ProfileRecord } from './types'; 2 2 3 3 const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; 4 4 const PLC_DIRECTORY = 'https://plc.directory'; ··· 18 18 } 19 19 20 20 async function fetchDidDocument(did: string): Promise<DidDocument | null> { 21 + console.log('[fetchDidDocument] Fetching DID document for:', did); 22 + 21 23 try { 22 24 if (did.startsWith('did:plc:')) { 23 - const res = await fetch(`${PLC_DIRECTORY}/${did}`); 24 - if (res.ok) return await res.json(); 25 + const url = `${PLC_DIRECTORY}/${did}`; 26 + console.log('[fetchDidDocument] Fetching from PLC directory:', url); 27 + const res = await fetch(url); 28 + console.log('[fetchDidDocument] PLC response status:', res.status); 29 + if (res.ok) { 30 + const doc = await res.json(); 31 + console.log('[fetchDidDocument] Successfully fetched PLC document'); 32 + return doc; 33 + } 25 34 } else if (did.startsWith('did:web:')) { 26 35 const domain = did.slice('did:web:'.length); 27 - const res = await fetch(`https://${domain}/.well-known/did.json`); 28 - if (res.ok) return await res.json(); 36 + const url = `https://${domain}/.well-known/did.json`; 37 + console.log('[fetchDidDocument] Fetching from did:web:', url); 38 + const res = await fetch(url); 39 + console.log('[fetchDidDocument] did:web response status:', res.status); 40 + if (res.ok) { 41 + const doc = await res.json(); 42 + console.log('[fetchDidDocument] Successfully fetched did:web document'); 43 + return doc; 44 + } 45 + } else { 46 + console.log('[fetchDidDocument] Unknown DID method:', did); 29 47 } 30 48 } catch (err) { 31 - console.error('Failed to fetch DID document for', did, err); 49 + console.error('[fetchDidDocument] Failed to fetch DID document for', did, err); 32 50 } 51 + 52 + console.log('[fetchDidDocument] Returning null for:', did); 33 53 return null; 34 54 } 35 55 ··· 53 73 54 74 /** Resolve a DID to its PDS host endpoint. Results are cached. */ 55 75 export async function resolvePdsHost(did: string): Promise<string | null> { 76 + console.log('[resolvePdsHost] Resolving PDS for DID:', did); 77 + 56 78 const cached = pdsCache.get(did); 57 - if (cached) return cached; 79 + if (cached) { 80 + console.log('[resolvePdsHost] Using cached PDS:', cached); 81 + return cached; 82 + } 58 83 84 + console.log('[resolvePdsHost] Fetching DID document...'); 59 85 const doc = await fetchDidDocument(did); 86 + 60 87 if (doc?.service) { 88 + console.log('[resolvePdsHost] Found', doc.service.length, 'services in DID document'); 61 89 const pds = doc.service.find( 62 90 (s) => s.id === '#atproto_pds' && s.type === 'AtprotoPersonalDataServer' 63 91 ); 64 92 if (pds) { 93 + console.log('[resolvePdsHost] Found PDS endpoint:', pds.serviceEndpoint); 65 94 pdsCache.set(did, pds.serviceEndpoint); 66 95 return pds.serviceEndpoint; 67 96 } 97 + console.log('[resolvePdsHost] No AtprotoPersonalDataServer service found'); 98 + } else { 99 + console.log('[resolvePdsHost] No services in DID document'); 68 100 } 69 101 102 + console.log('[resolvePdsHost] Could not resolve PDS for DID:', did); 70 103 return null; 71 104 } 72 105 ··· 211 244 playerTwoDid: string | null, 212 245 gameAtUri: string 213 246 ): Promise<{ moves: MoveRecord[]; passes: PassRecord[]; resigns: ResignRecord[] }> { 247 + console.log('[fetchGameActionsFromPds] Starting fetch for game:', gameAtUri); 248 + console.log('[fetchGameActionsFromPds] Players:', { playerOneDid, playerTwoDid }); 249 + 214 250 const moves: MoveRecord[] = []; 215 251 const passes: PassRecord[] = []; 216 252 const resigns: ResignRecord[] = []; ··· 219 255 if (playerTwoDid) dids.push(playerTwoDid); 220 256 221 257 for (const did of dids) { 258 + console.log('[fetchGameActionsFromPds] Processing DID:', did); 259 + 222 260 const pds = await resolvePdsHost(did); 223 - if (!pds) continue; 261 + if (!pds) { 262 + console.log('[fetchGameActionsFromPds] Could not resolve PDS for:', did); 263 + continue; 264 + } 265 + console.log('[fetchGameActionsFromPds] Resolved PDS:', pds); 224 266 225 267 // Fetch moves with pagination 226 268 try { 227 269 let cursor: string | undefined; 270 + let pageCount = 0; 228 271 do { 272 + pageCount++; 273 + console.log('[fetchGameActionsFromPds] Fetching moves page', pageCount, 'for', did); 274 + 229 275 const moveParams = new URLSearchParams({ 230 276 repo: did, 231 277 collection: 'boo.sky.go.move', ··· 236 282 const moveRes = await fetch( 237 283 `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 238 284 ); 285 + 286 + console.log('[fetchGameActionsFromPds] Move fetch response:', moveRes.status); 287 + 239 288 if (moveRes.ok) { 240 289 const data = await moveRes.json(); 290 + console.log('[fetchGameActionsFromPds] Got', data.records?.length || 0, 'move records'); 291 + 292 + let matchCount = 0; 241 293 for (const rec of data.records || []) { 242 294 if (rec.value?.game === gameAtUri) { 295 + matchCount++; 243 296 moves.push({ 244 297 ...(rec.value as MoveRecord), 245 298 uri: rec.uri, // Include the AT URI 246 299 }); 247 300 } 248 301 } 302 + console.log('[fetchGameActionsFromPds]', matchCount, 'moves matched game URI'); 303 + 249 304 cursor = data.cursor; 250 305 } else { 306 + console.log('[fetchGameActionsFromPds] Move fetch failed, breaking pagination'); 251 307 break; 252 308 } 253 309 } while (cursor); 310 + 311 + console.log('[fetchGameActionsFromPds] Finished fetching moves for', did); 254 312 } catch (err) { 255 - console.error('Failed to list move records from PDS for', did, err); 313 + console.error('[fetchGameActionsFromPds] Failed to list move records from PDS for', did, err); 256 314 } 257 315 258 316 // Fetch passes with pagination ··· 458 516 return null; 459 517 } 460 518 } 519 + 520 + /** 521 + * Fetch a Cloud Go profile record from a player's PDS 522 + */ 523 + export async function fetchCloudGoProfile(did: string): Promise<ProfileRecord | null> { 524 + const pds = await resolvePdsHost(did); 525 + if (!pds) return null; 526 + 527 + try { 528 + const params = new URLSearchParams({ 529 + repo: did, 530 + collection: 'boo.sky.go.profile', 531 + rkey: 'self', 532 + }); 533 + const res = await fetch( 534 + `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 535 + ); 536 + if (res.ok) { 537 + const data = await res.json(); 538 + return data.value as ProfileRecord; 539 + } 540 + } catch (err) { 541 + console.error('Failed to fetch Cloud Go profile:', err); 542 + } 543 + return null; 544 + } 545 + 546 + /** 547 + * Fetch resign records for a game from Constellation backlinks 548 + */ 549 + export async function fetchGameResigns( 550 + gameAtUri: string 551 + ): Promise<ResignRecord[]> { 552 + const allResigns: ResignRecord[] = []; 553 + let cursor: string | undefined; 554 + 555 + try { 556 + do { 557 + const params = new URLSearchParams({ 558 + subject: gameAtUri, 559 + source: 'boo.sky.go.resign:game', 560 + limit: '100', 561 + }); 562 + if (cursor) params.set('cursor', cursor); 563 + 564 + const res = await fetch( 565 + `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 566 + { headers: { Accept: 'application/json' } } 567 + ); 568 + 569 + if (!res.ok) break; 570 + 571 + const body: ConstellationBacklinksResponse = await res.json(); 572 + for (const rec of body.records) { 573 + allResigns.push(rec.value as ResignRecord); 574 + } 575 + cursor = body.cursor ?? undefined; 576 + } while (cursor); 577 + } catch (err) { 578 + console.error('Failed to fetch game resigns from Constellation:', err); 579 + } 580 + 581 + allResigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); 582 + return allResigns; 583 + } 584 + 585 + export interface GameWithMetadata extends GameRecord { 586 + uri: string; 587 + rkey: string; 588 + creatorDid: string; 589 + actionCount: number; 590 + lastActionType: 'move' | 'pass' | `resigned:${string}`; 591 + updatedAt: string; 592 + } 593 + 594 + /** 595 + * Calculate action count and metadata for a game by fetching moves/passes/resigns 596 + */ 597 + export async function calculateGameMetadata( 598 + gameUri: string, 599 + gameRecord: GameRecord, 600 + creatorDid: string, 601 + rkey: string 602 + ): Promise<GameWithMetadata> { 603 + // Fetch all actions in parallel 604 + const [moves, passes, resigns] = await Promise.all([ 605 + fetchGameMoves(gameUri), 606 + fetchGamePasses(gameUri), 607 + fetchGameResigns(gameUri), 608 + ]); 609 + 610 + const actionCount = moves.length + passes.length; 611 + 612 + // Determine last action type 613 + let lastActionType: 'move' | 'pass' | `resigned:${string}` = 'move'; 614 + let updatedAt = gameRecord.createdAt; 615 + 616 + if (resigns.length > 0) { 617 + const lastResign = resigns[resigns.length - 1]; 618 + lastActionType = `resigned:${lastResign.color}` as `resigned:${string}`; 619 + updatedAt = lastResign.createdAt; 620 + } else { 621 + const allActions = [ 622 + ...moves.map(m => ({ type: 'move' as const, createdAt: m.createdAt })), 623 + ...passes.map(p => ({ type: 'pass' as const, createdAt: p.createdAt })), 624 + ].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 625 + 626 + if (allActions.length > 0) { 627 + lastActionType = allActions[0].type; 628 + updatedAt = allActions[0].createdAt; 629 + } 630 + } 631 + 632 + return { 633 + ...gameRecord, 634 + uri: gameUri, 635 + rkey, 636 + creatorDid, 637 + actionCount, 638 + lastActionType, 639 + updatedAt, 640 + }; 641 + } 642 + 643 + /** 644 + * List all game records from a player's PDS 645 + */ 646 + export async function listPlayerGames(did: string): Promise<Array<{ uri: string; rkey: string; value: GameRecord }>> { 647 + const pds = await resolvePdsHost(did); 648 + if (!pds) return []; 649 + 650 + const games: Array<{ uri: string; rkey: string; value: GameRecord }> = []; 651 + let cursor: string | undefined; 652 + 653 + try { 654 + do { 655 + const params = new URLSearchParams({ 656 + repo: did, 657 + collection: 'boo.sky.go.game', 658 + limit: '100', 659 + }); 660 + if (cursor) params.set('cursor', cursor); 661 + 662 + const res = await fetch( 663 + `${pds}/xrpc/com.atproto.repo.listRecords?${params}` 664 + ); 665 + 666 + if (!res.ok) break; 667 + 668 + const data = await res.json(); 669 + for (const rec of data.records || []) { 670 + games.push({ 671 + uri: rec.uri, 672 + rkey: rec.uri.split('/').pop() || '', 673 + value: rec.value as GameRecord, 674 + }); 675 + } 676 + cursor = data.cursor; 677 + } while (cursor); 678 + } catch (err) { 679 + console.error('Failed to list player games:', err); 680 + } 681 + 682 + return games; 683 + } 684 + 685 + /** 686 + * Fetch all games from Constellation by querying all records 687 + * Note: This fetches from multiple known players or uses a game index 688 + */ 689 + export async function fetchAllGames(knownPlayerDids: string[] = []): Promise<GameWithMetadata[]> { 690 + const gameMap = new Map<string, { uri: string; rkey: string; value: GameRecord; creatorDid: string }>(); 691 + 692 + // Fetch games from all known players 693 + const fetchPromises = knownPlayerDids.map(async (did) => { 694 + const games = await listPlayerGames(did); 695 + return { did, games }; 696 + }); 697 + 698 + const results = await Promise.all(fetchPromises); 699 + 700 + for (const { did, games } of results) { 701 + for (const game of games) { 702 + if (!gameMap.has(game.uri)) { 703 + gameMap.set(game.uri, { ...game, creatorDid: did }); 704 + } 705 + } 706 + } 707 + 708 + // Calculate metadata for all games in parallel 709 + const metadataPromises = Array.from(gameMap.values()).map(({ uri, value, creatorDid, rkey }) => 710 + calculateGameMetadata(uri, value, creatorDid, rkey) 711 + ); 712 + 713 + const gamesWithMetadata = await Promise.all(metadataPromises); 714 + return gamesWithMetadata; 715 + }