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.

Add interactive puzzle solving and studied tracking

Enhancements:
- Made study board interactive for puzzle solving
- Players can now place stones to work through puzzles
- Added reset board button to restore initial puzzle position
- Track move history and turn state
- Created boo.sky.go.studied lexicon for tracking completed puzzles
- Added /api/study endpoint for marking and fetching studied puzzles
- Added "Mark as Studied" button (visible only when logged in)
- Visual indicator shows ✓ Studied for completed puzzles
- Studied status persists via ATProto records
- Fetches user's studied puzzles on mount

Users can now interactively solve puzzles and track their progress
across devices via the ATProto network.

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

+262 -2
+36
lexicons/boo.sky.go.studied.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "boo.sky.go.studied", 4 + "description": "Record of a Go puzzle studied by a user", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["collectionId", "problemNumber", "createdAt"], 12 + "properties": { 13 + "collectionId": { 14 + "type": "string", 15 + "description": "Identifier for the puzzle collection (e.g., 'cho-1-elementary')", 16 + "maxLength": 100 17 + }, 18 + "problemNumber": { 19 + "type": "integer", 20 + "description": "Problem number within the collection", 21 + "minimum": 1 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime" 26 + }, 27 + "notes": { 28 + "type": "string", 29 + "description": "Optional notes about the puzzle", 30 + "maxLength": 1000 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+115
src/routes/api/study/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 + 5 + function generateTid(): string { 6 + const timestamp = Date.now() * 1000; 7 + const clockid = Math.floor(Math.random() * 1024); 8 + const tid = timestamp.toString(32).padStart(11, '0') + clockid.toString(32).padStart(2, '0'); 9 + return tid; 10 + } 11 + 12 + /** 13 + * POST - Mark a puzzle as studied 14 + */ 15 + export const POST: RequestHandler = async (event) => { 16 + const session = await getSession(event); 17 + 18 + if (!session) { 19 + throw error(401, 'Not authenticated'); 20 + } 21 + 22 + try { 23 + const body = await event.request.json(); 24 + const { collectionId, problemNumber, notes } = body; 25 + 26 + if (!collectionId || !problemNumber) { 27 + throw error(400, 'Missing required fields: collectionId and problemNumber'); 28 + } 29 + 30 + const agent = await getAgent(event); 31 + if (!agent) { 32 + throw error(401, 'Failed to get authenticated agent'); 33 + } 34 + 35 + const rkey = generateTid(); 36 + const now = new Date().toISOString(); 37 + 38 + const studiedRecord = { 39 + $type: 'boo.sky.go.studied', 40 + collectionId, 41 + problemNumber, 42 + createdAt: now, 43 + ...(notes && { notes }), 44 + }; 45 + 46 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 47 + input: { 48 + repo: session.did, 49 + collection: 'boo.sky.go.studied', 50 + rkey, 51 + record: studiedRecord, 52 + }, 53 + }); 54 + 55 + if (!result.ok) { 56 + throw new Error(`Failed to create studied record: ${result.data.message}`); 57 + } 58 + 59 + return json({ 60 + success: true, 61 + uri: result.data.uri, 62 + collectionId, 63 + problemNumber, 64 + }); 65 + } catch (err) { 66 + console.error('Failed to mark puzzle as studied:', err); 67 + throw error(500, 'Failed to mark puzzle as studied'); 68 + } 69 + }; 70 + 71 + /** 72 + * GET - Fetch all studied puzzles for the current user 73 + */ 74 + export const GET: RequestHandler = async (event) => { 75 + const session = await getSession(event); 76 + 77 + if (!session) { 78 + throw error(401, 'Not authenticated'); 79 + } 80 + 81 + try { 82 + const agent = await getAgent(event); 83 + if (!agent) { 84 + throw error(401, 'Failed to get authenticated agent'); 85 + } 86 + 87 + // Fetch studied records from the user's repo 88 + const result = await (agent as any).get('com.atproto.repo.listRecords', { 89 + params: { 90 + repo: session.did, 91 + collection: 'boo.sky.go.studied', 92 + limit: 100, 93 + }, 94 + }); 95 + 96 + if (!result.ok) { 97 + throw new Error(`Failed to fetch studied records: ${result.data.message}`); 98 + } 99 + 100 + const studiedPuzzles = result.data.records.map((record: any) => ({ 101 + uri: record.uri, 102 + collectionId: record.value.collectionId, 103 + problemNumber: record.value.problemNumber, 104 + createdAt: record.value.createdAt, 105 + notes: record.value.notes, 106 + })); 107 + 108 + return json({ 109 + puzzles: studiedPuzzles, 110 + }); 111 + } catch (err) { 112 + console.error('Failed to fetch studied puzzles:', err); 113 + throw error(500, 'Failed to fetch studied puzzles'); 114 + } 115 + };
+111 -2
src/routes/study/+page.svelte
··· 6 6 import Board from '$lib/components/Board.svelte'; 7 7 import { PUZZLE_COLLECTIONS, loadCollection, type SgfPuzzle } from '$lib/puzzle-collections'; 8 8 import type { PuzzleCollection } from '$lib/puzzle-collections'; 9 + import type { LayoutData } from '../$types'; 10 + 11 + let { data }: { data: LayoutData } = $props(); 9 12 10 13 let selectedCollectionId = $state('cho-1-elementary'); 11 14 let currentPuzzleIndex = $state(0); ··· 15 18 let boardComponent = $state<any>(null); 16 19 let selectedCollection = $derived(PUZZLE_COLLECTIONS.find(c => c.id === selectedCollectionId)); 17 20 let currentPuzzle = $derived(puzzles[currentPuzzleIndex]); 21 + let currentTurn = $state<'black' | 'white'>('black'); 22 + let moveHistory = $state<Array<{ x: number; y: number; color: 'black' | 'white' }>>([]); 23 + let studiedPuzzles = $state<Set<string>>(new Set()); 24 + let isMarkingStudied = $state(false); 25 + let session = $derived(data?.session); 26 + let isPuzzleStudied = $derived( 27 + currentPuzzle ? studiedPuzzles.has(`${selectedCollectionId}:${currentPuzzle.number}`) : false 28 + ); 18 29 19 30 // Load URL parameters on mount 20 - onMount(() => { 31 + onMount(async () => { 21 32 const urlParams = new URLSearchParams(window.location.search); 22 33 const collectionParam = urlParams.get('collection'); 23 34 const problemParam = urlParams.get('problem'); ··· 33 44 currentPuzzleIndex = problemNum - 1; 34 45 } 35 46 } 47 + 48 + // Load studied puzzles if user is logged in 49 + if (session) { 50 + await loadStudiedPuzzles(); 51 + } 36 52 }); 37 53 54 + async function loadStudiedPuzzles() { 55 + try { 56 + const response = await fetch('/api/study'); 57 + if (response.ok) { 58 + const data = await response.json(); 59 + const studied = new Set<string>(); 60 + data.puzzles.forEach((p: any) => { 61 + studied.add(`${p.collectionId}:${p.problemNumber}`); 62 + }); 63 + studiedPuzzles = studied; 64 + } 65 + } catch (err) { 66 + console.error('Failed to load studied puzzles:', err); 67 + } 68 + } 69 + 70 + async function markAsStudied() { 71 + if (!currentPuzzle || !session) return; 72 + 73 + isMarkingStudied = true; 74 + try { 75 + const response = await fetch('/api/study', { 76 + method: 'POST', 77 + headers: { 'Content-Type': 'application/json' }, 78 + body: JSON.stringify({ 79 + collectionId: selectedCollectionId, 80 + problemNumber: currentPuzzle.number, 81 + }), 82 + }); 83 + 84 + if (response.ok) { 85 + const key = `${selectedCollectionId}:${currentPuzzle.number}`; 86 + studiedPuzzles = new Set([...studiedPuzzles, key]); 87 + } else { 88 + alert('Failed to mark puzzle as studied. Please try again.'); 89 + } 90 + } catch (err) { 91 + console.error('Failed to mark as studied:', err); 92 + alert('Failed to mark puzzle as studied. Please try again.'); 93 + } finally { 94 + isMarkingStudied = false; 95 + } 96 + } 97 + 38 98 // Load collection when selection changes 39 99 $effect(() => { 40 100 if (selectedCollectionId) { ··· 82 142 function displayPuzzle() { 83 143 if (!currentPuzzle || !boardComponent) return; 84 144 145 + // Reset move history and turn 146 + moveHistory = []; 147 + currentTurn = 'black'; 148 + 149 + // Clear the board first by triggering a replay with empty moves 150 + boardComponent.replayToMove(-1); 151 + 85 152 // Add black stones 86 153 currentPuzzle.blackStones.forEach(stone => { 87 154 boardComponent.addStone(stone.x, stone.y, 'black'); ··· 91 158 currentPuzzle.whiteStones.forEach(stone => { 92 159 boardComponent.addStone(stone.x, stone.y, 'white'); 93 160 }); 161 + } 162 + 163 + function resetBoard() { 164 + displayPuzzle(); 165 + } 166 + 167 + function handleMove(x: number, y: number, captures: number) { 168 + // Record the move 169 + moveHistory.push({ x, y, color: currentTurn }); 170 + 171 + // Toggle turn 172 + currentTurn = currentTurn === 'black' ? 'white' : 'black'; 94 173 } 95 174 96 175 function selectCollection(collectionId: string) { ··· 200 279 bind:this={boardComponent} 201 280 boardSize={currentPuzzle.boardSize} 202 281 gameState={{ moves: [] }} 203 - interactive={false} 282 + interactive={true} 283 + currentTurn={currentTurn} 284 + onMove={handleMove} 204 285 /> 205 286 </div> 206 287 288 + <div class="puzzle-actions"> 289 + <Button onclick={resetBoard} variant="secondary"> 290 + 🔄 Reset Board 291 + </Button> 292 + {#if session} 293 + <Button 294 + onclick={markAsStudied} 295 + disabled={isMarkingStudied || isPuzzleStudied} 296 + variant={isPuzzleStudied ? 'success' : 'primary'} 297 + > 298 + {#if isPuzzleStudied} 299 + ✓ Studied 300 + {:else if isMarkingStudied} 301 + Marking... 302 + {:else} 303 + Mark as Studied 304 + {/if} 305 + </Button> 306 + {/if} 307 + </div> 308 + 207 309 <div class="puzzle-controls"> 208 310 <Button 209 311 onclick={previousPuzzle} ··· 345 447 display: flex; 346 448 justify-content: center; 347 449 margin: 2rem 0; 450 + } 451 + 452 + .puzzle-actions { 453 + display: flex; 454 + justify-content: center; 455 + gap: 1rem; 456 + margin: 1rem 0; 348 457 } 349 458 350 459 .puzzle-controls {