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.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203import { Kysely } from 'kysely';import { D1Dialect } from 'kysely-d1';
/** The subset of the platform env this module needs. */type PlatformEnv = NonNullable<App.Platform['env']>;
/** * Minimal structural shape needed to reach the D1 binding. `App.Platform` * satisfies it, as does the plain `{ env }` of a standalone Worker. */export interface DbEnv { env?: Pick<PlatformEnv, 'DB'>;}
export interface GameRecord { id: string; // AT URI (game_at_uri) rkey: string; // Record key (TID) creator_did: string; // DID of whoever created the game (owns the ATProto record) player_one: string | null; player_two: string | null; board_size: number; status: 'waiting' | 'active' | 'completed'; action_count: number; last_action_type: string | null; winner: string | null; handicap: number; created_at: string; updated_at: string;}
export interface Database { games: GameRecord;}
export function getDb(platform: DbEnv | undefined): Kysely<Database> { // Production: Use Cloudflare D1 if (platform?.env?.DB) { return new Kysely<Database>({ dialect: new D1Dialect({ database: platform.env.DB }), }); }
// No D1 configured throw new Error( 'Database not configured. In Cloudflare, set up D1 and bind it as "DB". ' + 'For local development, use `wrangler pages dev` with D1 bindings.' );}
const UFOS_API = 'https://ufos-api.microcosm.blue';const CONSTELLATION_API = 'https://constellation.microcosm.blue/xrpc';
/** * Read the backlink count out of a Constellation `getBacklinks` response. * `records` is truncated to `limit`, so prefer the authoritative `total`. */function backlinkCount(data: unknown): number { const d = data as { total?: unknown; records?: unknown } | null; if (typeof d?.total === 'number') return d.total; return Array.isArray(d?.records) ? d.records.length : 0;}
/** * Look up a game by rkey, falling back to UFOs + Constellation for * records created outside Cloud Go (third-party PDS writes). * Upserts into D1 so subsequent lookups are fast. */export async function ensureGameInDb( rkey: string, platform: DbEnv | undefined): Promise<[GameRecord, Kysely<Database>] | null> { const db = getDb(platform);
const existing = await db .selectFrom('games') .selectAll() .where('rkey', '=', rkey) .executeTakeFirst();
if (existing) return [existing, db];
// Not in the discovery index — fall back to the public ATProto indexes. // NOTE: UFOs `/records` ignores `limit` and returns only a capped sample of // recent records (no cursor), so this is best-effort: older games are not // discoverable here and will 404. const ufosRes = await fetch(`${UFOS_API}/records?collection=boo.sky.go.game`); if (!ufosRes.ok) return null;
const allGames = (await ufosRes.json()) as Array<{ did: string; collection: string; rkey: string; record: Record<string, unknown>; time_us: number; }>;
if (!Array.isArray(allGames)) return null;
const game = allGames.find((g) => g.rkey === rkey); if (!game) return null;
const uri = `at://${game.did}/${game.collection}/${game.rkey}`; const rec = game.record as Record<string, unknown>; const creatorDid = game.did; const playerOne = (rec.playerOne as string) || creatorDid; let playerTwo = (rec.playerTwo as string) || null;
// Reconstruct action state from Constellation backlinks const [movesRes, passesRes, resignsRes] = await Promise.all([ fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.move:game&limit=1000` ), fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.pass:game&limit=1000` ), fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.resign:game&limit=1000` ), ]);
const movesData = movesRes.ok ? await movesRes.json() : null; const passesData = passesRes.ok ? await passesRes.json() : null; const resignsData = resignsRes.ok ? await resignsRes.json() : null;
const moveRecords: Array<{ did: string }> = Array.isArray(movesData?.records) ? movesData.records : []; const moveCount = backlinkCount(movesData); const passCount = backlinkCount(passesData); const resignCount = backlinkCount(resignsData);
const actionCount = moveCount + passCount;
// Infer playerTwo from backlink authors if not set in the record if (!playerTwo && actionCount >= 2) { const dids = new Set<string>(); for (const m of moveRecords) dids.add(m.did); dids.delete(creatorDid); if (dids.size === 1) playerTwo = [...dids][0]; }
// Derive status from PDS data let status: 'waiting' | 'active' | 'completed' = 'waiting'; if ( (rec.blackScore !== undefined && rec.blackScore !== null) || (rec.whiteScore !== undefined && rec.whiteScore !== null) || (rec.winner !== undefined && rec.winner !== null) || resignCount > 0 ) { status = 'completed'; } else if (actionCount > 0 || playerTwo) { status = 'active'; }
// Determine last action type for turn/consecutive-pass tracking. // Without per-record timestamps we can't order mixed actions precisely; // conservatively treat the last action as 'move' whenever moves exist, // since two consecutive passes (game-ending) require *only* passes at the tail. let lastActionType: string | null = null; if (actionCount > 0) { lastActionType = passCount > 0 && moveCount === 0 ? 'pass' : 'move'; }
const now = new Date().toISOString(); const createdAt = typeof rec.createdAt === 'string' ? rec.createdAt : now;
const row: GameRecord = { id: uri, rkey, creator_did: creatorDid, player_one: playerOne, player_two: playerTwo, board_size: (rec.boardSize as number) || 19, status, action_count: actionCount, last_action_type: lastActionType, winner: (rec.winner as string) || null, handicap: (rec.handicap as number) || 0, created_at: createdAt, updated_at: now, };
await db .insertInto('games') .values(row) .onConflict((oc) => oc.column('id').doUpdateSet({ player_two: row.player_two, board_size: row.board_size, status: row.status, action_count: row.action_count, last_action_type: row.last_action_type, winner: row.winner, handicap: row.handicap, updated_at: now, }) ) .execute();
return [row, db];}