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.
at master 773 lines 23 kB view raw
1import type { GameRecord, MoveRecord, PassRecord, ResignRecord, ReactionRecord, ProfileRecord } from './types'; 2 3const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; 4const PLC_DIRECTORY = 'https://plc.directory'; 5 6// Caches 7const handleCache = new Map<string, string>(); 8const pdsCache = new Map<string, string>(); 9 10interface DidDocument { 11 id: string; 12 alsoKnownAs?: string[]; 13 service?: Array<{ 14 id: string; 15 type: string; 16 serviceEndpoint: string; 17 }>; 18} 19 20async function fetchDidDocument(did: string | null): Promise<DidDocument | null> { 21 if (!did) { 22 console.warn('[fetchDidDocument] Null or empty DID provided'); 23 return null; 24 } 25 26 console.log('[fetchDidDocument] Fetching DID document for:', did); 27 28 try { 29 if (did.startsWith('did:plc:')) { 30 const url = `${PLC_DIRECTORY}/${did}`; 31 console.log('[fetchDidDocument] Fetching from PLC directory:', url); 32 33 // iOS Safari sometimes blocks plc.directory requests, add timeout 34 const controller = new AbortController(); 35 const timeoutId = setTimeout(() => controller.abort(), 5000); 36 37 try { 38 const res = await fetch(url, { signal: controller.signal }); 39 clearTimeout(timeoutId); 40 console.log('[fetchDidDocument] PLC response status:', res.status); 41 if (res.ok) { 42 const doc = await res.json(); 43 console.log('[fetchDidDocument] Successfully fetched PLC document'); 44 return doc; 45 } 46 } catch (fetchErr) { 47 clearTimeout(timeoutId); 48 console.warn('[fetchDidDocument] PLC fetch failed (possible iOS Safari blocking):', fetchErr); 49 throw fetchErr; 50 } 51 } else if (did.startsWith('did:web:')) { 52 const domain = did.slice('did:web:'.length); 53 const url = `https://${domain}/.well-known/did.json`; 54 console.log('[fetchDidDocument] Fetching from did:web:', url); 55 const res = await fetch(url); 56 console.log('[fetchDidDocument] did:web response status:', res.status); 57 if (res.ok) { 58 const doc = await res.json(); 59 console.log('[fetchDidDocument] Successfully fetched did:web document'); 60 return doc; 61 } 62 } else { 63 console.log('[fetchDidDocument] Unknown DID method:', did); 64 } 65 } catch (err) { 66 console.error('[fetchDidDocument] Failed to fetch DID document for', did, err); 67 } 68 69 console.log('[fetchDidDocument] Returning null for:', did); 70 return null; 71} 72 73/** Resolve a DID to a human-readable handle. Results are cached. */ 74export async function resolveDidToHandle(did: string | null): Promise<string> { 75 if (!did) return 'Unknown'; 76 77 const cached = handleCache.get(did); 78 if (cached) return cached; 79 80 // Try PLC directory first 81 const doc = await fetchDidDocument(did); 82 if (doc?.alsoKnownAs && doc.alsoKnownAs.length > 0) { 83 const handleUri = doc.alsoKnownAs[0]; 84 if (handleUri.startsWith('at://')) { 85 const handle = handleUri.slice(5); 86 handleCache.set(did, handle); 87 return handle; 88 } 89 } 90 91 // Fallback: Use Bluesky public API (works on iOS Safari when plc.directory is blocked) 92 try { 93 console.log('[resolveDidToHandle] Falling back to Bluesky public API for:', did); 94 const res = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`); 95 if (res.ok) { 96 const data = await res.json(); 97 if (data.handle) { 98 console.log('[resolveDidToHandle] Resolved via public API:', data.handle); 99 handleCache.set(did, data.handle); 100 return data.handle; 101 } 102 } 103 } catch (err) { 104 console.error('[resolveDidToHandle] Public API fallback failed:', err); 105 } 106 107 return did; 108} 109 110/** Resolve a DID to its PDS host endpoint. Results are cached. */ 111export async function resolvePdsHost(did: string): Promise<string | null> { 112 console.log('[resolvePdsHost] Resolving PDS for DID:', did); 113 114 const cached = pdsCache.get(did); 115 if (cached) { 116 console.log('[resolvePdsHost] Using cached PDS:', cached); 117 return cached; 118 } 119 120 console.log('[resolvePdsHost] Fetching DID document...'); 121 const doc = await fetchDidDocument(did); 122 123 if (doc?.service) { 124 console.log('[resolvePdsHost] Found', doc.service.length, 'services in DID document'); 125 const pds = doc.service.find( 126 (s) => s.id === '#atproto_pds' && s.type === 'AtprotoPersonalDataServer' 127 ); 128 if (pds) { 129 console.log('[resolvePdsHost] Found PDS endpoint:', pds.serviceEndpoint); 130 pdsCache.set(did, pds.serviceEndpoint); 131 return pds.serviceEndpoint; 132 } 133 console.log('[resolvePdsHost] No AtprotoPersonalDataServer service found'); 134 } else { 135 console.log('[resolvePdsHost] No services in DID document'); 136 } 137 138 console.log('[resolvePdsHost] Could not resolve PDS for DID:', did); 139 return null; 140} 141 142/** Fetch a game record directly from a player's PDS. */ 143export async function fetchGameRecord( 144 creatorDid: string, 145 rkey: string 146): Promise<GameRecord | null> { 147 console.log('[fetchGameRecord] Called with:', { creatorDid, rkey }); 148 149 if (!creatorDid || !rkey) { 150 console.error('[fetchGameRecord] Invalid parameters - creatorDid or rkey is missing'); 151 return null; 152 } 153 154 const pds = await resolvePdsHost(creatorDid); 155 if (!pds) { 156 console.error('[fetchGameRecord] Could not resolve PDS for:', creatorDid); 157 return null; 158 } 159 160 try { 161 const params = new URLSearchParams({ 162 repo: creatorDid, 163 collection: 'boo.sky.go.game', 164 rkey, 165 }); 166 const url = `${pds}/xrpc/com.atproto.repo.getRecord?${params}`; 167 console.log('[fetchGameRecord] Fetching:', url); 168 const res = await fetch(url); 169 170 if (res.ok) { 171 const data = await res.json(); 172 console.log('[fetchGameRecord] Success:', data.value); 173 return data.value as GameRecord; 174 } else { 175 console.error('[fetchGameRecord] HTTP error:', res.status, res.statusText); 176 const errorBody = await res.text(); 177 console.error('[fetchGameRecord] Error body:', errorBody); 178 } 179 } catch (err) { 180 console.error('[fetchGameRecord] Exception:', err); 181 } 182 return null; 183} 184 185interface ConstellationBacklinksResponse { 186 records: Array<{ uri: string; value: any }>; 187 total: number; 188 cursor: string | null; 189} 190 191/** Fetch all moves for a game from Constellation backlinks. */ 192export async function fetchGameMoves( 193 gameAtUri: string 194): Promise<MoveRecord[]> { 195 const allMoves: MoveRecord[] = []; 196 let cursor: string | undefined; 197 198 try { 199 do { 200 const params = new URLSearchParams({ 201 subject: gameAtUri, 202 source: 'boo.sky.go.move:game', 203 limit: '100', 204 }); 205 if (cursor) params.set('cursor', cursor); 206 207 const res = await fetch( 208 `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 209 { headers: { Accept: 'application/json' } } 210 ); 211 212 if (!res.ok) break; 213 214 const body: ConstellationBacklinksResponse = await res.json(); 215 for (const rec of body.records) { 216 allMoves.push(rec.value as MoveRecord); 217 } 218 cursor = body.cursor ?? undefined; 219 } while (cursor); 220 } catch (err) { 221 console.error('Failed to fetch game moves from Constellation:', err); 222 } 223 224 allMoves.sort((a, b) => a.moveNumber - b.moveNumber); 225 return allMoves; 226} 227 228/** Fetch all passes for a game from Constellation backlinks. */ 229export async function fetchGamePasses( 230 gameAtUri: string 231): Promise<PassRecord[]> { 232 const allPasses: PassRecord[] = []; 233 let cursor: string | undefined; 234 235 try { 236 do { 237 const params = new URLSearchParams({ 238 subject: gameAtUri, 239 source: 'boo.sky.go.pass:game', 240 limit: '100', 241 }); 242 if (cursor) params.set('cursor', cursor); 243 244 const res = await fetch( 245 `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 246 { headers: { Accept: 'application/json' } } 247 ); 248 249 if (!res.ok) break; 250 251 const body: ConstellationBacklinksResponse = await res.json(); 252 for (const rec of body.records) { 253 allPasses.push(rec.value as PassRecord); 254 } 255 cursor = body.cursor ?? undefined; 256 } while (cursor); 257 } catch (err) { 258 console.error('Failed to fetch game passes from Constellation:', err); 259 } 260 261 allPasses.sort((a, b) => a.moveNumber - b.moveNumber); 262 return allPasses; 263} 264 265/** Fetch the total move count for a game from Constellation. */ 266export async function fetchMoveCount( 267 gameAtUri: string 268): Promise<number | null> { 269 try { 270 const params = new URLSearchParams({ 271 subject: gameAtUri, 272 source: 'boo.sky.go.move:game', 273 limit: '1', 274 }); 275 const res = await fetch( 276 `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 277 { headers: { Accept: 'application/json' } } 278 ); 279 if (res.ok) { 280 const body = await res.json(); 281 if (typeof body.total === 'number') return body.total; 282 return 0; 283 } 284 } catch (err) { 285 console.error('Constellation fetch failed for', gameAtUri, err); 286 } 287 return null; 288} 289 290/** 291 * Fallback: fetch moves/passes/resigns by listing records from both players' PDS repos. 292 * Used when Constellation is unavailable. 293 */ 294export async function fetchGameActionsFromPds( 295 playerOneDid: string, 296 playerTwoDid: string | null, 297 gameAtUri: string 298): Promise<{ moves: MoveRecord[]; passes: PassRecord[]; resigns: ResignRecord[] }> { 299 const moves: MoveRecord[] = []; 300 const passes: PassRecord[] = []; 301 const resigns: ResignRecord[] = []; 302 303 const dids = [playerOneDid]; 304 if (playerTwoDid) dids.push(playerTwoDid); 305 306 for (const did of dids) { 307 const pds = await resolvePdsHost(did); 308 if (!pds) { 309 console.warn('[fetchGameActionsFromPds] Could not resolve PDS for:', did); 310 continue; 311 } 312 313 // Fetch moves with pagination 314 try { 315 let cursor: string | undefined; 316 do { 317 const moveParams = new URLSearchParams({ 318 repo: did, 319 collection: 'boo.sky.go.move', 320 limit: '100', 321 }); 322 if (cursor) moveParams.set('cursor', cursor); 323 324 const moveRes = await fetch( 325 `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 326 ); 327 328 if (moveRes.ok) { 329 const data = await moveRes.json(); 330 331 for (const rec of data.records || []) { 332 if (rec.value?.game === gameAtUri) { 333 moves.push({ 334 ...(rec.value as MoveRecord), 335 uri: rec.uri, // Include the AT URI 336 }); 337 } 338 } 339 340 cursor = data.cursor; 341 } else { 342 break; 343 } 344 } while (cursor); 345 } catch (err) { 346 console.error('[fetchGameActionsFromPds] Failed to list move records from PDS for', did, err); 347 } 348 349 // Fetch passes with pagination 350 try { 351 let cursor: string | undefined; 352 do { 353 const passParams = new URLSearchParams({ 354 repo: did, 355 collection: 'boo.sky.go.pass', 356 limit: '100', 357 }); 358 if (cursor) passParams.set('cursor', cursor); 359 360 const passRes = await fetch( 361 `${pds}/xrpc/com.atproto.repo.listRecords?${passParams}` 362 ); 363 if (passRes.ok) { 364 const data = await passRes.json(); 365 for (const rec of data.records || []) { 366 if (rec.value?.game === gameAtUri) { 367 passes.push(rec.value as PassRecord); 368 } 369 } 370 cursor = data.cursor; 371 } else { 372 break; 373 } 374 } while (cursor); 375 } catch (err) { 376 console.error('Failed to list pass records from PDS for', did, err); 377 } 378 379 // Fetch resigns with pagination 380 try { 381 let cursor: string | undefined; 382 do { 383 const resignParams = new URLSearchParams({ 384 repo: did, 385 collection: 'boo.sky.go.resign', 386 limit: '100', 387 }); 388 if (cursor) resignParams.set('cursor', cursor); 389 390 const resignRes = await fetch( 391 `${pds}/xrpc/com.atproto.repo.listRecords?${resignParams}` 392 ); 393 if (resignRes.ok) { 394 const data = await resignRes.json(); 395 for (const rec of data.records || []) { 396 if (rec.value?.game === gameAtUri) { 397 resigns.push(rec.value as ResignRecord); 398 } 399 } 400 cursor = data.cursor; 401 } else { 402 break; 403 } 404 } while (cursor); 405 } catch (err) { 406 console.error('Failed to list resign records from PDS for', did, err); 407 } 408 } 409 410 moves.sort((a, b) => a.moveNumber - b.moveNumber); 411 passes.sort((a, b) => a.moveNumber - b.moveNumber); 412 resigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); 413 414 return { moves, passes, resigns }; 415} 416 417export interface ReactionWithAuthor extends ReactionRecord { 418 uri: string; 419 author: string; 420 authorHandle?: string; 421} 422 423interface ConstellationBacklinkRef { 424 did: string; 425 collection: string; 426 rkey: string; 427} 428 429/** Fetch all reactions for a game from Constellation backlinks. Returns a map of moveUri -> reactions. */ 430export async function fetchGameReactions( 431 gameAtUri: string 432): Promise<Map<string, ReactionWithAuthor[]>> { 433 const reactionsByMove = new Map<string, ReactionWithAuthor[]>(); 434 const backlinkRefs: ConstellationBacklinkRef[] = []; 435 let cursor: string | undefined; 436 437 try { 438 // Step 1: Get backlink references from Constellation 439 do { 440 const params = new URLSearchParams({ 441 subject: gameAtUri, 442 source: 'boo.sky.go.reaction:game', 443 limit: '100', 444 }); 445 if (cursor) params.set('cursor', cursor); 446 447 const res = await fetch( 448 `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 449 { headers: { Accept: 'application/json' } } 450 ); 451 452 if (!res.ok) break; 453 454 const body = await res.json(); 455 for (const rec of body.records || []) { 456 if (rec.did && rec.collection && rec.rkey) { 457 backlinkRefs.push(rec as ConstellationBacklinkRef); 458 } 459 } 460 cursor = body.cursor ?? undefined; 461 } while (cursor); 462 463 // Step 2: Fetch actual records from each author's PDS 464 const fetchPromises = backlinkRefs.map(async (ref) => { 465 try { 466 const pds = await resolvePdsHost(ref.did); 467 if (!pds) return null; 468 469 const params = new URLSearchParams({ 470 repo: ref.did, 471 collection: ref.collection, 472 rkey: ref.rkey, 473 }); 474 475 const res = await fetch( 476 `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 477 ); 478 479 if (!res.ok) return null; 480 481 const data = await res.json(); 482 if (!data.value) return null; 483 484 const reaction = data.value as ReactionRecord; 485 const uri = data.uri || `at://${ref.did}/${ref.collection}/${ref.rkey}`; 486 487 return { 488 ...reaction, 489 uri, 490 author: ref.did, 491 } as ReactionWithAuthor; 492 } catch { 493 return null; 494 } 495 }); 496 497 const reactions = (await Promise.all(fetchPromises)).filter((r): r is ReactionWithAuthor => r !== null); 498 499 // Step 3: Group by move URI 500 for (const reaction of reactions) { 501 const moveUri = reaction.move; 502 const existing = reactionsByMove.get(moveUri) || []; 503 existing.push(reaction); 504 reactionsByMove.set(moveUri, existing); 505 } 506 507 // Sort reactions within each move by creation time, newest first 508 for (const [, reacts] of reactionsByMove) { 509 reacts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 510 } 511 } catch (err) { 512 console.error('Failed to fetch reactions from Constellation:', err); 513 } 514 515 return reactionsByMove; 516} 517 518export interface UserProfile { 519 did: string; 520 handle: string; 521 displayName?: string; 522 avatar?: string; 523 description?: string; 524} 525 526/** 527 * Fetch user profile including avatar from Bluesky public API 528 */ 529export async function fetchUserProfile(did: string): Promise<UserProfile | null> { 530 try { 531 const params = new URLSearchParams({ actor: did }); 532 const res = await fetch( 533 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}`, 534 { headers: { Accept: 'application/json' } } 535 ); 536 537 if (!res.ok) return null; 538 539 const data = await res.json(); 540 return { 541 did: data.did, 542 handle: data.handle, 543 displayName: data.displayName, 544 avatar: data.avatar, 545 description: data.description, 546 }; 547 } catch (err) { 548 console.error('Failed to fetch user profile:', err); 549 return null; 550 } 551} 552 553/** 554 * Fetch a Cloud Go profile record from a player's PDS 555 */ 556export async function fetchCloudGoProfile(did: string): Promise<ProfileRecord | null> { 557 const pds = await resolvePdsHost(did); 558 if (!pds) return null; 559 560 try { 561 const params = new URLSearchParams({ 562 repo: did, 563 collection: 'boo.sky.go.profile', 564 rkey: 'self', 565 }); 566 const res = await fetch( 567 `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 568 ); 569 if (res.ok) { 570 const data = await res.json(); 571 return data.value as ProfileRecord; 572 } 573 } catch (err) { 574 console.error('Failed to fetch Cloud Go profile:', err); 575 } 576 return null; 577} 578 579/** 580 * Fetch resign records for a game from Constellation backlinks 581 */ 582export async function fetchGameResigns( 583 gameAtUri: string 584): Promise<ResignRecord[]> { 585 const allResigns: ResignRecord[] = []; 586 let cursor: string | undefined; 587 588 try { 589 do { 590 const params = new URLSearchParams({ 591 subject: gameAtUri, 592 source: 'boo.sky.go.resign:game', 593 limit: '100', 594 }); 595 if (cursor) params.set('cursor', cursor); 596 597 const res = await fetch( 598 `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 599 { headers: { Accept: 'application/json' } } 600 ); 601 602 if (!res.ok) break; 603 604 const body: ConstellationBacklinksResponse = await res.json(); 605 for (const rec of body.records) { 606 allResigns.push(rec.value as ResignRecord); 607 } 608 cursor = body.cursor ?? undefined; 609 } while (cursor); 610 } catch (err) { 611 console.error('Failed to fetch game resigns from Constellation:', err); 612 } 613 614 allResigns.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); 615 return allResigns; 616} 617 618export interface GameWithMetadata extends GameRecord { 619 uri: string; 620 rkey: string; 621 creatorDid: string; 622 actionCount: number; 623 lastActionType: 'move' | 'pass' | `resigned:${string}`; 624 updatedAt: string; 625} 626 627/** 628 * Calculate action count and metadata for a game by fetching moves/passes/resigns 629 */ 630export async function calculateGameMetadata( 631 gameUri: string, 632 gameRecord: GameRecord, 633 creatorDid: string, 634 rkey: string 635): Promise<GameWithMetadata> { 636 // Fetch all actions in parallel 637 const [moves, passes, resigns] = await Promise.all([ 638 fetchGameMoves(gameUri), 639 fetchGamePasses(gameUri), 640 fetchGameResigns(gameUri), 641 ]); 642 643 const actionCount = moves.length + passes.length; 644 645 // Determine last action type 646 let lastActionType: 'move' | 'pass' | `resigned:${string}` = 'move'; 647 let updatedAt = gameRecord.createdAt; 648 649 if (resigns.length > 0) { 650 const lastResign = resigns[resigns.length - 1]; 651 lastActionType = `resigned:${lastResign.color}` as `resigned:${string}`; 652 updatedAt = lastResign.createdAt; 653 } else { 654 const allActions = [ 655 ...moves.map(m => ({ type: 'move' as const, createdAt: m.createdAt })), 656 ...passes.map(p => ({ type: 'pass' as const, createdAt: p.createdAt })), 657 ].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 658 659 if (allActions.length > 0) { 660 lastActionType = allActions[0].type; 661 updatedAt = allActions[0].createdAt; 662 } 663 } 664 665 return { 666 ...gameRecord, 667 uri: gameUri, 668 rkey, 669 creatorDid, 670 actionCount, 671 lastActionType, 672 updatedAt, 673 }; 674} 675 676/** 677 * Find a game by creator DID and rkey 678 */ 679export async function findGame(creatorDid: string, rkey: string): Promise<GameWithMetadata | null> { 680 const gameRecord = await fetchGameRecord(creatorDid, rkey); 681 if (!gameRecord) return null; 682 683 const gameUri = `at://${creatorDid}/boo.sky.go.game/${rkey}`; 684 return await calculateGameMetadata(gameUri, gameRecord, creatorDid, rkey); 685} 686 687/** 688 * Find a game by rkey by searching through known players' PDSs 689 * This requires either knowing potential creator DIDs or searching through all known players 690 */ 691export async function findGameByRkey(rkey: string, potentialCreatorDids: string[] = []): Promise<GameWithMetadata | null> { 692 // Try each potential creator until we find the game 693 for (const did of potentialCreatorDids) { 694 const game = await findGame(did, rkey); 695 if (game) return game; 696 } 697 698 return null; 699} 700 701/** 702 * List all game records from a player's PDS 703 */ 704export async function listPlayerGames(did: string): Promise<Array<{ uri: string; rkey: string; value: GameRecord }>> { 705 const pds = await resolvePdsHost(did); 706 if (!pds) return []; 707 708 const games: Array<{ uri: string; rkey: string; value: GameRecord }> = []; 709 let cursor: string | undefined; 710 711 try { 712 do { 713 const params = new URLSearchParams({ 714 repo: did, 715 collection: 'boo.sky.go.game', 716 limit: '100', 717 }); 718 if (cursor) params.set('cursor', cursor); 719 720 const res = await fetch( 721 `${pds}/xrpc/com.atproto.repo.listRecords?${params}` 722 ); 723 724 if (!res.ok) break; 725 726 const data = await res.json(); 727 for (const rec of data.records || []) { 728 games.push({ 729 uri: rec.uri, 730 rkey: rec.uri.split('/').pop() || '', 731 value: rec.value as GameRecord, 732 }); 733 } 734 cursor = data.cursor; 735 } while (cursor); 736 } catch (err) { 737 console.error('Failed to list player games:', err); 738 } 739 740 return games; 741} 742 743/** 744 * Fetch all games by querying multiple known players' PDSs 745 * This is the primary method since Constellation doesn't expose a simple listRecords API 746 */ 747export async function fetchAllGames(knownPlayerDids: string[] = []): Promise<GameWithMetadata[]> { 748 const gameMap = new Map<string, { uri: string; rkey: string; value: GameRecord; creatorDid: string }>(); 749 750 // Fetch games from all known players 751 const fetchPromises = knownPlayerDids.map(async (did) => { 752 const games = await listPlayerGames(did); 753 return { did, games }; 754 }); 755 756 const results = await Promise.all(fetchPromises); 757 758 for (const { did, games } of results) { 759 for (const game of games) { 760 if (!gameMap.has(game.uri)) { 761 gameMap.set(game.uri, { ...game, creatorDid: did }); 762 } 763 } 764 } 765 766 // Calculate metadata for all games in parallel 767 const metadataPromises = Array.from(gameMap.values()).map(({ uri, value, creatorDid, rkey }) => 768 calculateGameMetadata(uri, value, creatorDid, rkey) 769 ); 770 771 const gamesWithMetadata = await Promise.all(metadataPromises); 772 return gamesWithMetadata; 773}