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.
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}