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 { json, error } from '@sveltejs/kit';
2import type { RequestHandler } from './$types';
3import { getSession, getAgent } from '$lib/server/auth';
4import { getDb } from '$lib/server/db';
5
6function generateTid(): string {
7 const timestamp = Date.now() * 1000;
8 const clockid = Math.floor(Math.random() * 1024);
9 const tid = timestamp.toString(32).padStart(11, '0') + clockid.toString(32).padStart(2, '0');
10 return tid;
11}
12
13async function resolveHandleToDid(handle: string): Promise<string | null> {
14 try {
15 // Remove @ prefix if present
16 const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
17
18 const response = await fetch(
19 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanHandle)}`
20 );
21
22 if (!response.ok) return null;
23
24 const data = await response.json();
25 return data.did || null;
26 } catch (err) {
27 console.error('Failed to resolve handle:', err);
28 return null;
29 }
30}
31
32// Convert Go notation (e.g., "D4") to board coordinates
33function notationToCoords(notation: string, boardSize: number): { x: number; y: number } {
34 const col = notation.charAt(0);
35 const row = parseInt(notation.substring(1));
36
37 // Convert column letter to x coordinate (A=0, B=1, C=2, D=3, etc., skipping I)
38 let x = col.charCodeAt(0) - 65; // A=0
39 if (col >= 'J') {
40 x -= 1; // Skip I
41 }
42
43 // Convert row number to y coordinate (bottom to top)
44 const y = boardSize - row;
45
46 return { x, y };
47}
48
49// Get handicap stone positions for a given board size and handicap count
50function getHandicapPositions(boardSize: number, handicap: number): Array<{ x: number; y: number }> {
51 const positions19x19 = ['D4', 'Q16', 'D16', 'Q4', 'D10', 'Q10', 'K16', 'K4', 'K10'];
52 const positions13x13 = ['D4', 'K10', 'D10', 'K4', 'G7'];
53
54 const notations = boardSize === 19 ? positions19x19 : boardSize === 13 ? positions13x13 : [];
55
56 return notations
57 .slice(0, handicap)
58 .map(notation => notationToCoords(notation, boardSize));
59}
60
61export const POST: RequestHandler = async (event) => {
62 const session = await getSession(event);
63
64 if (!session) {
65 throw error(401, 'Not authenticated');
66 }
67
68 const {
69 boardSize = 19,
70 opponentHandle,
71 colorChoice = 'black',
72 handicap = 0
73 } = await event.request.json();
74
75 if (![5, 7, 9, 13, 19].includes(boardSize)) {
76 throw error(400, 'Invalid board size. Supported: 5x5, 7x7, 9x9, 13x13, 19x19');
77 }
78
79 // Validate handicap
80 const maxHandicap = boardSize === 19 ? 9 : boardSize === 13 ? 5 : 0;
81 if (handicap < 0 || handicap > maxHandicap) {
82 throw error(400, `Invalid handicap. Max for ${boardSize}x${boardSize} is ${maxHandicap}`);
83 }
84
85 try {
86 const agent = await getAgent(event);
87 if (!agent) {
88 throw error(401, 'Failed to get authenticated agent');
89 }
90
91 // Resolve opponent handle if provided
92 let opponentDid: string | null = null;
93 if (opponentHandle) {
94 opponentDid = await resolveHandleToDid(opponentHandle);
95 if (!opponentDid) {
96 return json({ error: 'Could not find user with that handle. Please check the handle and try again.' }, { status: 400 });
97 }
98 if (opponentDid === session.did) {
99 return json({ error: 'You cannot play against yourself!' }, { status: 400 });
100 }
101 }
102
103 // Determine player assignments based on color choice and handicap
104 let playerOne: string;
105 let playerTwo: string | null;
106
107 if (handicap > 0) {
108 // With handicap, creator is always white (playerTwo)
109 playerOne = opponentDid || ''; // Will be filled when opponent joins
110 playerTwo = session.did;
111 } else if (colorChoice === 'random') {
112 // Random assignment
113 const creatorIsBlack = Math.random() < 0.5;
114 if (creatorIsBlack) {
115 playerOne = session.did;
116 playerTwo = opponentDid;
117 } else {
118 playerOne = opponentDid || '';
119 playerTwo = session.did;
120 }
121 } else if (colorChoice === 'white') {
122 // Creator wants white
123 playerOne = opponentDid || '';
124 playerTwo = session.did;
125 } else {
126 // Creator wants black (default)
127 playerOne = session.did;
128 playerTwo = opponentDid;
129 }
130
131 const rkey = generateTid();
132 const now = new Date().toISOString();
133
134 // Create game record in AT Protocol
135 const record: any = {
136 $type: 'boo.sky.go.game',
137 playerOne: playerOne || undefined,
138 playerTwo: playerTwo || undefined,
139 boardSize,
140 status: opponentDid ? 'active' : 'waiting',
141 createdAt: now,
142 };
143
144 // Add handicap stones if needed
145 if (handicap > 0) {
146 const handicapPositions = getHandicapPositions(boardSize, handicap);
147 record.handicap = handicap;
148 record.handicapStones = handicapPositions;
149 }
150
151 // Publish to AT Protocol
152 const result = await (agent as any).post('com.atproto.repo.createRecord', {
153 input: {
154 repo: session.did,
155 collection: 'boo.sky.go.game',
156 rkey,
157 record,
158 },
159 });
160
161 if (!result.ok) {
162 throw new Error(`Failed to create record: ${result.data.message}`);
163 }
164
165 const uri = result.data.uri;
166
167 // Store in local discovery index
168 const db = getDb(event.platform);
169 await db
170 .insertInto('games')
171 .values({
172 id: uri,
173 rkey,
174 creator_did: session.did, // The person who created the game owns the ATProto record
175 player_one: playerOne || null,
176 player_two: playerTwo || null,
177 board_size: boardSize,
178 status: opponentDid ? 'active' : 'waiting',
179 action_count: 0,
180 last_action_type: null,
181 winner: null,
182 handicap: handicap,
183 created_at: now,
184 updated_at: now,
185 })
186 .execute();
187
188 return json({ gameId: rkey, uri });
189 } catch (err) {
190 console.error('Failed to create game:', err);
191 throw error(500, 'Failed to create game');
192 }
193};