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.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add study/practice tab with Go problem collections

Implements study mode with 2,500+ Go problems from scrapeGo repository:

Features:
- Enhanced SGF parser supporting AB/AW properties for puzzle positions
- Multi-problem SGF parsing for collection files
- Puzzle collection management with GitHub fetching and caching
- Study route with collection selector, puzzle display, and navigation
- 5 puzzle collections: Cho's Encyclopedia (Vols 1-3), Gokyo Shumyo, Hatsuyoron
- Deep linking support via URL parameters (?collection=cho-1&problem=42)
- Cloud-themed UI consistent with existing design
- Full credits and attribution section for source materials

Collections from https://github.com/mango314/scrapeGo

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

+661 -1
+1
src/lib/components/Footer.svelte
··· 15 15 Replay Tutorial 16 16 </Button> 17 17 <a href="/rules">Rules</a> 18 + <a href="/study">Study</a> 18 19 </div> 19 20 <div class="footer-credit"> 20 21 Made with ❤️ by <a href="https://goose.business/" target="_blank" rel="noopener noreferrer">Business Goose</a> with help from
+105
src/lib/puzzle-collections.ts
··· 1 + /** 2 + * Puzzle collection management for study/practice mode 3 + * Data sourced from https://github.com/mango314/scrapeGo 4 + */ 5 + 6 + import { loadPuzzleCollection, type SgfPuzzle } from './sgf-parser'; 7 + 8 + export interface PuzzleCollection { 9 + id: string; 10 + name: string; 11 + difficulty: 'Elementary' | 'Intermediate' | 'Advanced' | 'Classic'; 12 + description: string; 13 + source: string; 14 + githubUrl: string; 15 + estimatedCount: number; 16 + } 17 + 18 + export const PUZZLE_COLLECTIONS: PuzzleCollection[] = [ 19 + { 20 + id: 'cho-1-elementary', 21 + name: "Cho's Encyclopedia Vol 1", 22 + difficulty: 'Elementary', 23 + description: "Cho Chikun's Encyclopedia of Life & Death (Vol 1) - Elementary Problems", 24 + source: 'Cho Chikun', 25 + githubUrl: 'https://raw.githubusercontent.com/mango314/scrapeGo/master/cho-1-elementary.sgf', 26 + estimatedCount: 900 27 + }, 28 + { 29 + id: 'cho-2-intermediate', 30 + name: "Cho's Encyclopedia Vol 2", 31 + difficulty: 'Intermediate', 32 + description: "Cho Chikun's Encyclopedia of Life & Death (Vol 2) - Intermediate Problems", 33 + source: 'Cho Chikun', 34 + githubUrl: 'https://raw.githubusercontent.com/mango314/scrapeGo/master/cho-2-intermediate.sgf', 35 + estimatedCount: 900 36 + }, 37 + { 38 + id: 'cho-3-advanced', 39 + name: "Cho's Encyclopedia Vol 3", 40 + difficulty: 'Advanced', 41 + description: "Cho Chikun's Encyclopedia of Life & Death (Vol 3) - Advanced Problems", 42 + source: 'Cho Chikun', 43 + githubUrl: 'https://raw.githubusercontent.com/mango314/scrapeGo/master/cho-3-advanced.sgf', 44 + estimatedCount: 900 45 + }, 46 + { 47 + id: 'gokyoshumyo', 48 + name: 'Gokyo Shumyo', 49 + difficulty: 'Classic', 50 + description: 'Classic Go problem collection - Gokyo Shumyo', 51 + source: 'Traditional', 52 + githubUrl: 'https://raw.githubusercontent.com/mango314/scrapeGo/master/gokyoshumyo.sgf', 53 + estimatedCount: 522 54 + }, 55 + { 56 + id: 'hatsuyoron', 57 + name: 'Hatsuyoron', 58 + difficulty: 'Classic', 59 + description: 'Classic Go problem collection - Hatsuyoron', 60 + source: 'Traditional', 61 + githubUrl: 'https://raw.githubusercontent.com/mango314/scrapeGo/master/hatsuyoron.sgf', 62 + estimatedCount: 183 63 + } 64 + ]; 65 + 66 + // Cache for loaded puzzle collections 67 + const puzzleCache = new Map<string, SgfPuzzle[]>(); 68 + 69 + /** 70 + * Load puzzles from a collection with caching 71 + */ 72 + export async function loadCollection(collectionId: string): Promise<SgfPuzzle[]> { 73 + // Check cache first 74 + if (puzzleCache.has(collectionId)) { 75 + return puzzleCache.get(collectionId)!; 76 + } 77 + 78 + // Find collection metadata 79 + const collection = PUZZLE_COLLECTIONS.find(c => c.id === collectionId); 80 + if (!collection) { 81 + throw new Error(`Collection not found: ${collectionId}`); 82 + } 83 + 84 + // Load and parse from GitHub 85 + const puzzles = await loadPuzzleCollection(collection.githubUrl); 86 + 87 + // Cache for future use 88 + puzzleCache.set(collectionId, puzzles); 89 + 90 + return puzzles; 91 + } 92 + 93 + /** 94 + * Get collection metadata by ID 95 + */ 96 + export function getCollection(collectionId: string): PuzzleCollection | undefined { 97 + return PUZZLE_COLLECTIONS.find(c => c.id === collectionId); 98 + } 99 + 100 + /** 101 + * Clear the puzzle cache (useful for testing or if data needs refresh) 102 + */ 103 + export function clearCache(): void { 104 + puzzleCache.clear(); 105 + }
+94 -1
src/lib/sgf-parser.ts
··· 1 1 /** 2 - * Simple SGF parser for extracting moves from SGF files 2 + * Simple SGF parser for extracting moves from SGF files and puzzle positions 3 3 */ 4 4 5 5 export interface SgfMove { ··· 11 11 export interface SgfData { 12 12 boardSize: number; 13 13 moves: SgfMove[]; 14 + } 15 + 16 + export interface SgfPuzzle { 17 + number: number; 18 + comment: string; 19 + blackStones: Array<{ x: number; y: number }>; 20 + whiteStones: Array<{ x: number; y: number }>; 21 + boardSize: number; 22 + solution?: SgfMove[]; 14 23 } 15 24 16 25 /** ··· 57 66 const content = await response.text(); 58 67 return parseSgf(content); 59 68 } 69 + 70 + /** 71 + * Parse coordinates from SGF format (e.g., "be" -> {x: 1, y: 4}) 72 + */ 73 + function parseCoords(coords: string): { x: number; y: number } { 74 + const x = coords.charCodeAt(0) - 97; // 'a' = 0 75 + const y = coords.charCodeAt(1) - 97; 76 + return { x, y }; 77 + } 78 + 79 + /** 80 + * Extract all coordinates from a property (e.g., AB[be][bf][cb]) 81 + */ 82 + function extractStones(propertyMatch: string): Array<{ x: number; y: number }> { 83 + const stones: Array<{ x: number; y: number }> = []; 84 + const coordPattern = /\[([a-z]{2})\]/g; 85 + let match; 86 + 87 + while ((match = coordPattern.exec(propertyMatch)) !== null) { 88 + stones.push(parseCoords(match[1])); 89 + } 90 + 91 + return stones; 92 + } 93 + 94 + /** 95 + * Parse a multi-problem SGF file into individual puzzles 96 + * Format: (;AB[...][...]AW[...][...]C[problem X]) 97 + */ 98 + export function parseMultiProblemSgf(sgfContent: string): SgfPuzzle[] { 99 + const puzzles: SgfPuzzle[] = []; 100 + let boardSize = 19; // default 101 + 102 + // Extract board size if specified 103 + const sizeMatch = sgfContent.match(/SZ\[(\d+)\]/); 104 + if (sizeMatch) { 105 + boardSize = parseInt(sizeMatch[1], 10); 106 + } 107 + 108 + // Split into individual problems - each starts with (; 109 + // Match pattern: (;...AB[...]...AW[...]...C[problem X]...) 110 + const problemPattern = /\(;[^()]+\)/g; 111 + let match; 112 + 113 + while ((match = problemPattern.exec(sgfContent)) !== null) { 114 + const problemStr = match[0]; 115 + 116 + // Extract black stones (AB property) 117 + const blackMatch = problemStr.match(/AB(\[[a-z]{2}\])+/); 118 + const blackStones = blackMatch ? extractStones(blackMatch[0]) : []; 119 + 120 + // Extract white stones (AW property) 121 + const whiteMatch = problemStr.match(/AW(\[[a-z]{2}\])+/); 122 + const whiteStones = whiteMatch ? extractStones(whiteMatch[0]) : []; 123 + 124 + // Extract comment (C property) 125 + const commentMatch = problemStr.match(/C\[([^\]]+)\]/); 126 + const comment = commentMatch ? commentMatch[1] : ''; 127 + 128 + // Extract problem number from comment 129 + const numberMatch = comment.match(/problem (\d+)/i); 130 + const number = numberMatch ? parseInt(numberMatch[1], 10) : puzzles.length + 1; 131 + 132 + puzzles.push({ 133 + number, 134 + comment, 135 + blackStones, 136 + whiteStones, 137 + boardSize, 138 + solution: [] // Solution moves can be added later if needed 139 + }); 140 + } 141 + 142 + return puzzles; 143 + } 144 + 145 + /** 146 + * Load and parse a multi-problem SGF file from a URL 147 + */ 148 + export async function loadPuzzleCollection(url: string): Promise<SgfPuzzle[]> { 149 + const response = await fetch(url); 150 + const content = await response.text(); 151 + return parseMultiProblemSgf(content); 152 + }
+461
src/routes/study/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { page } from '$app/stores'; 4 + import { goto } from '$app/navigation'; 5 + import { Card, Button, Badge } from '$lib/components/ui'; 6 + import Board from '$lib/components/Board.svelte'; 7 + import { PUZZLE_COLLECTIONS, loadCollection, type SgfPuzzle } from '$lib/puzzle-collections'; 8 + import type { PuzzleCollection } from '$lib/puzzle-collections'; 9 + 10 + let selectedCollectionId = $state('cho-1-elementary'); 11 + let currentPuzzleIndex = $state(0); 12 + let puzzles = $state<SgfPuzzle[]>([]); 13 + let isLoading = $state(false); 14 + let error = $state<string | null>(null); 15 + let boardComponent = $state<any>(null); 16 + let selectedCollection = $derived(PUZZLE_COLLECTIONS.find(c => c.id === selectedCollectionId)); 17 + let currentPuzzle = $derived(puzzles[currentPuzzleIndex]); 18 + 19 + // Load URL parameters on mount 20 + onMount(() => { 21 + const urlParams = new URLSearchParams(window.location.search); 22 + const collectionParam = urlParams.get('collection'); 23 + const problemParam = urlParams.get('problem'); 24 + 25 + if (collectionParam && PUZZLE_COLLECTIONS.some(c => c.id === collectionParam)) { 26 + selectedCollectionId = collectionParam; 27 + } 28 + 29 + if (problemParam) { 30 + const problemNum = parseInt(problemParam, 10); 31 + if (!isNaN(problemNum) && problemNum > 0) { 32 + // We'll set the index after loading the collection 33 + currentPuzzleIndex = problemNum - 1; 34 + } 35 + } 36 + }); 37 + 38 + // Load collection when selection changes 39 + $effect(() => { 40 + if (selectedCollectionId) { 41 + loadPuzzleCollection(); 42 + } 43 + }); 44 + 45 + // Update board when puzzle changes 46 + $effect(() => { 47 + if (currentPuzzle && boardComponent) { 48 + displayPuzzle(); 49 + } 50 + }); 51 + 52 + // Update URL when collection or puzzle changes 53 + $effect(() => { 54 + if (typeof window !== 'undefined' && selectedCollectionId) { 55 + const params = new URLSearchParams(); 56 + params.set('collection', selectedCollectionId); 57 + params.set('problem', String(currentPuzzleIndex + 1)); 58 + const newUrl = `/study?${params.toString()}`; 59 + window.history.replaceState({}, '', newUrl); 60 + } 61 + }); 62 + 63 + async function loadPuzzleCollection() { 64 + isLoading = true; 65 + error = null; 66 + 67 + try { 68 + puzzles = await loadCollection(selectedCollectionId); 69 + // Ensure current index is valid 70 + if (currentPuzzleIndex >= puzzles.length) { 71 + currentPuzzleIndex = 0; 72 + } 73 + } catch (err) { 74 + console.error('Failed to load puzzle collection:', err); 75 + error = 'Failed to load puzzle collection. Please try again.'; 76 + puzzles = []; 77 + } finally { 78 + isLoading = false; 79 + } 80 + } 81 + 82 + function displayPuzzle() { 83 + if (!currentPuzzle || !boardComponent) return; 84 + 85 + // Add black stones 86 + currentPuzzle.blackStones.forEach(stone => { 87 + boardComponent.addStone(stone.x, stone.y, 'black'); 88 + }); 89 + 90 + // Add white stones 91 + currentPuzzle.whiteStones.forEach(stone => { 92 + boardComponent.addStone(stone.x, stone.y, 'white'); 93 + }); 94 + } 95 + 96 + function selectCollection(collectionId: string) { 97 + selectedCollectionId = collectionId; 98 + currentPuzzleIndex = 0; 99 + } 100 + 101 + function previousPuzzle() { 102 + if (currentPuzzleIndex > 0) { 103 + currentPuzzleIndex--; 104 + } 105 + } 106 + 107 + function nextPuzzle() { 108 + if (currentPuzzleIndex < puzzles.length - 1) { 109 + currentPuzzleIndex++; 110 + } 111 + } 112 + 113 + function jumpToPuzzle() { 114 + const input = prompt(`Enter problem number (1-${puzzles.length}):`); 115 + if (input) { 116 + const num = parseInt(input, 10); 117 + if (!isNaN(num) && num >= 1 && num <= puzzles.length) { 118 + currentPuzzleIndex = num - 1; 119 + } else { 120 + alert(`Please enter a number between 1 and ${puzzles.length}`); 121 + } 122 + } 123 + } 124 + 125 + function getDifficultyColor(difficulty: string): string { 126 + switch (difficulty) { 127 + case 'Elementary': 128 + return 'success'; 129 + case 'Intermediate': 130 + return 'warning'; 131 + case 'Advanced': 132 + return 'danger'; 133 + case 'Classic': 134 + return 'info'; 135 + default: 136 + return 'default'; 137 + } 138 + } 139 + </script> 140 + 141 + <svelte:head> 142 + <title>Study - Cloud Go</title> 143 + </svelte:head> 144 + 145 + <div class="study-container"> 146 + <Card variant="large" class="study-card"> 147 + <div class="study-header"> 148 + <h1>Go Problem Study</h1> 149 + <p class="subtitle">Practice with classic Go problems from master collections</p> 150 + </div> 151 + 152 + {#if !isLoading && !error} 153 + <div class="collection-selector"> 154 + {#each PUZZLE_COLLECTIONS as collection} 155 + <button 156 + class="collection-button" 157 + class:active={selectedCollectionId === collection.id} 158 + onclick={() => selectCollection(collection.id)} 159 + > 160 + <div class="collection-name">{collection.name}</div> 161 + <Badge variant={getDifficultyColor(collection.difficulty)}> 162 + {collection.difficulty} 163 + </Badge> 164 + <div class="collection-count">{collection.estimatedCount} problems</div> 165 + </button> 166 + {/each} 167 + </div> 168 + {/if} 169 + 170 + {#if error} 171 + <div class="error-message"> 172 + <p>{error}</p> 173 + <Button onclick={() => loadPuzzleCollection()} variant="primary"> 174 + Try Again 175 + </Button> 176 + </div> 177 + {:else if isLoading} 178 + <div class="loading-message"> 179 + <p>Loading {selectedCollection?.name}...</p> 180 + <div class="loading-spinner"></div> 181 + </div> 182 + {:else if currentPuzzle} 183 + <div class="puzzle-display"> 184 + <div class="puzzle-info"> 185 + <h2> 186 + Problem {currentPuzzle.number} 187 + {#if selectedCollection} 188 + <Badge variant={getDifficultyColor(selectedCollection.difficulty)}> 189 + {selectedCollection.difficulty} 190 + </Badge> 191 + {/if} 192 + </h2> 193 + <p class="problem-counter"> 194 + Problem {currentPuzzleIndex + 1} of {puzzles.length} 195 + </p> 196 + </div> 197 + 198 + <div class="board-wrapper"> 199 + <Board 200 + bind:this={boardComponent} 201 + boardSize={currentPuzzle.boardSize} 202 + gameState={{ moves: [] }} 203 + interactive={false} 204 + /> 205 + </div> 206 + 207 + <div class="puzzle-controls"> 208 + <Button 209 + onclick={previousPuzzle} 210 + disabled={currentPuzzleIndex === 0} 211 + variant="secondary" 212 + > 213 + ← Previous 214 + </Button> 215 + 216 + <Button onclick={jumpToPuzzle} variant="secondary"> 217 + Jump to # 218 + </Button> 219 + 220 + <Button 221 + onclick={nextPuzzle} 222 + disabled={currentPuzzleIndex === puzzles.length - 1} 223 + variant="primary" 224 + > 225 + Next → 226 + </Button> 227 + </div> 228 + </div> 229 + {/if} 230 + 231 + <div class="credits-section"> 232 + <h3>Credits & Attribution</h3> 233 + <p> 234 + Puzzle data sourced from 235 + <a href="https://github.com/mango314/scrapeGo" target="_blank" rel="noopener noreferrer"> 236 + scrapeGo by mango314 237 + </a> 238 + </p> 239 + <p class="source-list"> 240 + <strong>Source Materials:</strong> 241 + </p> 242 + <ul> 243 + <li>Cho Chikun's Encyclopedia of Life & Death (Volumes 1-3)</li> 244 + <li>Gokyo Shumyo (Classic Go Problem Collection)</li> 245 + <li>Hatsuyoron (Classic Go Problem Collection)</li> 246 + </ul> 247 + <p class="attribution-note"> 248 + These classic Go problems are made available for educational purposes. 249 + Thank you to all who have preserved and shared these materials. 250 + </p> 251 + </div> 252 + </Card> 253 + </div> 254 + 255 + <style> 256 + .study-container { 257 + max-width: 1200px; 258 + margin: 0 auto; 259 + padding: 2rem; 260 + } 261 + 262 + .study-header { 263 + text-align: center; 264 + margin-bottom: 2rem; 265 + } 266 + 267 + .study-header h1 { 268 + font-size: 2.5rem; 269 + color: var(--sky-slate-dark); 270 + margin-bottom: 0.5rem; 271 + } 272 + 273 + .subtitle { 274 + color: var(--sky-gray); 275 + font-size: 1.125rem; 276 + } 277 + 278 + .collection-selector { 279 + display: grid; 280 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 281 + gap: 1rem; 282 + margin-bottom: 2rem; 283 + } 284 + 285 + .collection-button { 286 + background: var(--sky-white); 287 + border: 2px solid var(--sky-blue-pale); 288 + border-radius: 0.75rem; 289 + padding: 1.25rem; 290 + cursor: pointer; 291 + transition: all 0.3s ease; 292 + text-align: left; 293 + } 294 + 295 + .collection-button:hover { 296 + border-color: var(--sky-apricot); 297 + transform: translateY(-2px); 298 + box-shadow: 0 4px 12px rgba(90, 122, 144, 0.15); 299 + } 300 + 301 + .collection-button.active { 302 + border-color: var(--sky-apricot-dark); 303 + background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-white) 100%); 304 + box-shadow: 0 4px 12px rgba(229, 168, 120, 0.25); 305 + } 306 + 307 + .collection-name { 308 + font-size: 1.125rem; 309 + font-weight: 600; 310 + color: var(--sky-slate-dark); 311 + margin-bottom: 0.5rem; 312 + } 313 + 314 + .collection-count { 315 + font-size: 0.875rem; 316 + color: var(--sky-gray); 317 + margin-top: 0.5rem; 318 + } 319 + 320 + .puzzle-display { 321 + margin-bottom: 2rem; 322 + } 323 + 324 + .puzzle-info { 325 + text-align: center; 326 + margin-bottom: 1.5rem; 327 + } 328 + 329 + .puzzle-info h2 { 330 + font-size: 1.75rem; 331 + color: var(--sky-slate-dark); 332 + margin-bottom: 0.5rem; 333 + display: flex; 334 + align-items: center; 335 + justify-content: center; 336 + gap: 0.75rem; 337 + } 338 + 339 + .problem-counter { 340 + color: var(--sky-gray); 341 + font-size: 1rem; 342 + } 343 + 344 + .board-wrapper { 345 + display: flex; 346 + justify-content: center; 347 + margin: 2rem 0; 348 + } 349 + 350 + .puzzle-controls { 351 + display: flex; 352 + justify-content: center; 353 + gap: 1rem; 354 + flex-wrap: wrap; 355 + } 356 + 357 + .error-message, 358 + .loading-message { 359 + text-align: center; 360 + padding: 3rem 2rem; 361 + } 362 + 363 + .error-message p { 364 + color: var(--sky-danger); 365 + font-size: 1.125rem; 366 + margin-bottom: 1.5rem; 367 + } 368 + 369 + .loading-message p { 370 + color: var(--sky-gray); 371 + font-size: 1.125rem; 372 + margin-bottom: 1rem; 373 + } 374 + 375 + .loading-spinner { 376 + width: 40px; 377 + height: 40px; 378 + border: 4px solid var(--sky-blue-pale); 379 + border-top-color: var(--sky-apricot); 380 + border-radius: 50%; 381 + animation: spin 0.8s linear infinite; 382 + margin: 0 auto; 383 + } 384 + 385 + @keyframes spin { 386 + to { 387 + transform: rotate(360deg); 388 + } 389 + } 390 + 391 + .credits-section { 392 + margin-top: 3rem; 393 + padding-top: 2rem; 394 + border-top: 1px solid var(--sky-blue-pale); 395 + color: var(--sky-gray); 396 + } 397 + 398 + .credits-section h3 { 399 + color: var(--sky-slate-dark); 400 + font-size: 1.25rem; 401 + margin-bottom: 1rem; 402 + } 403 + 404 + .credits-section p { 405 + margin-bottom: 0.75rem; 406 + line-height: 1.6; 407 + } 408 + 409 + .credits-section a { 410 + color: var(--sky-apricot-dark); 411 + text-decoration: none; 412 + font-weight: 600; 413 + } 414 + 415 + .credits-section a:hover { 416 + text-decoration: underline; 417 + } 418 + 419 + .source-list { 420 + font-weight: 600; 421 + margin-top: 1.5rem; 422 + margin-bottom: 0.5rem; 423 + } 424 + 425 + .credits-section ul { 426 + margin-left: 1.5rem; 427 + margin-bottom: 1rem; 428 + } 429 + 430 + .credits-section li { 431 + margin-bottom: 0.5rem; 432 + } 433 + 434 + .attribution-note { 435 + font-style: italic; 436 + font-size: 0.9rem; 437 + margin-top: 1rem; 438 + } 439 + 440 + @media (max-width: 768px) { 441 + .study-container { 442 + padding: 1rem; 443 + } 444 + 445 + .study-header h1 { 446 + font-size: 2rem; 447 + } 448 + 449 + .collection-selector { 450 + grid-template-columns: 1fr; 451 + } 452 + 453 + .puzzle-controls { 454 + flex-direction: column; 455 + } 456 + 457 + .puzzle-controls :global(button) { 458 + width: 100%; 459 + } 460 + } 461 + </style>