import type { GameRecord, MoveRecord, PassRecord, ResignRecord, ReactionRecord, ProfileRecord } from './types'; const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; const PLC_DIRECTORY = 'https://plc.directory'; // Caches const handleCache = new Map(); const pdsCache = new Map(); interface DidDocument { id: string; alsoKnownAs?: string[]; service?: Array<{ id: string; type: string; serviceEndpoint: string; }>; } async function fetchDidDocument(did: string | null): Promise { if (!did) { console.warn('[fetchDidDocument] Null or empty DID provided'); return null; } console.log('[fetchDidDocument] Fetching DID document for:', did); try { if (did.startsWith('did:plc:')) { const url = `${PLC_DIRECTORY}/${did}`; console.log('[fetchDidDocument] Fetching from PLC directory:', url); // iOS Safari sometimes blocks plc.directory requests, add timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); console.log('[fetchDidDocument] PLC response status:', res.status); if (res.ok) { const doc = await res.json(); console.log('[fetchDidDocument] Successfully fetched PLC document'); return doc; } } catch (fetchErr) { clearTimeout(timeoutId); console.warn('[fetchDidDocument] PLC fetch failed (possible iOS Safari blocking):', fetchErr); throw fetchErr; } } else if (did.startsWith('did:web:')) { const domain = did.slice('did:web:'.length); const url = `https://${domain}/.well-known/did.json`; console.log('[fetchDidDocument] Fetching from did:web:', url); const res = await fetch(url); console.log('[fetchDidDocument] did:web response status:', res.status); if (res.ok) { const doc = await res.json(); console.log('[fetchDidDocument] Successfully fetched did:web document'); return doc; } } else { console.log('[fetchDidDocument] Unknown DID method:', did); } } catch (err) { console.error('[fetchDidDocument] Failed to fetch DID document for', did, err); } console.log('[fetchDidDocument] Returning null for:', did); return null; } /** Resolve a DID to a human-readable handle. Results are cached. */ export async function resolveDidToHandle(did: string | null): Promise { if (!did) return 'Unknown'; const cached = handleCache.get(did); if (cached) return cached; // Try PLC directory first const doc = await fetchDidDocument(did); if (doc?.alsoKnownAs && doc.alsoKnownAs.length > 0) { const handleUri = doc.alsoKnownAs[0]; if (handleUri.startsWith('at://')) { const handle = handleUri.slice(5); handleCache.set(did, handle); return handle; } } // Fallback: Use Bluesky public API (works on iOS Safari when plc.directory is blocked) try { console.log('[resolveDidToHandle] Falling back to Bluesky public API for:', did); const res = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`); if (res.ok) { const data = await res.json(); if (data.handle) { console.log('[resolveDidToHandle] Resolved via public API:', data.handle); handleCache.set(did, data.handle); return data.handle; } } } catch (err) { console.error('[resolveDidToHandle] Public API fallback failed:', err); } return did; } /** Resolve a DID to its PDS host endpoint. Results are cached. */ export async function resolvePdsHost(did: string): Promise { console.log('[resolvePdsHost] Resolving PDS for DID:', did); const cached = pdsCache.get(did); if (cached) { console.log('[resolvePdsHost] Using cached PDS:', cached); return cached; } console.log('[resolvePdsHost] Fetching DID document...'); const doc = await fetchDidDocument(did); if (doc?.service) { console.log('[resolvePdsHost] Found', doc.service.length, 'services in DID document'); const pds = doc.service.find( (s) => s.id === '#atproto_pds' && s.type === 'AtprotoPersonalDataServer' ); if (pds) { console.log('[resolvePdsHost] Found PDS endpoint:', pds.serviceEndpoint); pdsCache.set(did, pds.serviceEndpoint); return pds.serviceEndpoint; } console.log('[resolvePdsHost] No AtprotoPersonalDataServer service found'); } else { console.log('[resolvePdsHost] No services in DID document'); } console.log('[resolvePdsHost] Could not resolve PDS for DID:', did); return null; } /** Fetch a game record directly from a player's PDS. */ export async function fetchGameRecord( creatorDid: string, rkey: string ): Promise { console.log('[fetchGameRecord] Called with:', { creatorDid, rkey }); if (!creatorDid || !rkey) { console.error('[fetchGameRecord] Invalid parameters - creatorDid or rkey is missing'); return null; } const pds = await resolvePdsHost(creatorDid); if (!pds) { console.error('[fetchGameRecord] Could not resolve PDS for:', creatorDid); return null; } try { const params = new URLSearchParams({ repo: creatorDid, collection: 'boo.sky.go.game', rkey, }); const url = `${pds}/xrpc/com.atproto.repo.getRecord?${params}`; console.log('[fetchGameRecord] Fetching:', url); const res = await fetch(url); if (res.ok) { const data = await res.json(); console.log('[fetchGameRecord] Success:', data.value); return data.value as GameRecord; } else { console.error('[fetchGameRecord] HTTP error:', res.status, res.statusText); const errorBody = await res.text(); console.error('[fetchGameRecord] Error body:', errorBody); } } catch (err) { console.error('[fetchGameRecord] Exception:', err); } return null; } interface ConstellationBacklinksResponse { records: Array<{ uri: string; value: any }>; total: number; cursor: string | null; } /** Fetch all moves for a game from Constellation backlinks. */ export async function fetchGameMoves( gameAtUri: string ): Promise { const allMoves: MoveRecord[] = []; let cursor: string | undefined; try { do { const params = new URLSearchParams({ subject: gameAtUri, source: 'boo.sky.go.move:game', limit: '100', }); if (cursor) params.set('cursor', cursor); const res = await fetch( `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, { headers: { Accept: 'application/json' } } ); if (!res.ok) break; const body: ConstellationBacklinksResponse = await res.json(); for (const rec of body.records) { allMoves.push(rec.value as MoveRecord); } cursor = body.cursor ?? undefined; } while (cursor); } catch (err) { console.error('Failed to fetch game moves from Constellation:', err); } allMoves.sort((a, b) => a.moveNumber - b.moveNumber); return allMoves; } /** Fetch all passes for a game from Constellation backlinks. */ export async function fetchGamePasses( gameAtUri: string ): Promise { const allPasses: PassRecord[] = []; let cursor: string | undefined; try { do { const params = new URLSearchParams({ subject: gameAtUri, source: 'boo.sky.go.pass:game', limit: '100', }); if (cursor) params.set('cursor', cursor); const res = await fetch( `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, { headers: { Accept: 'application/json' } } ); if (!res.ok) break; const body: ConstellationBacklinksResponse = await res.json(); for (const rec of body.records) { allPasses.push(rec.value as PassRecord); } cursor = body.cursor ?? undefined; } while (cursor); } catch (err) { console.error('Failed to fetch game passes from Constellation:', err); } allPasses.sort((a, b) => a.moveNumber - b.moveNumber); return allPasses; } /** Fetch the total move count for a game from Constellation. */ export async function fetchMoveCount( gameAtUri: string ): Promise { try { const params = new URLSearchParams({ subject: gameAtUri, source: 'boo.sky.go.move:game', limit: '1', }); const res = await fetch( `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, { headers: { Accept: 'application/json' } } ); if (res.ok) { const body = await res.json(); if (typeof body.total === 'number') return body.total; return 0; } } catch (err) { console.error('Constellation fetch failed for', gameAtUri, err); } return null; } /** * Fallback: fetch moves/passes/resigns by listing records from both players' PDS repos. * Used when Constellation is unavailable. */ export async function fetchGameActionsFromPds( playerOneDid: string, playerTwoDid: string | null, gameAtUri: string ): Promise<{ moves: MoveRecord[]; passes: PassRecord[]; resigns: ResignRecord[] }> { const moves: MoveRecord[] = []; const passes: PassRecord[] = []; const resigns: ResignRecord[] = []; const dids = [playerOneDid]; if (playerTwoDid) dids.push(playerTwoDid); for (const did of dids) { const pds = await resolvePdsHost(did); if (!pds) { console.warn('[fetchGameActionsFromPds] Could not resolve PDS for:', did); continue; } // Fetch moves with pagination try { let cursor: string | undefined; do { const moveParams = new URLSearchParams({ repo: did, collection: 'boo.sky.go.move', limit: '100', }); if (cursor) moveParams.set('cursor', cursor); const moveRes = await fetch( `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` ); if (moveRes.ok) { const data = await moveRes.json(); for (const rec of data.records || []) { if (rec.value?.game === gameAtUri) { moves.push({ ...(rec.value as MoveRecord), uri: rec.uri, // Include the AT URI }); } } cursor = data.cursor; } else { break; } } while (cursor); } catch (err) { console.error('[fetchGameActionsFromPds] Failed to list move records from PDS for', did, err); } // Fetch passes with pagination try { let cursor: string | undefined; do { const passParams = new URLSearchParams({ repo: did, collection: 'boo.sky.go.pass', limit: '100', }); if (cursor) passParams.set('cursor', cursor); const passRes = await fetch( `${pds}/xrpc/com.atproto.repo.listRecords?${passParams}` ); if (passRes.ok) { const data = await passRes.json(); for (const rec of data.records || []) { if (rec.value?.game === gameAtUri) { passes.push(rec.value as PassRecord); } } cursor = data.cursor; } else { break; } } while (cursor); } catch (err) { console.error('Failed to list pass records from PDS for', did, err); } // Fetch resigns with pagination try { let cursor: string | undefined; do { const resignParams = new URLSearchParams({ repo: did, collection: 'boo.sky.go.resign', limit: '100', }); if (cursor) resignParams.set('cursor', cursor); const resignRes = await fetch( `${pds}/xrpc/com.atproto.repo.listRecords?${resignParams}` ); if (resignRes.ok) { const data = await resignRes.json(); for (const rec of data.records || []) { if (rec.value?.game === gameAtUri) { resigns.push(rec.value as ResignRecord); } } cursor = data.cursor; } else { break; } } while (cursor); } catch (err) { console.error('Failed to list resign records from PDS for', did, err); } } moves.sort((a, b) => a.moveNumber - b.moveNumber); passes.sort((a, b) => a.moveNumber - b.moveNumber); resigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); return { moves, passes, resigns }; } export interface ReactionWithAuthor extends ReactionRecord { uri: string; author: string; authorHandle?: string; } interface ConstellationBacklinkRef { did: string; collection: string; rkey: string; } /** Fetch all reactions for a game from Constellation backlinks. Returns a map of moveUri -> reactions. */ export async function fetchGameReactions( gameAtUri: string ): Promise> { const reactionsByMove = new Map(); const backlinkRefs: ConstellationBacklinkRef[] = []; let cursor: string | undefined; try { // Step 1: Get backlink references from Constellation do { const params = new URLSearchParams({ subject: gameAtUri, source: 'boo.sky.go.reaction:game', limit: '100', }); if (cursor) params.set('cursor', cursor); const res = await fetch( `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, { headers: { Accept: 'application/json' } } ); if (!res.ok) break; const body = await res.json(); for (const rec of body.records || []) { if (rec.did && rec.collection && rec.rkey) { backlinkRefs.push(rec as ConstellationBacklinkRef); } } cursor = body.cursor ?? undefined; } while (cursor); // Step 2: Fetch actual records from each author's PDS const fetchPromises = backlinkRefs.map(async (ref) => { try { const pds = await resolvePdsHost(ref.did); if (!pds) return null; const params = new URLSearchParams({ repo: ref.did, collection: ref.collection, rkey: ref.rkey, }); const res = await fetch( `${pds}/xrpc/com.atproto.repo.getRecord?${params}` ); if (!res.ok) return null; const data = await res.json(); if (!data.value) return null; const reaction = data.value as ReactionRecord; const uri = data.uri || `at://${ref.did}/${ref.collection}/${ref.rkey}`; return { ...reaction, uri, author: ref.did, } as ReactionWithAuthor; } catch { return null; } }); const reactions = (await Promise.all(fetchPromises)).filter((r): r is ReactionWithAuthor => r !== null); // Step 3: Group by move URI for (const reaction of reactions) { const moveUri = reaction.move; const existing = reactionsByMove.get(moveUri) || []; existing.push(reaction); reactionsByMove.set(moveUri, existing); } // Sort reactions within each move by creation time, newest first for (const [, reacts] of reactionsByMove) { reacts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } } catch (err) { console.error('Failed to fetch reactions from Constellation:', err); } return reactionsByMove; } export interface UserProfile { did: string; handle: string; displayName?: string; avatar?: string; description?: string; } /** * Fetch user profile including avatar from Bluesky public API */ export async function fetchUserProfile(did: string): Promise { try { const params = new URLSearchParams({ actor: did }); const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}`, { headers: { Accept: 'application/json' } } ); if (!res.ok) return null; const data = await res.json(); return { did: data.did, handle: data.handle, displayName: data.displayName, avatar: data.avatar, description: data.description, }; } catch (err) { console.error('Failed to fetch user profile:', err); return null; } } /** * Fetch a Cloud Go profile record from a player's PDS */ export async function fetchCloudGoProfile(did: string): Promise { const pds = await resolvePdsHost(did); if (!pds) return null; try { const params = new URLSearchParams({ repo: did, collection: 'boo.sky.go.profile', rkey: 'self', }); const res = await fetch( `${pds}/xrpc/com.atproto.repo.getRecord?${params}` ); if (res.ok) { const data = await res.json(); return data.value as ProfileRecord; } } catch (err) { console.error('Failed to fetch Cloud Go profile:', err); } return null; } /** * Fetch resign records for a game from Constellation backlinks */ export async function fetchGameResigns( gameAtUri: string ): Promise { const allResigns: ResignRecord[] = []; let cursor: string | undefined; try { do { const params = new URLSearchParams({ subject: gameAtUri, source: 'boo.sky.go.resign:game', limit: '100', }); if (cursor) params.set('cursor', cursor); const res = await fetch( `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, { headers: { Accept: 'application/json' } } ); if (!res.ok) break; const body: ConstellationBacklinksResponse = await res.json(); for (const rec of body.records) { allResigns.push(rec.value as ResignRecord); } cursor = body.cursor ?? undefined; } while (cursor); } catch (err) { console.error('Failed to fetch game resigns from Constellation:', err); } allResigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); return allResigns; } export interface GameWithMetadata extends GameRecord { uri: string; rkey: string; creatorDid: string; actionCount: number; lastActionType: 'move' | 'pass' | `resigned:${string}`; updatedAt: string; } /** * Calculate action count and metadata for a game by fetching moves/passes/resigns */ export async function calculateGameMetadata( gameUri: string, gameRecord: GameRecord, creatorDid: string, rkey: string ): Promise { // Fetch all actions in parallel const [moves, passes, resigns] = await Promise.all([ fetchGameMoves(gameUri), fetchGamePasses(gameUri), fetchGameResigns(gameUri), ]); const actionCount = moves.length + passes.length; // Determine last action type let lastActionType: 'move' | 'pass' | `resigned:${string}` = 'move'; let updatedAt = gameRecord.createdAt; if (resigns.length > 0) { const lastResign = resigns[resigns.length - 1]; lastActionType = `resigned:${lastResign.color}` as `resigned:${string}`; updatedAt = lastResign.createdAt; } else { const allActions = [ ...moves.map(m => ({ type: 'move' as const, createdAt: m.createdAt })), ...passes.map(p => ({ type: 'pass' as const, createdAt: p.createdAt })), ].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); if (allActions.length > 0) { lastActionType = allActions[0].type; updatedAt = allActions[0].createdAt; } } return { ...gameRecord, uri: gameUri, rkey, creatorDid, actionCount, lastActionType, updatedAt, }; } /** * Find a game by creator DID and rkey */ export async function findGame(creatorDid: string, rkey: string): Promise { const gameRecord = await fetchGameRecord(creatorDid, rkey); if (!gameRecord) return null; const gameUri = `at://${creatorDid}/boo.sky.go.game/${rkey}`; return await calculateGameMetadata(gameUri, gameRecord, creatorDid, rkey); } /** * Find a game by rkey by searching through known players' PDSs * This requires either knowing potential creator DIDs or searching through all known players */ export async function findGameByRkey(rkey: string, potentialCreatorDids: string[] = []): Promise { // Try each potential creator until we find the game for (const did of potentialCreatorDids) { const game = await findGame(did, rkey); if (game) return game; } return null; } /** * List all game records from a player's PDS */ export async function listPlayerGames(did: string): Promise> { const pds = await resolvePdsHost(did); if (!pds) return []; const games: Array<{ uri: string; rkey: string; value: GameRecord }> = []; let cursor: string | undefined; try { do { const params = new URLSearchParams({ repo: did, collection: 'boo.sky.go.game', limit: '100', }); if (cursor) params.set('cursor', cursor); const res = await fetch( `${pds}/xrpc/com.atproto.repo.listRecords?${params}` ); if (!res.ok) break; const data = await res.json(); for (const rec of data.records || []) { games.push({ uri: rec.uri, rkey: rec.uri.split('/').pop() || '', value: rec.value as GameRecord, }); } cursor = data.cursor; } while (cursor); } catch (err) { console.error('Failed to list player games:', err); } return games; } /** * Fetch all games by querying multiple known players' PDSs * This is the primary method since Constellation doesn't expose a simple listRecords API */ export async function fetchAllGames(knownPlayerDids: string[] = []): Promise { const gameMap = new Map(); // Fetch games from all known players const fetchPromises = knownPlayerDids.map(async (did) => { const games = await listPlayerGames(did); return { did, games }; }); const results = await Promise.all(fetchPromises); for (const { did, games } of results) { for (const game of games) { if (!gameMap.has(game.uri)) { gameMap.set(game.uri, { ...game, creatorDid: did }); } } } // Calculate metadata for all games in parallel const metadataPromises = Array.from(gameMap.values()).map(({ uri, value, creatorDid, rkey }) => calculateGameMetadata(uri, value, creatorDid, rkey) ); const gamesWithMetadata = await Promise.all(metadataPromises); return gamesWithMetadata; }