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.

Phase 1: Add client-side ATProto fetch library and shared types

New src/lib/atproto-client.ts provides unauthenticated client-side functions
for resolving DIDs to handles, fetching game records from PDS, and fetching
moves/passes from Constellation backlinks. Includes PDS fallback.

New src/lib/types.ts defines shared interfaces for ATProto record shapes.

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

+309
+273
src/lib/atproto-client.ts
··· 1 + import type { GameRecord, MoveRecord, PassRecord } from './types'; 2 + 3 + const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; 4 + const PLC_DIRECTORY = 'https://plc.directory'; 5 + 6 + // Caches 7 + const handleCache = new Map<string, string>(); 8 + const pdsCache = new Map<string, string>(); 9 + 10 + interface DidDocument { 11 + id: string; 12 + alsoKnownAs?: string[]; 13 + service?: Array<{ 14 + id: string; 15 + type: string; 16 + serviceEndpoint: string; 17 + }>; 18 + } 19 + 20 + async function fetchDidDocument(did: string): Promise<DidDocument | null> { 21 + try { 22 + if (did.startsWith('did:plc:')) { 23 + const res = await fetch(`${PLC_DIRECTORY}/${did}`); 24 + if (res.ok) return await res.json(); 25 + } else if (did.startsWith('did:web:')) { 26 + 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(); 29 + } 30 + } catch (err) { 31 + console.error('Failed to fetch DID document for', did, err); 32 + } 33 + return null; 34 + } 35 + 36 + /** Resolve a DID to a human-readable handle. Results are cached. */ 37 + export async function resolveDidToHandle(did: string): Promise<string> { 38 + const cached = handleCache.get(did); 39 + if (cached) return cached; 40 + 41 + const doc = await fetchDidDocument(did); 42 + if (doc?.alsoKnownAs && doc.alsoKnownAs.length > 0) { 43 + const handleUri = doc.alsoKnownAs[0]; 44 + if (handleUri.startsWith('at://')) { 45 + const handle = handleUri.slice(5); 46 + handleCache.set(did, handle); 47 + return handle; 48 + } 49 + } 50 + 51 + return did; 52 + } 53 + 54 + /** Resolve a DID to its PDS host endpoint. Results are cached. */ 55 + export async function resolvePdsHost(did: string): Promise<string | null> { 56 + const cached = pdsCache.get(did); 57 + if (cached) return cached; 58 + 59 + const doc = await fetchDidDocument(did); 60 + if (doc?.service) { 61 + const pds = doc.service.find( 62 + (s) => s.id === '#atproto_pds' && s.type === 'AtprotoPersonalDataServer' 63 + ); 64 + if (pds) { 65 + pdsCache.set(did, pds.serviceEndpoint); 66 + return pds.serviceEndpoint; 67 + } 68 + } 69 + 70 + return null; 71 + } 72 + 73 + /** Fetch a game record directly from a player's PDS. */ 74 + export async function fetchGameRecord( 75 + creatorDid: string, 76 + rkey: string 77 + ): Promise<GameRecord | null> { 78 + const pds = await resolvePdsHost(creatorDid); 79 + if (!pds) return null; 80 + 81 + try { 82 + const params = new URLSearchParams({ 83 + repo: creatorDid, 84 + collection: 'boo.sky.go.game', 85 + rkey, 86 + }); 87 + const res = await fetch( 88 + `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 89 + ); 90 + if (res.ok) { 91 + const data = await res.json(); 92 + return data.value as GameRecord; 93 + } 94 + } catch (err) { 95 + console.error('Failed to fetch game record:', err); 96 + } 97 + return null; 98 + } 99 + 100 + interface ConstellationBacklinksResponse { 101 + links: Array<{ uri: string; value: any }>; 102 + total: number; 103 + cursor?: string; 104 + } 105 + 106 + /** Fetch all moves for a game from Constellation backlinks. */ 107 + export async function fetchGameMoves( 108 + gameAtUri: string 109 + ): Promise<MoveRecord[]> { 110 + const allMoves: MoveRecord[] = []; 111 + let cursor: string | undefined; 112 + 113 + try { 114 + do { 115 + const params = new URLSearchParams({ 116 + subject: gameAtUri, 117 + source: 'boo.sky.go.move:game', 118 + limit: '100', 119 + }); 120 + if (cursor) params.set('cursor', cursor); 121 + 122 + const res = await fetch( 123 + `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 124 + { headers: { Accept: 'application/json' } } 125 + ); 126 + 127 + if (!res.ok) break; 128 + 129 + const body: ConstellationBacklinksResponse = await res.json(); 130 + for (const link of body.links) { 131 + allMoves.push(link.value as MoveRecord); 132 + } 133 + cursor = body.cursor; 134 + } while (cursor); 135 + } catch (err) { 136 + console.error('Failed to fetch game moves from Constellation:', err); 137 + } 138 + 139 + allMoves.sort((a, b) => a.moveNumber - b.moveNumber); 140 + return allMoves; 141 + } 142 + 143 + /** Fetch all passes for a game from Constellation backlinks. */ 144 + export async function fetchGamePasses( 145 + gameAtUri: string 146 + ): Promise<PassRecord[]> { 147 + const allPasses: PassRecord[] = []; 148 + let cursor: string | undefined; 149 + 150 + try { 151 + do { 152 + const params = new URLSearchParams({ 153 + subject: gameAtUri, 154 + source: 'boo.sky.go.pass:game', 155 + limit: '100', 156 + }); 157 + if (cursor) params.set('cursor', cursor); 158 + 159 + const res = await fetch( 160 + `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 161 + { headers: { Accept: 'application/json' } } 162 + ); 163 + 164 + if (!res.ok) break; 165 + 166 + const body: ConstellationBacklinksResponse = await res.json(); 167 + for (const link of body.links) { 168 + allPasses.push(link.value as PassRecord); 169 + } 170 + cursor = body.cursor; 171 + } while (cursor); 172 + } catch (err) { 173 + console.error('Failed to fetch game passes from Constellation:', err); 174 + } 175 + 176 + allPasses.sort((a, b) => a.moveNumber - b.moveNumber); 177 + return allPasses; 178 + } 179 + 180 + /** Fetch the total move count for a game from Constellation. */ 181 + export async function fetchMoveCount( 182 + gameAtUri: string 183 + ): Promise<number | null> { 184 + try { 185 + const params = new URLSearchParams({ 186 + subject: gameAtUri, 187 + source: 'boo.sky.go.move:game', 188 + limit: '1', 189 + }); 190 + const res = await fetch( 191 + `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 192 + { headers: { Accept: 'application/json' } } 193 + ); 194 + if (res.ok) { 195 + const body = await res.json(); 196 + if (typeof body.total === 'number') return body.total; 197 + return 0; 198 + } 199 + } catch (err) { 200 + console.error('Constellation fetch failed for', gameAtUri, err); 201 + } 202 + return null; 203 + } 204 + 205 + /** 206 + * Fallback: fetch moves/passes by listing records from both players' PDS repos. 207 + * Used when Constellation is unavailable. 208 + */ 209 + export async function fetchGameActionsFromPds( 210 + playerOneDid: string, 211 + playerTwoDid: string | null, 212 + gameAtUri: string 213 + ): Promise<{ moves: MoveRecord[]; passes: PassRecord[] }> { 214 + const moves: MoveRecord[] = []; 215 + const passes: PassRecord[] = []; 216 + 217 + const dids = [playerOneDid]; 218 + if (playerTwoDid) dids.push(playerTwoDid); 219 + 220 + for (const did of dids) { 221 + const pds = await resolvePdsHost(did); 222 + if (!pds) continue; 223 + 224 + // Fetch moves 225 + try { 226 + const moveParams = new URLSearchParams({ 227 + repo: did, 228 + collection: 'boo.sky.go.move', 229 + limit: '100', 230 + }); 231 + const moveRes = await fetch( 232 + `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 233 + ); 234 + if (moveRes.ok) { 235 + const data = await moveRes.json(); 236 + for (const rec of data.records || []) { 237 + if (rec.value?.game === gameAtUri) { 238 + moves.push(rec.value as MoveRecord); 239 + } 240 + } 241 + } 242 + } catch (err) { 243 + console.error('Failed to list move records from PDS for', did, err); 244 + } 245 + 246 + // Fetch passes 247 + try { 248 + const passParams = new URLSearchParams({ 249 + repo: did, 250 + collection: 'boo.sky.go.pass', 251 + limit: '100', 252 + }); 253 + const passRes = await fetch( 254 + `${pds}/xrpc/com.atproto.repo.listRecords?${passParams}` 255 + ); 256 + if (passRes.ok) { 257 + const data = await passRes.json(); 258 + for (const rec of data.records || []) { 259 + if (rec.value?.game === gameAtUri) { 260 + passes.push(rec.value as PassRecord); 261 + } 262 + } 263 + } 264 + } catch (err) { 265 + console.error('Failed to list pass records from PDS for', did, err); 266 + } 267 + } 268 + 269 + moves.sort((a, b) => a.moveNumber - b.moveNumber); 270 + passes.sort((a, b) => a.moveNumber - b.moveNumber); 271 + 272 + return { moves, passes }; 273 + }
+36
src/lib/types.ts
··· 1 + // Shared ATProto record shapes for boo.sky.go.* lexicons 2 + 3 + export interface GameRecord { 4 + $type: 'boo.sky.go.game'; 5 + playerOne: string; 6 + playerTwo?: string; 7 + boardSize: number; 8 + status: 'waiting' | 'active' | 'completed'; 9 + winner?: string; 10 + blackScore?: number; 11 + whiteScore?: number; 12 + blackScorer?: string; 13 + whiteScorer?: string; 14 + createdAt: string; 15 + } 16 + 17 + export interface MoveRecord { 18 + $type: 'boo.sky.go.move'; 19 + game: string; // AT URI of the game 20 + player: string; 21 + moveNumber: number; 22 + x: number; 23 + y: number; 24 + color: 'black' | 'white'; 25 + captureCount: number; 26 + createdAt: string; 27 + } 28 + 29 + export interface PassRecord { 30 + $type: 'boo.sky.go.pass'; 31 + game: string; // AT URI of the game 32 + player: string; 33 + moveNumber: number; 34 + color: 'black' | 'white'; 35 + createdAt: string; 36 + }