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.

Revert to using database for game discovery and lookups

- Keep database as index for homepage game discovery
- Keep database for game page lookups (rkey -> full game data)
- Database remains necessary for efficient discovery of games user is not involved in
- ATProtocol remains source of truth, database is kept in sync

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

+2188 -259
+9 -1
lexicons/boo.sky.go.game.json
··· 20 20 }, 21 21 "boardSize": { 22 22 "type": "integer", 23 - "description": "Size of the board (9, 13, or 19)", 23 + "description": "Size of the board (5-19)", 24 24 "default": 19 25 25 }, 26 26 "status": { ··· 47 47 "whiteScorer": { 48 48 "type": "string", 49 49 "description": "DID of player who submitted white's score" 50 + }, 51 + "deadStones": { 52 + "type": "array", 53 + "description": "Array of dead stones in format: <b/w><A-T><01-19>", 54 + "items": { 55 + "type": "string", 56 + "pattern": "^[bw][A-T](0[1-9]|1[0-9]|2[0-5])$" 57 + } 50 58 }, 51 59 "createdAt": { 52 60 "type": "string",
+2 -2
lexicons/boo.sky.go.move.json
··· 26 26 "type": "integer", 27 27 "description": "X coordinate (0-based)", 28 28 "minimum": 0, 29 - "maximum": 18 29 + "maximum": 24 30 30 }, 31 31 "y": { 32 32 "type": "integer", 33 33 "description": "Y coordinate (0-based)", 34 34 "minimum": 0, 35 - "maximum": 18 35 + "maximum": 24 36 36 }, 37 37 "color": { 38 38 "type": "string",
+46
lexicons/boo.sky.go.profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "boo.sky.go.profile", 4 + "description": "A Cloud Go player profile with stats and status", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "wins": { 14 + "type": "integer", 15 + "description": "Total number of wins", 16 + "default": 0 17 + }, 18 + "losses": { 19 + "type": "integer", 20 + "description": "Total number of losses", 21 + "default": 0 22 + }, 23 + "ranking": { 24 + "type": "integer", 25 + "description": "ELO-based ranking score", 26 + "default": 1200 27 + }, 28 + "status": { 29 + "type": "string", 30 + "enum": ["playing", "watching", "offline"], 31 + "description": "Current player status", 32 + "default": "offline" 33 + }, 34 + "createdAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + }, 38 + "updatedAt": { 39 + "type": "string", 40 + "format": "datetime" 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+18 -101
src/lib/atproto-client.ts
··· 641 641 } 642 642 643 643 /** 644 - * Find a game by rkey from Constellation 644 + * Find a game by creator DID and rkey 645 645 */ 646 - export async function findGameByRkey(rkey: string): Promise<GameWithMetadata | null> { 647 - try { 648 - const params = new URLSearchParams({ 649 - collection: 'boo.sky.go.game', 650 - limit: '100', // Search through recent games 651 - }); 646 + export async function findGame(creatorDid: string, rkey: string): Promise<GameWithMetadata | null> { 647 + const gameRecord = await fetchGameRecord(creatorDid, rkey); 648 + if (!gameRecord) return null; 652 649 653 - const res = await fetch( 654 - `${CONSTELLATION_BASE}/blue.microcosm.records.listRecords?${params}`, 655 - { headers: { Accept: 'application/json' } } 656 - ); 650 + const gameUri = `at://${creatorDid}/boo.sky.go.game/${rkey}`; 651 + return await calculateGameMetadata(gameUri, gameRecord, creatorDid, rkey); 652 + } 657 653 658 - if (!res.ok) { 659 - console.error('Constellation listRecords failed:', res.status); 660 - return null; 661 - } 662 - 663 - const body = await res.json(); 664 - 665 - for (const rec of body.records || []) { 666 - if (rec.uri) { 667 - const uriParts = rec.uri.split('/'); 668 - const recRkey = uriParts[4]; // Extract rkey from at://did/collection/rkey 669 - 670 - if (recRkey === rkey) { 671 - const did = uriParts[2]; 672 - const gameRecord = rec.value as GameRecord; 673 - 674 - return await calculateGameMetadata(rec.uri, gameRecord, did, rkey); 675 - } 676 - } 677 - } 678 - } catch (err) { 679 - console.error('Failed to find game by rkey from Constellation:', err); 654 + /** 655 + * Find a game by rkey by searching through known players' PDSs 656 + * This requires either knowing potential creator DIDs or searching through all known players 657 + */ 658 + export async function findGameByRkey(rkey: string, potentialCreatorDids: string[] = []): Promise<GameWithMetadata | null> { 659 + // Try each potential creator until we find the game 660 + for (const did of potentialCreatorDids) { 661 + const game = await findGame(did, rkey); 662 + if (game) return game; 680 663 } 681 664 682 665 return null; ··· 725 708 } 726 709 727 710 /** 728 - * Fetch all game records from Constellation's record index 729 - */ 730 - export async function fetchAllGamesFromConstellation(): Promise<GameWithMetadata[]> { 731 - const games: Array<{ uri: string; rkey: string; value: GameRecord; creatorDid: string }> = []; 732 - let cursor: string | undefined; 733 - 734 - try { 735 - // Use Constellation's record listing API 736 - do { 737 - const params = new URLSearchParams({ 738 - collection: 'boo.sky.go.game', 739 - limit: '100', 740 - }); 741 - if (cursor) params.set('cursor', cursor); 742 - 743 - const res = await fetch( 744 - `${CONSTELLATION_BASE}/blue.microcosm.records.listRecords?${params}`, 745 - { headers: { Accept: 'application/json' } } 746 - ); 747 - 748 - if (!res.ok) { 749 - console.error('Constellation listRecords failed:', res.status); 750 - break; 751 - } 752 - 753 - const body = await res.json(); 754 - 755 - for (const rec of body.records || []) { 756 - if (rec.uri && rec.value) { 757 - const uriParts = rec.uri.split('/'); 758 - const did = uriParts[2]; // Extract DID from at://did/collection/rkey 759 - const rkey = uriParts[4]; // Extract rkey 760 - 761 - games.push({ 762 - uri: rec.uri, 763 - rkey, 764 - value: rec.value as GameRecord, 765 - creatorDid: did, 766 - }); 767 - } 768 - } 769 - 770 - cursor = body.cursor; 771 - } while (cursor); 772 - } catch (err) { 773 - console.error('Failed to fetch games from Constellation:', err); 774 - } 775 - 776 - // Calculate metadata for all games (in batches to avoid overwhelming the API) 777 - const BATCH_SIZE = 20; 778 - const gamesWithMetadata: GameWithMetadata[] = []; 779 - 780 - for (let i = 0; i < games.length; i += BATCH_SIZE) { 781 - const batch = games.slice(i, i + BATCH_SIZE); 782 - const batchResults = await Promise.all( 783 - batch.map(({ uri, value, creatorDid, rkey }) => 784 - calculateGameMetadata(uri, value, creatorDid, rkey) 785 - ) 786 - ); 787 - gamesWithMetadata.push(...batchResults); 788 - } 789 - 790 - return gamesWithMetadata; 791 - } 792 - 793 - /** 794 - * Fetch all games from Constellation by querying multiple players' games 795 - * Fallback method if Constellation's record listing is unavailable 711 + * Fetch all games by querying multiple known players' PDSs 712 + * This is the primary method since Constellation doesn't expose a simple listRecords API 796 713 */ 797 714 export async function fetchAllGames(knownPlayerDids: string[] = []): Promise<GameWithMetadata[]> { 798 715 const gameMap = new Map<string, { uri: string; rkey: string; value: GameRecord; creatorDid: string }>();
+315
src/lib/client-scoring.ts
··· 1 + import type { MoveRecord } from './types'; 2 + 3 + export interface ScoreResult { 4 + black: number; 5 + white: number; 6 + winner: 'black' | 'white' | 'tie'; 7 + } 8 + 9 + export interface TerritoryMap { 10 + territory: Array<Array<'black' | 'white' | 'neutral' | null>>; 11 + blackTerritory: number; 12 + whiteTerritory: number; 13 + } 14 + 15 + /** 16 + * Client-side scoring calculation that doesn't rely on tenuki to avoid hangs. 17 + * Replays moves to build board state, then calculates territory using flood fill. 18 + */ 19 + export function calculateScore( 20 + moves: MoveRecord[], 21 + boardSize: number = 19, 22 + komi: number = 6.5, 23 + deadStones: string[] = [] 24 + ): ScoreResult { 25 + console.log('[client-scoring] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); 26 + 27 + // Build board state by replaying moves with capture logic 28 + const boardState = buildBoardState(moves, boardSize); 29 + 30 + // Count stones on the board 31 + let blackStones = 0; 32 + let whiteStones = 0; 33 + 34 + for (let y = 0; y < boardSize; y++) { 35 + for (let x = 0; x < boardSize; x++) { 36 + if (boardState[y][x] === 'black') blackStones++; 37 + if (boardState[y][x] === 'white') whiteStones++; 38 + } 39 + } 40 + 41 + console.log('[client-scoring] Before dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 42 + 43 + // Process dead stones - remove them from the board and count as captures for opponent 44 + let blackCaptures = 0; 45 + let whiteCaptures = 0; 46 + 47 + if (deadStones.length > 0) { 48 + console.log('[client-scoring] Processing', deadStones.length, 'dead stones...'); 49 + for (const notation of deadStones) { 50 + // Parse notation like "bA01" or "wT19" 51 + const color = notation[0] === 'b' ? 'black' : 'white'; 52 + const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 53 + const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 54 + 55 + if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { 56 + if (boardState[row][col] === color) { 57 + // Remove the stone from the board 58 + boardState[row][col] = null; 59 + 60 + // Count as capture for opponent 61 + if (color === 'black') { 62 + blackStones--; 63 + whiteCaptures++; 64 + } else { 65 + whiteStones--; 66 + blackCaptures++; 67 + } 68 + 69 + console.log(`[client-scoring] Removed dead ${color} stone at (${col}, ${row})`); 70 + } 71 + } 72 + } 73 + console.log('[client-scoring] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 74 + console.log('[client-scoring] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); 75 + } 76 + 77 + // Calculate territory using flood fill 78 + const territoryMap = calculateTerritory(boardState, boardSize); 79 + 80 + console.log('[client-scoring] Territory calculated. Black:', territoryMap.blackTerritory, 'White:', territoryMap.whiteTerritory); 81 + 82 + // Calculate final scores (territory + stones + captures) 83 + const blackScore = blackStones + territoryMap.blackTerritory + blackCaptures; 84 + const whiteScore = whiteStones + territoryMap.whiteTerritory + whiteCaptures + komi; 85 + 86 + console.log('[client-scoring] Final scores. Black:', blackScore, 'White:', whiteScore); 87 + 88 + // Determine winner 89 + let winner: 'black' | 'white' | 'tie'; 90 + if (blackScore > whiteScore) { 91 + winner = 'black'; 92 + } else if (whiteScore > blackScore) { 93 + winner = 'white'; 94 + } else { 95 + winner = 'tie'; 96 + } 97 + 98 + console.log('[client-scoring] Winner determined:', winner); 99 + 100 + return { 101 + black: blackScore, 102 + white: whiteScore, 103 + winner, 104 + }; 105 + } 106 + 107 + /** 108 + * Build board state by replaying moves with basic capture logic. 109 + * This is a simplified implementation that handles captures but not complex ko situations. 110 + */ 111 + export function buildBoardState( 112 + moves: MoveRecord[], 113 + boardSize: number 114 + ): Array<Array<'black' | 'white' | null>> { 115 + const board: Array<Array<'black' | 'white' | null>> = Array.from( 116 + { length: boardSize }, 117 + () => Array.from({ length: boardSize }, () => null) 118 + ); 119 + 120 + for (const move of moves) { 121 + const { x, y, color } = move; 122 + 123 + if (x < 0 || x >= boardSize || y < 0 || y >= boardSize) continue; 124 + if (board[y][x] !== null) continue; // Square already occupied 125 + 126 + // Place the stone 127 + board[y][x] = color; 128 + 129 + // Check for captures of opponent stones 130 + const opponent = color === 'black' ? 'white' : 'black'; 131 + const neighbors = getNeighbors(x, y, boardSize); 132 + 133 + for (const [nx, ny] of neighbors) { 134 + if (board[ny][nx] === opponent) { 135 + // Check if this opponent group has no liberties 136 + if (getLiberties(board, nx, ny, boardSize).length === 0) { 137 + // Capture the group 138 + removeGroup(board, nx, ny, boardSize); 139 + } 140 + } 141 + } 142 + } 143 + 144 + return board; 145 + } 146 + 147 + /** 148 + * Get neighboring coordinates 149 + */ 150 + function getNeighbors(x: number, y: number, boardSize: number): Array<[number, number]> { 151 + const neighbors: Array<[number, number]> = []; 152 + if (x > 0) neighbors.push([x - 1, y]); 153 + if (x < boardSize - 1) neighbors.push([x + 1, y]); 154 + if (y > 0) neighbors.push([x, y - 1]); 155 + if (y < boardSize - 1) neighbors.push([x, y + 1]); 156 + return neighbors; 157 + } 158 + 159 + /** 160 + * Get all liberties (empty neighbors) of a group 161 + */ 162 + function getLiberties( 163 + board: Array<Array<'black' | 'white' | null>>, 164 + x: number, 165 + y: number, 166 + boardSize: number 167 + ): Array<[number, number]> { 168 + const color = board[y][x]; 169 + if (color === null) return []; 170 + 171 + const liberties = new Set<string>(); 172 + const visited = new Set<string>(); 173 + const queue: Array<[number, number]> = [[x, y]]; 174 + 175 + while (queue.length > 0) { 176 + const [cx, cy] = queue.shift()!; 177 + const key = `${cx},${cy}`; 178 + 179 + if (visited.has(key)) continue; 180 + visited.add(key); 181 + 182 + for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { 183 + const nkey = `${nx},${ny}`; 184 + if (board[ny][nx] === null) { 185 + liberties.add(nkey); 186 + } else if (board[ny][nx] === color && !visited.has(nkey)) { 187 + queue.push([nx, ny]); 188 + } 189 + } 190 + } 191 + 192 + return Array.from(liberties).map(key => { 193 + const [x, y] = key.split(',').map(Number); 194 + return [x, y] as [number, number]; 195 + }); 196 + } 197 + 198 + /** 199 + * Remove a captured group from the board 200 + */ 201 + function removeGroup( 202 + board: Array<Array<'black' | 'white' | null>>, 203 + x: number, 204 + y: number, 205 + boardSize: number 206 + ): void { 207 + const color = board[y][x]; 208 + if (color === null) return; 209 + 210 + const visited = new Set<string>(); 211 + const queue: Array<[number, number]> = [[x, y]]; 212 + 213 + while (queue.length > 0) { 214 + const [cx, cy] = queue.shift()!; 215 + const key = `${cx},${cy}`; 216 + 217 + if (visited.has(key)) continue; 218 + visited.add(key); 219 + 220 + board[cy][cx] = null; 221 + 222 + for (const [nx, ny] of getNeighbors(cx, cy, boardSize)) { 223 + const nkey = `${nx},${ny}`; 224 + if (board[ny][nx] === color && !visited.has(nkey)) { 225 + queue.push([nx, ny]); 226 + } 227 + } 228 + } 229 + } 230 + 231 + /** 232 + * Calculate territory for each empty intersection using flood fill. 233 + * An empty region belongs to a player if it's completely surrounded by their stones. 234 + */ 235 + export function calculateTerritory( 236 + boardState: Array<Array<'black' | 'white' | null>>, 237 + boardSize: number 238 + ): TerritoryMap { 239 + const territory: Array<Array<'black' | 'white' | 'neutral' | null>> = Array.from( 240 + { length: boardSize }, 241 + () => Array.from({ length: boardSize }, () => null) 242 + ); 243 + 244 + const visited: Array<Array<boolean>> = Array.from( 245 + { length: boardSize }, 246 + () => Array.from({ length: boardSize }, () => false) 247 + ); 248 + 249 + let blackTerritory = 0; 250 + let whiteTerritory = 0; 251 + 252 + // Flood fill to find connected empty regions 253 + function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { 254 + const points: Array<[number, number]> = []; 255 + const stack: Array<[number, number]> = [[startY, startX]]; 256 + let touchesBlack = false; 257 + let touchesWhite = false; 258 + 259 + while (stack.length > 0) { 260 + const [y, x] = stack.pop()!; 261 + 262 + if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; 263 + if (visited[y][x]) continue; 264 + 265 + const cell = boardState[y][x]; 266 + 267 + if (cell === 'black') { 268 + touchesBlack = true; 269 + continue; 270 + } 271 + if (cell === 'white') { 272 + touchesWhite = true; 273 + continue; 274 + } 275 + 276 + // Empty intersection 277 + visited[y][x] = true; 278 + points.push([y, x]); 279 + 280 + // Add neighbors 281 + stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); 282 + } 283 + 284 + let owner: 'black' | 'white' | 'neutral'; 285 + if (touchesBlack && !touchesWhite) { 286 + owner = 'black'; 287 + } else if (touchesWhite && !touchesBlack) { 288 + owner = 'white'; 289 + } else { 290 + owner = 'neutral'; 291 + } 292 + 293 + return { points, owner }; 294 + } 295 + 296 + // Find all empty regions 297 + for (let y = 0; y < boardSize; y++) { 298 + for (let x = 0; x < boardSize; x++) { 299 + if (!visited[y][x] && boardState[y][x] === null) { 300 + const { points, owner } = floodFill(y, x); 301 + for (const [py, px] of points) { 302 + territory[py][px] = owner; 303 + if (owner === 'black') blackTerritory++; 304 + else if (owner === 'white') whiteTerritory++; 305 + } 306 + } 307 + } 308 + } 309 + 310 + return { 311 + territory, 312 + blackTerritory, 313 + whiteTerritory, 314 + }; 315 + }
+222 -17
src/lib/components/Board.svelte
··· 1 1 <script lang="ts"> 2 2 import { untrack } from 'svelte'; 3 3 import JGO from 'jgoboard'; 4 + import { isMobileDevice } from '$lib/mobile-detection'; 4 5 5 6 interface TerritoryData { 6 7 territory: Array<Array<'black' | 'white' | 'neutral' | null>>; ··· 17 18 currentTurn?: 'black' | 'white'; 18 19 territoryData?: TerritoryData | null; 19 20 libertyData?: Array<Array<number>> | null; 21 + deadStones?: string[]; 22 + markingDeadStones?: boolean; 23 + onToggleDeadStone?: (x: number, y: number, color: 'black' | 'white') => void; 20 24 } 21 25 22 26 let { ··· 27 31 interactive = true, 28 32 currentTurn = 'black', 29 33 territoryData = null, 30 - libertyData = null 34 + libertyData = null, 35 + deadStones = [], 36 + markingDeadStones = false, 37 + onToggleDeadStone = () => {} 31 38 }: Props = $props(); 32 39 33 40 let boardElement: HTMLDivElement; ··· 39 46 let lastY = $state(-1); 40 47 let ko: any = $state(false); 41 48 let lastMarkedCoord: any = $state(null); 49 + let pendingMove = $state<{ x: number, y: number, captures: number } | null>(null); 50 + let showMobileConfirmation = $state(false); 51 + let isMobile = $state(false); 42 52 43 53 // Responsive grid size - calculate based on viewport and board size 44 54 const calculateGridSize = () => { ··· 127 137 } 128 138 129 139 // Initialize board once 140 + // Initialize mobile detection 141 + $effect(() => { 142 + if (typeof window !== 'undefined') { 143 + isMobile = isMobileDevice(); 144 + } 145 + }); 146 + 130 147 $effect(() => { 131 148 if (!boardElement) return; 132 149 ··· 312 329 }); 313 330 314 331 function handleCanvasClick(coord: any) { 332 + if (!board || !isReady) return; 333 + 334 + // coord.i and coord.j are -1 if outside board 335 + if (coord.i < 0 || coord.j < 0) return; 336 + 337 + // If in dead stone marking mode, toggle the stone 338 + if (markingDeadStones) { 339 + const type = board.getType(coord); 340 + // Only allow marking actual stones (not empty intersections) 341 + if (type === JGO.BLACK || type === JGO.WHITE) { 342 + const color = type === JGO.BLACK ? 'black' : 'white'; 343 + onToggleDeadStone(coord.i, coord.j, color); 344 + } 345 + return; 346 + } 347 + 315 348 // Always check the latest interactive prop value 316 349 if (!interactive) { 317 350 console.log('Click ignored - board not interactive. Interactive:', interactive, 'isReady:', isReady); 318 351 return; 319 352 } 320 - 321 - if (!board || !isReady) return; 322 - 323 - // coord.i and coord.j are -1 if outside board 324 - if (coord.i < 0 || coord.j < 0) return; 325 353 326 354 // Clear hover preview before attempting move 327 355 if (lastHover) { ··· 336 364 const play = board.playMove(coord, player, ko); 337 365 338 366 if (play.success) { 339 - // Place the stone 340 - board.setType(coord, player); 367 + // On mobile, show confirmation instead of immediate submission 368 + if (isMobile) { 369 + pendingMove = { 370 + x: coord.i, 371 + y: coord.j, 372 + captures: play.captures.length 373 + }; 374 + showMobileConfirmation = true; 375 + } else { 376 + // Desktop: immediate submission 377 + // Place the stone 378 + board.setType(coord, player); 341 379 342 - // Remove captured stones 343 - if (play.captures.length > 0) { 344 - for (const capture of play.captures) { 345 - board.setType(capture, JGO.CLEAR); 380 + // Remove captured stones 381 + if (play.captures.length > 0) { 382 + for (const capture of play.captures) { 383 + board.setType(capture, JGO.CLEAR); 384 + } 346 385 } 347 - } 348 386 349 - // Update ko point 350 - ko = play.ko; 387 + // Update ko point 388 + ko = play.ko; 351 389 352 - // Call the onMove callback with capture count 353 - onMove(coord.i, coord.j, play.captures.length); 390 + // Call the onMove callback with capture count 391 + onMove(coord.i, coord.j, play.captures.length); 392 + } 354 393 } else { 355 394 // Invalid move - show error or just ignore 356 395 console.log('Invalid move:', play.errorMsg); 357 396 } 397 + } 398 + 399 + function confirmMove() { 400 + if (!pendingMove || !board) return; 401 + 402 + const coord = new JGO.Coordinate(pendingMove.x, pendingMove.y); 403 + const player = activeTurn() === 'black' ? JGO.BLACK : JGO.WHITE; 404 + const play = board.playMove(coord, player, ko); 405 + 406 + if (play.success) { 407 + board.setType(coord, player); 408 + if (play.captures.length > 0) { 409 + for (const capture of play.captures) { 410 + board.setType(capture, JGO.CLEAR); 411 + } 412 + } 413 + ko = play.ko; 414 + onMove(pendingMove.x, pendingMove.y, pendingMove.captures); 415 + } 416 + 417 + pendingMove = null; 418 + showMobileConfirmation = false; 419 + } 420 + 421 + function cancelMove() { 422 + pendingMove = null; 423 + showMobileConfirmation = false; 358 424 } 359 425 360 426 function handleMouseMove(coord: any) { ··· 586 652 {/each} 587 653 </div> 588 654 {/if} 655 + {#if deadStones.length > 0 && board} 656 + {@const padding = gridSize * 0.8} 657 + {@const margin = gridSize * 0.8} 658 + {@const stoneRadius = gridSize * 0.38} 659 + {@const boardOffset = padding + margin + stoneRadius - 1} 660 + <div class="dead-stone-overlay"> 661 + {#each deadStones as notation} 662 + {@const col = notation.charCodeAt(1) - 65} 663 + {@const row = parseInt(notation.slice(2)) - 1} 664 + {#if row >= 0 && row < boardSize && col >= 0 && col < boardSize} 665 + {@const coord = new JGO.Coordinate(col, row)} 666 + {@const stoneType = board.getType(coord)} 667 + {#if stoneType === JGO.BLACK || stoneType === JGO.WHITE} 668 + {@const xSize = gridSize * 0.6} 669 + <div 670 + class="dead-stone-mark" 671 + style=" 672 + left: {boardOffset + col * gridSize}px; 673 + top: {boardOffset + row * gridSize}px; 674 + width: {xSize}px; 675 + height: {xSize}px; 676 + font-size: {xSize * 0.8}px; 677 + " 678 + > 679 + 680 + </div> 681 + {/if} 682 + {/if} 683 + {/each} 684 + </div> 685 + {/if} 589 686 </div> 590 687 </div> 591 688 ··· 596 693 </button> 597 694 <div class="turn-indicator"> 598 695 Current turn: <span class="turn-{activeTurn()}">{activeTurn()}</span> 696 + </div> 697 + </div> 698 + {/if} 699 + 700 + {#if showMobileConfirmation && pendingMove} 701 + <div class="mobile-confirmation-overlay"> 702 + <div class="mobile-confirmation-dialog"> 703 + <p class="confirmation-message"> 704 + Place {activeTurn()} stone at ({pendingMove.x}, {pendingMove.y})? 705 + {#if pendingMove.captures > 0} 706 + <span class="capture-info"> 707 + Will capture {pendingMove.captures} stone{pendingMove.captures > 1 ? 's' : ''} 708 + </span> 709 + {/if} 710 + </p> 711 + <div class="confirmation-buttons"> 712 + <button class="confirm-button" onclick={confirmMove}>Confirm Move</button> 713 + <button class="cancel-button" onclick={cancelMove}>Cancel</button> 714 + </div> 599 715 </div> 600 716 </div> 601 717 {/if} ··· 751 867 50% { 752 868 transform: translate(-50%, -50%) scale(1.1); 753 869 } 870 + } 871 + 872 + .dead-stone-overlay { 873 + position: absolute; 874 + top: 0; 875 + left: 0; 876 + right: 0; 877 + bottom: 0; 878 + pointer-events: none; 879 + } 880 + 881 + .dead-stone-mark { 882 + position: absolute; 883 + transform: translate(-50%, -50%); 884 + color: #ff0000; 885 + font-weight: 900; 886 + text-align: center; 887 + line-height: 1; 888 + pointer-events: none; 889 + display: flex; 890 + align-items: center; 891 + justify-content: center; 892 + text-shadow: 893 + 0 0 4px rgba(255, 255, 255, 1), 894 + 0 0 8px rgba(255, 255, 255, 0.8), 895 + 2px 2px 4px rgba(0, 0, 0, 0.9); 896 + opacity: 0.95; 897 + } 898 + 899 + .mobile-confirmation-overlay { 900 + position: fixed; 901 + top: 0; 902 + left: 0; 903 + right: 0; 904 + bottom: 0; 905 + background: rgba(0, 0, 0, 0.5); 906 + display: flex; 907 + align-items: center; 908 + justify-content: center; 909 + z-index: 1000; 910 + backdrop-filter: blur(2px); 911 + } 912 + 913 + .mobile-confirmation-dialog { 914 + background: white; 915 + border-radius: 1rem; 916 + padding: 1.5rem; 917 + max-width: 90%; 918 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); 919 + } 920 + 921 + .confirmation-message { 922 + font-size: 1rem; 923 + color: var(--sky-slate-dark); 924 + margin: 0 0 1rem 0; 925 + text-align: center; 926 + } 927 + 928 + .capture-info { 929 + display: block; 930 + color: var(--sky-apricot-dark); 931 + font-weight: 600; 932 + margin-top: 0.5rem; 933 + } 934 + 935 + .confirmation-buttons { 936 + display: flex; 937 + gap: 0.75rem; 938 + } 939 + 940 + .confirm-button { 941 + flex: 1; 942 + padding: 0.875rem 1.5rem; 943 + background: linear-gradient(135deg, var(--sky-apricot-dark) 0%, var(--sky-apricot) 100%); 944 + color: white; 945 + border: none; 946 + border-radius: 0.5rem; 947 + font-weight: 600; 948 + cursor: pointer; 949 + } 950 + 951 + .cancel-button { 952 + flex: 1; 953 + padding: 0.875rem 1.5rem; 954 + background: var(--sky-cloud); 955 + color: var(--sky-slate); 956 + border: 1px solid var(--sky-blue-pale); 957 + border-radius: 0.5rem; 958 + cursor: pointer; 754 959 } 755 960 </style>
+10
src/lib/mobile-detection.ts
··· 1 + export function isMobileDevice(): boolean { 2 + if (typeof window === 'undefined') return false; 3 + 4 + const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 5 + const isMobileWidth = window.innerWidth < 768; 6 + const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; 7 + const isMobileUA = mobileRegex.test(navigator.userAgent); 8 + 9 + return (hasTouch && isMobileWidth) || isMobileUA; 10 + }
+1 -1
src/lib/server/auth.ts
··· 51 51 client_name: "Cloud Go", 52 52 redirect_uris: [new URL("/auth/callback", publicUrl).href], 53 53 scope: 54 - "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 54 + "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create repo:boo.sky.go.profile?action=create repo:boo.sky.go.profile?action=update", 55 55 jwks_uri: new URL("/jwks.json", publicUrl).href, 56 56 }, 57 57
+38 -1
src/lib/server/board-svg.ts
··· 26 26 boardState: Array<Array<'black' | 'white' | null>>, 27 27 boardSize: number 28 28 ): TerritoryMap { 29 + // Validate boardSize to prevent infinite loop 30 + if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 31 + console.error('Invalid boardSize:', boardSize); 32 + return { 33 + territory: [], 34 + blackTerritory: 0, 35 + whiteTerritory: 0, 36 + blackCaptures: 0, 37 + whiteCaptures: 0, 38 + }; 39 + } 40 + 29 41 const territory: Array<Array<'black' | 'white' | 'neutral' | null>> = Array.from( 30 42 { length: boardSize }, 31 43 () => Array.from({ length: boardSize }, () => null) ··· 240 252 */ 241 253 export async function buildBoardStateFromMoves( 242 254 moves: Array<{ x: number; y: number; color: 'black' | 'white' }>, 243 - boardSize: number 255 + boardSize: number, 256 + deadStones: string[] = [] 244 257 ): Promise<{ boardState: Array<Array<'black' | 'white' | null>>; lastMove: { x: number; y: number } | null }> { 258 + // Validate boardSize 259 + if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 260 + console.error('Invalid boardSize:', boardSize); 261 + return { 262 + boardState: [], 263 + lastMove: null, 264 + }; 265 + } 266 + 245 267 const tenuki = await import('tenuki'); 246 268 const game = new tenuki.default.Game({ boardSize }); 247 269 ··· 267 289 const intersection = game.intersectionAt(y, x); 268 290 if (intersection.value === 'black' || intersection.value === 'white') { 269 291 boardState[y][x] = intersection.value; 292 + } 293 + } 294 + } 295 + 296 + // Remove dead stones from the board state 297 + if (deadStones.length > 0) { 298 + for (const notation of deadStones) { 299 + const color = notation[0] === 'b' ? 'black' : 'white'; 300 + const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 301 + const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 302 + 303 + if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { 304 + if (boardState[row][col] === color) { 305 + boardState[row][col] = null; // Remove the dead stone 306 + } 270 307 } 271 308 } 272 309 }
+40
src/lib/server/elo.ts
··· 1 + /** 2 + * ELO rating system utilities for Cloud Go 3 + */ 4 + 5 + const DEFAULT_RATING = 1200; 6 + const K_FACTOR = 32; // Standard K-factor for chess-like games 7 + 8 + /** 9 + * Calculate expected score for a player 10 + * @param playerRating - Player's current rating 11 + * @param opponentRating - Opponent's current rating 12 + * @returns Expected score (0-1) 13 + */ 14 + function calculateExpectedScore(playerRating: number, opponentRating: number): number { 15 + return 1 / (1 + Math.pow(10, (opponentRating - playerRating) / 400)); 16 + } 17 + 18 + /** 19 + * Calculate new ELO rating after a game 20 + * @param currentRating - Player's current rating 21 + * @param opponentRating - Opponent's rating 22 + * @param actualScore - Actual game result (1 = win, 0.5 = draw, 0 = loss) 23 + * @returns New rating 24 + */ 25 + export function calculateNewRating( 26 + currentRating: number, 27 + opponentRating: number, 28 + actualScore: number 29 + ): number { 30 + const expectedScore = calculateExpectedScore(currentRating, opponentRating); 31 + const ratingChange = K_FACTOR * (actualScore - expectedScore); 32 + return Math.round(currentRating + ratingChange); 33 + } 34 + 35 + /** 36 + * Get the default rating for players without a profile 37 + */ 38 + export function getDefaultRating(): number { 39 + return DEFAULT_RATING; 40 + }
+169 -15
src/lib/server/scoring.ts
··· 19 19 export function calculateScore( 20 20 moves: MoveRecord[], 21 21 boardSize: number = 19, 22 - komi: number = 6.5 22 + komi: number = 6.5, 23 + deadStones: string[] = [] 23 24 ): ScoreResult { 25 + console.log('[calculateScore] Starting with', moves.length, 'moves, boardSize:', boardSize, 'komi:', komi, 'deadStones:', deadStones.length); 26 + 27 + // Validate boardSize 28 + if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 29 + console.error('[calculateScore] Invalid boardSize:', boardSize); 30 + return { 31 + black: 0, 32 + white: 0, 33 + winner: 'tie', 34 + }; 35 + } 36 + 24 37 // Create a new tenuki game instance (no DOM element needed for scoring) 38 + console.log('[calculateScore] Creating tenuki Game instance...'); 25 39 const game = new Game({ 26 40 boardSize, 27 41 komi, 28 42 scoring: 'territory', 29 43 }); 44 + console.log('[calculateScore] Game instance created'); 30 45 31 46 // Replay all moves 32 - for (const move of moves) { 47 + console.log('[calculateScore] Replaying moves...'); 48 + for (let i = 0; i < moves.length; i++) { 49 + const move = moves[i]; 33 50 // tenuki uses (y, x) coordinates, not (x, y) 34 51 const success = game.playAt(move.y, move.x); 35 52 if (!success) { 36 - console.warn(`Failed to replay move at (${move.x}, ${move.y})`); 53 + console.warn(`[calculateScore] Failed to replay move ${i + 1} at (${move.x}, ${move.y})`); 37 54 } 38 55 } 56 + console.log('[calculateScore] All moves replayed'); 39 57 40 - // Pass twice to end the game so we can score 41 - // (the game should already be in "game over" state after double-pass, 42 - // but if we're scoring mid-game for preview, we need this) 43 - if (!game.isOver()) { 44 - game.pass(); 45 - game.pass(); 58 + // Extract current board state to calculate territory manually 59 + console.log('[calculateScore] Extracting board state for manual scoring...'); 60 + const boardState: Array<Array<'empty' | 'black' | 'white'>> = []; 61 + let blackStones = 0; 62 + let whiteStones = 0; 63 + 64 + for (let y = 0; y < boardSize; y++) { 65 + const row: Array<'empty' | 'black' | 'white'> = []; 66 + for (let x = 0; x < boardSize; x++) { 67 + const intersection = game.intersectionAt(y, x); 68 + const value = intersection.value as 'empty' | 'black' | 'white'; 69 + row.push(value); 70 + if (value === 'black') blackStones++; 71 + if (value === 'white') whiteStones++; 72 + } 73 + boardState.push(row); 46 74 } 47 75 48 - // Get the score from tenuki 49 - const score = game.score(); 76 + console.log('[calculateScore] Board state extracted. Black stones:', blackStones, 'White stones:', whiteStones); 77 + 78 + // Process dead stones - remove them from the board and count as captures for opponent 79 + let blackCaptures = 0; 80 + let whiteCaptures = 0; 81 + 82 + if (deadStones.length > 0) { 83 + console.log('[calculateScore] Processing', deadStones.length, 'dead stones...'); 84 + for (const notation of deadStones) { 85 + // Parse notation like "bA01" or "wT19" 86 + const color = notation[0] === 'b' ? 'black' : 'white'; 87 + const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 88 + const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 89 + 90 + if (row >= 0 && row < boardSize && col >= 0 && col < boardSize) { 91 + if (boardState[row][col] === color) { 92 + // Remove the stone from the board 93 + boardState[row][col] = 'empty'; 94 + 95 + // Count as capture for opponent 96 + if (color === 'black') { 97 + blackStones--; 98 + whiteCaptures++; 99 + } else { 100 + whiteStones--; 101 + blackCaptures++; 102 + } 103 + 104 + console.log(`[calculateScore] Removed dead ${color} stone at (${col}, ${row})`); 105 + } else { 106 + console.warn(`[calculateScore] Dead stone notation ${notation} doesn't match board state at (${col}, ${row})`); 107 + } 108 + } else { 109 + console.warn(`[calculateScore] Invalid dead stone notation: ${notation}`); 110 + } 111 + } 112 + console.log('[calculateScore] After removing dead stones - Black stones:', blackStones, 'White stones:', whiteStones); 113 + console.log('[calculateScore] Captures - Black captured:', blackCaptures, 'White captured:', whiteCaptures); 114 + } 115 + 116 + // Calculate territory using flood fill (same algorithm as calculateTerritory in board-svg.ts) 117 + console.log('[calculateScore] Calculating territory...'); 118 + const visited: Array<Array<boolean>> = Array.from( 119 + { length: boardSize }, 120 + () => Array.from({ length: boardSize }, () => false) 121 + ); 122 + 123 + let blackTerritory = 0; 124 + let whiteTerritory = 0; 125 + 126 + function floodFill(startY: number, startX: number): { points: Array<[number, number]>; owner: 'black' | 'white' | 'neutral' } { 127 + const points: Array<[number, number]> = []; 128 + const stack: Array<[number, number]> = [[startY, startX]]; 129 + let touchesBlack = false; 130 + let touchesWhite = false; 131 + 132 + while (stack.length > 0) { 133 + const [y, x] = stack.pop()!; 134 + 135 + if (y < 0 || y >= boardSize || x < 0 || x >= boardSize) continue; 136 + if (visited[y][x]) continue; 137 + 138 + const cell = boardState[y][x]; 139 + 140 + if (cell === 'black') { 141 + touchesBlack = true; 142 + continue; 143 + } 144 + if (cell === 'white') { 145 + touchesWhite = true; 146 + continue; 147 + } 148 + 149 + // Empty intersection 150 + visited[y][x] = true; 151 + points.push([y, x]); 152 + 153 + // Add neighbors 154 + stack.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]); 155 + } 156 + 157 + let owner: 'black' | 'white' | 'neutral'; 158 + if (touchesBlack && !touchesWhite) { 159 + owner = 'black'; 160 + } else if (touchesWhite && !touchesBlack) { 161 + owner = 'white'; 162 + } else { 163 + owner = 'neutral'; 164 + } 165 + 166 + return { points, owner }; 167 + } 168 + 169 + // Find all empty regions 170 + for (let y = 0; y < boardSize; y++) { 171 + for (let x = 0; x < boardSize; x++) { 172 + if (!visited[y][x] && boardState[y][x] === 'empty') { 173 + const { points, owner } = floodFill(y, x); 174 + if (owner === 'black') { 175 + blackTerritory += points.length; 176 + } else if (owner === 'white') { 177 + whiteTerritory += points.length; 178 + } 179 + } 180 + } 181 + } 182 + 183 + console.log('[calculateScore] Territory calculated. Black:', blackTerritory, 'White:', whiteTerritory); 184 + 185 + // Calculate final scores (territory + stones + captures) 186 + const blackScore = blackStones + blackTerritory + blackCaptures; 187 + const whiteScore = whiteStones + whiteTerritory + whiteCaptures + komi; 188 + 189 + console.log('[calculateScore] Final scores. Black:', blackScore, 'White:', whiteScore); 50 190 51 191 // Determine winner 52 192 let winner: 'black' | 'white' | 'tie'; 53 - if (score.black > score.white) { 193 + if (blackScore > whiteScore) { 54 194 winner = 'black'; 55 - } else if (score.white > score.black) { 195 + } else if (whiteScore > blackScore) { 56 196 winner = 'white'; 57 197 } else { 58 198 winner = 'tie'; 59 199 } 60 200 201 + console.log('[calculateScore] Winner determined:', winner); 202 + 61 203 return { 62 - black: score.black, 63 - white: score.white, 204 + black: blackScore, 205 + white: whiteScore, 64 206 winner, 65 207 }; 66 208 } ··· 72 214 moves: MoveRecord[], 73 215 boardSize: number = 19 74 216 ): Array<Array<'empty' | 'black' | 'white'>> { 217 + // Validate boardSize 218 + if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 219 + console.error('Invalid boardSize:', boardSize); 220 + return []; 221 + } 222 + 75 223 const game = new Game({ 76 224 boardSize, 77 225 }); ··· 103 251 moves: MoveRecord[], 104 252 boardSize: number = 19 105 253 ): Array<Array<number>> { 254 + // Validate boardSize 255 + if (!boardSize || typeof boardSize !== 'number' || boardSize < 1 || boardSize > 25) { 256 + console.error('Invalid boardSize:', boardSize); 257 + return []; 258 + } 259 + 106 260 const game = new Game({ 107 261 boardSize, 108 262 });
+22
src/lib/time-utils.ts
··· 1 + export function formatElapsedTime(timestamp: string): string { 2 + const now = Date.now(); 3 + const then = new Date(timestamp).getTime(); 4 + const diffMs = now - then; 5 + 6 + const minutes = Math.floor(diffMs / 60000); 7 + const hours = Math.floor(minutes / 60); 8 + const days = Math.floor(hours / 24); 9 + 10 + if (minutes < 1) return 'just now'; 11 + if (minutes < 60) return `${minutes}m ago`; 12 + if (hours < 24) return `${hours}h ago`; 13 + if (days === 1) return '1 day ago'; 14 + return `${days} days ago`; 15 + } 16 + 17 + export function isStale(timestamp: string, thresholdDays: number = 3): boolean { 18 + const now = Date.now(); 19 + const then = new Date(timestamp).getTime(); 20 + const diffDays = (now - then) / (1000 * 60 * 60 * 24); 21 + return diffDays > thresholdDays; 22 + }
+26
src/lib/types.ts
··· 11 11 whiteScore?: number; 12 12 blackScorer?: string; 13 13 whiteScorer?: string; 14 + deadStones?: string[]; // Format: ["bA01", "wT19"] 14 15 createdAt: string; 15 16 } 16 17 ··· 53 54 stars?: number; // 1-5 54 55 createdAt: string; 55 56 } 57 + 58 + export interface ProfileRecord { 59 + $type: 'boo.sky.go.profile'; 60 + wins: number; 61 + losses: number; 62 + ranking: number; 63 + status: 'playing' | 'watching' | 'offline'; 64 + createdAt: string; 65 + updatedAt?: string; 66 + } 67 + 68 + // Convert (x, y, color) to board notation with zero-padded numbers 69 + export function coordsToNotation(x: number, y: number, color: 'black' | 'white'): string { 70 + const col = String.fromCharCode(65 + x); // 0=A, 1=B, etc. 71 + const row = (y + 1).toString().padStart(2, '0'); // 0-indexed to 1-indexed, zero-padded 72 + return `${color === 'black' ? 'b' : 'w'}${col}${row}`; 73 + } 74 + 75 + // Convert board notation to (x, y, color) 76 + export function notationToCoords(notation: string): { x: number, y: number, color: 'black' | 'white' } { 77 + const color = notation[0] === 'b' ? 'black' : 'white'; 78 + const col = notation.charCodeAt(1) - 65; // A=0, B=1, etc. 79 + const row = parseInt(notation.slice(2)) - 1; // 1-indexed to 0-indexed 80 + return { x: col, y: row, color }; 81 + }
+24 -36
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { getSession } from '$lib/server/auth'; 3 - import { fetchAllGamesFromConstellation } from '$lib/atproto-client'; 3 + import { getDb } from '$lib/server/db'; 4 4 import { gameTitle } from '$lib/game-titles'; 5 5 6 6 export const load: PageServerLoad = async (event) => { 7 7 const session = await getSession(event); 8 + const db = getDb(); 8 9 9 - // Calculate timestamps for filtering 10 + // Calculate timestamps 10 11 const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); 11 12 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 12 13 13 - // Fetch all games from Constellation 14 - const allGames = await fetchAllGamesFromConstellation(); 15 - 16 - // Filter active/waiting games (updated in last 12 hours) 17 - const activeGames = allGames.filter( 18 - (game) => 19 - (game.status === 'active' || game.status === 'waiting') && 20 - game.updatedAt >= twelveHoursAgo 21 - ); 22 - 23 - // Filter completed games (from last 7 days) 24 - const completedGames = allGames.filter( 25 - (game) => game.status === 'completed' && game.updatedAt >= sevenDaysAgo 26 - ); 27 - 28 - // Combine and sort by updated time 29 - const recentActiveGames = activeGames 30 - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) 31 - .slice(0, 100); 14 + // Fetch recent active/waiting games (updated in last 12 hours) 15 + const activeGames = await db 16 + .selectFrom('games') 17 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 18 + .where('status', 'in', ['active', 'waiting']) 19 + .where('updated_at', '>=', twelveHoursAgo) 20 + .orderBy('updated_at', 'desc') 21 + .limit(100) 22 + .execute(); 32 23 33 - const recentCompletedGames = completedGames 34 - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) 35 - .slice(0, 50); 24 + // Fetch completed games from last 7 days 25 + const completedGames = await db 26 + .selectFrom('games') 27 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 28 + .where('status', '=', 'completed') 29 + .where('updated_at', '>=', sevenDaysAgo) 30 + .orderBy('updated_at', 'desc') 31 + .limit(50) 32 + .execute(); 36 33 37 - const games = [...recentActiveGames, ...recentCompletedGames]; 34 + // Combine all games 35 + const games = [...activeGames, ...completedGames]; 38 36 39 - // Map to expected format with titles 40 37 const gamesWithTitles = games.map((game) => ({ 41 - rkey: game.rkey, 42 - id: game.uri, 43 - player_one: game.playerOne, 44 - player_two: game.playerTwo || null, 45 - board_size: game.boardSize, 46 - status: game.status, 47 - created_at: game.createdAt, 48 - updated_at: game.updatedAt, 49 - last_action_type: game.lastActionType, 50 - action_count: game.actionCount, 38 + ...game, 51 39 title: gameTitle(game.rkey), 52 40 })); 53 41
+191 -14
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 - import { resolveDidToHandle, fetchMoveCount } from '$lib/atproto-client'; 4 + import { resolveDidToHandle, fetchMoveCount, fetchCloudGoProfile } from '$lib/atproto-client'; 5 + import type { ProfileRecord } from '$lib/types'; 6 + import { formatElapsedTime, isStale } from '$lib/time-utils'; 5 7 6 8 let { data }: { data: PageData } = $props(); 7 9 ··· 12 14 let spectating = $state(false); 13 15 let moveCounts = $state<Record<string, number | null>>({}); 14 16 let handles = $state<Record<string, string>>({}); 17 + let playerStatuses = $state<Record<string, ProfileRecord | null>>({}); 15 18 let sessionHandle = $state<string | null>(null); 16 19 let showMyGamesOnly = $state(false); 17 20 let showMyTurnOnly = $state(false); 18 21 let archivePage = $state(1); 22 + let activePage = $state(1); 19 23 const ARCHIVE_PAGE_SIZE = 6; 24 + const ACTIVE_PAGE_SIZE = 10; 20 25 21 26 // Helper to determine whose turn it is in a game 22 27 function getWhoseTurn(game: typeof data.games[0]): 'black' | 'white' { ··· 60 65 (data.games || []) 61 66 .filter((g) => g.status === 'completed') 62 67 .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 68 + ); 69 + 70 + const activeTotalPages = $derived(Math.ceil(currentGames.length / ACTIVE_PAGE_SIZE)); 71 + 72 + const paginatedActiveGames = $derived( 73 + currentGames.slice((activePage - 1) * ACTIVE_PAGE_SIZE, activePage * ACTIVE_PAGE_SIZE) 63 74 ); 64 75 65 76 const archiveTotalPages = $derived(Math.ceil(archivedGames.length / ARCHIVE_PAGE_SIZE)); ··· 76 87 return null; 77 88 } 78 89 90 + function getPlayerStatus(did: string): 'playing' | 'watching' | 'offline' | 'unknown' { 91 + const profile = playerStatuses[did]; 92 + return profile?.status ?? 'unknown'; 93 + } 94 + 95 + function getPlayerRanking(did: string): string { 96 + const profile = playerStatuses[did]; 97 + return profile?.ranking ? `(${profile.ranking})` : ''; 98 + } 99 + 79 100 onMount(() => { 80 101 // Resolve session handle 81 102 if (data.session) { ··· 92 113 if (game.player_two) dids.add(game.player_two); 93 114 } 94 115 95 - // Resolve handles 116 + // Resolve handles and fetch Cloud Go profiles 96 117 for (const did of dids) { 97 118 resolveDidToHandle(did).then((h) => { 98 119 handles = { ...handles, [did]: h }; 99 120 }); 121 + 122 + fetchCloudGoProfile(did).then((p) => { 123 + playerStatuses = { ...playerStatuses, [did]: p }; 124 + }); 100 125 } 101 126 102 127 // Fetch move counts ··· 214 239 <div class="games-layout"> 215 240 <!-- Current Games --> 216 241 <div class="card current-games"> 217 - <h2>Current Games</h2> 218 - {#if currentGames.length > 0} 242 + <div class="section-header-with-count"> 243 + <h2>Current Games</h2> 244 + {#if currentGames.length > ACTIVE_PAGE_SIZE} 245 + <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> 246 + {/if} 247 + </div> 248 + {#if paginatedActiveGames.length > 0} 219 249 <div class="games-list"> 220 - {#each currentGames as game} 250 + {#each paginatedActiveGames as game} 221 251 <div class="game-item"> 222 252 <img 223 253 src="/api/games/{game.rkey}/board?size=70" ··· 231 261 <strong>{game.board_size}x{game.board_size}</strong> board 232 262 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 233 263 </div> 264 + <div class="last-move-time" class:stale={isStale(game.updated_at)}> 265 + Last move: {formatElapsedTime(game.updated_at)} 266 + </div> 234 267 <div class="game-players"> 235 - Player 1: <a href="/profile/{game.player_one}" class="player-link">{handles[game.player_one] || game.player_one}</a> 268 + Player 1: <a 269 + href="/profile/{game.player_one}" 270 + class="player-link" 271 + class:player-playing={getPlayerStatus(game.player_one) === 'playing'} 272 + class:player-watching={getPlayerStatus(game.player_one) === 'watching'} 273 + class:player-offline={getPlayerStatus(game.player_one) === 'offline'} 274 + > 275 + {handles[game.player_one] || game.player_one} <span class="ranking-badge">{getPlayerRanking(game.player_one)}</span> 276 + </a> 236 277 {#if game.player_two} 237 - <br />Player 2: <a href="/profile/{game.player_two}" class="player-link">{handles[game.player_two] || game.player_two}</a> 278 + <br />Player 2: <a 279 + href="/profile/{game.player_two}" 280 + class="player-link" 281 + class:player-playing={getPlayerStatus(game.player_two) === 'playing'} 282 + class:player-watching={getPlayerStatus(game.player_two) === 'watching'} 283 + class:player-offline={getPlayerStatus(game.player_two) === 'offline'} 284 + > 285 + {handles[game.player_two] || game.player_two} <span class="ranking-badge">{getPlayerRanking(game.player_two)}</span> 286 + </a> 238 287 {/if} 239 288 </div> 240 289 </div> ··· 244 293 </div> 245 294 {/each} 246 295 </div> 296 + {#if activeTotalPages > 1} 297 + <div class="pagination"> 298 + <button 299 + class="pagination-button" 300 + disabled={activePage === 1} 301 + onclick={() => activePage = Math.max(1, activePage - 1)} 302 + > 303 + ← Previous 304 + </button> 305 + <span class="pagination-info">Page {activePage} of {activeTotalPages}</span> 306 + <button 307 + class="pagination-button" 308 + disabled={activePage === activeTotalPages} 309 + onclick={() => activePage = Math.min(activeTotalPages, activePage + 1)} 310 + > 311 + Next → 312 + </button> 313 + </div> 314 + {/if} 247 315 {:else} 248 - <p class="empty-state">No active games right now.</p> 316 + <p class="empty-state">No active games in the last 12 hours. <a href="/profile/{data.session?.did}" class="link">View all your games</a></p> 249 317 {/if} 250 318 </div> 251 319 ··· 297 365 <div class="archive-card-meta"> 298 366 <strong>{game.board_size}x{game.board_size}</strong> 299 367 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 368 + <div class="last-move-time" class:stale={isStale(game.updated_at)}> 369 + Last move: {formatElapsedTime(game.updated_at)} 370 + </div> 300 371 </div> 301 372 <div class="archive-card-players"> 302 373 <span>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> ··· 341 412 <label> 342 413 Board Size: 343 414 <select bind:value={boardSize} class="select"> 415 + <option value={5}>5x5</option> 416 + <option value={7}>7x7</option> 344 417 <option value={9}>9x9</option> 345 418 <option value={13}>13x13</option> 346 419 <option value={19}>19x19</option> ··· 360 433 <!-- Current Games --> 361 434 <div class="card current-games"> 362 435 <div class="section-header"> 363 - <h2>Current Games</h2> 436 + <div class="section-title-row"> 437 + <h2>Current Games</h2> 438 + {#if currentGames.length > ACTIVE_PAGE_SIZE} 439 + <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> 440 + {/if} 441 + </div> 364 442 <div class="filter-toggles"> 365 443 <label class="toggle-label"> 366 444 <input type="checkbox" bind:checked={showMyGamesOnly} /> ··· 372 450 </label> 373 451 </div> 374 452 </div> 375 - {#if currentGames.length > 0} 453 + {#if paginatedActiveGames.length > 0} 376 454 <div class="games-list"> 377 - {#each currentGames as game} 455 + {#each paginatedActiveGames as game} 378 456 {@const whoseTurn = getWhoseTurn(game)} 379 457 {@const myTurn = isMyTurn(game)} 380 458 {@const playing = isMyGame(game)} ··· 401 479 <div> 402 480 <strong>{game.board_size}x{game.board_size}</strong> board 403 481 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 482 + </div> 483 + <div class="last-move-time" class:stale={isStale(game.updated_at)}> 484 + Last move: {formatElapsedTime(game.updated_at)} 404 485 </div> 405 486 <div class="game-players"> 406 - <span class:current-turn={whoseTurn === 'black'}>⚫ <a href="/profile/{game.player_one}" class="player-link">{handles[game.player_one] || game.player_one}</a></span> 487 + <span class:current-turn={whoseTurn === 'black'}>⚫ <a 488 + href="/profile/{game.player_one}" 489 + class="player-link" 490 + class:player-playing={getPlayerStatus(game.player_one) === 'playing'} 491 + class:player-watching={getPlayerStatus(game.player_one) === 'watching'} 492 + class:player-offline={getPlayerStatus(game.player_one) === 'offline'} 493 + > 494 + {handles[game.player_one] || game.player_one} <span class="ranking-badge">{getPlayerRanking(game.player_one)}</span> 495 + </a></span> 407 496 {#if game.player_two} 408 497 <span class="vs">vs</span> 409 - <span class:current-turn={whoseTurn === 'white'}>⚪ <a href="/profile/{game.player_two}" class="player-link">{handles[game.player_two] || game.player_two}</a></span> 498 + <span class:current-turn={whoseTurn === 'white'}>⚪ <a 499 + href="/profile/{game.player_two}" 500 + class="player-link" 501 + class:player-playing={getPlayerStatus(game.player_two) === 'playing'} 502 + class:player-watching={getPlayerStatus(game.player_two) === 'watching'} 503 + class:player-offline={getPlayerStatus(game.player_two) === 'offline'} 504 + > 505 + {handles[game.player_two] || game.player_two} <span class="ranking-badge">{getPlayerRanking(game.player_two)}</span> 506 + </a></span> 410 507 {/if} 411 508 </div> 412 509 </div> ··· 422 519 </div> 423 520 {/each} 424 521 </div> 522 + {#if activeTotalPages > 1} 523 + <div class="pagination"> 524 + <button 525 + class="pagination-button" 526 + disabled={activePage === 1} 527 + onclick={() => activePage = Math.max(1, activePage - 1)} 528 + > 529 + ← Previous 530 + </button> 531 + <span class="pagination-info">Page {activePage} of {activeTotalPages}</span> 532 + <button 533 + class="pagination-button" 534 + disabled={activePage === activeTotalPages} 535 + onclick={() => activePage = Math.min(activeTotalPages, activePage + 1)} 536 + > 537 + Next → 538 + </button> 539 + </div> 540 + {/if} 425 541 {:else} 426 - <p class="empty-state">{showMyGamesOnly ? 'No active games you\'re in.' : 'No active games right now.'}</p> 542 + <p class="empty-state"> 543 + {showMyGamesOnly ? 'No active games you\'re in from the last 12 hours.' : 'No active games in the last 12 hours.'} 544 + <a href="/profile/{data.session?.did}" class="link">View all your games</a> 545 + </p> 427 546 {/if} 428 547 </div> 429 548 ··· 479 598 <div class="archive-card-meta"> 480 599 <strong>{game.board_size}x{game.board_size}</strong> 481 600 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 601 + <div class="last-move-time" class:stale={isStale(game.updated_at)}> 602 + Last move: {formatElapsedTime(game.updated_at)} 603 + </div> 482 604 </div> 483 605 <div class="archive-card-players"> 484 606 <span>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> ··· 840 962 color: var(--sky-apricot-dark); 841 963 } 842 964 965 + .player-link.player-playing { 966 + color: #059669; 967 + font-weight: 600; 968 + } 969 + 970 + .player-link.player-watching { 971 + color: #ca8a04; 972 + font-weight: 600; 973 + } 974 + 975 + .player-link.player-offline { 976 + color: #9ca3af; 977 + opacity: 0.7; 978 + } 979 + 980 + .ranking-badge { 981 + font-size: 0.85em; 982 + color: var(--sky-gray); 983 + font-weight: normal; 984 + } 985 + 843 986 .empty-state { 844 987 text-align: center; 845 988 color: var(--sky-gray); ··· 922 1065 margin-left: 0.5rem; 923 1066 } 924 1067 1068 + .last-move-time { 1069 + font-size: 0.75rem; 1070 + color: var(--sky-gray); 1071 + margin-top: 0.25rem; 1072 + } 1073 + 1074 + .last-move-time.stale { 1075 + color: var(--sky-apricot-dark); 1076 + font-weight: 600; 1077 + } 1078 + 925 1079 .games-layout { 926 1080 display: grid; 927 1081 grid-template-columns: 1fr 1fr; ··· 949 1103 950 1104 .section-header h2 { 951 1105 margin: 0; 1106 + } 1107 + 1108 + .section-header-with-count { 1109 + display: flex; 1110 + justify-content: space-between; 1111 + align-items: center; 1112 + margin-bottom: 1rem; 1113 + } 1114 + 1115 + .section-header-with-count h2 { 1116 + margin: 0; 1117 + } 1118 + 1119 + .section-title-row { 1120 + display: flex; 1121 + align-items: center; 1122 + gap: 0.75rem; 1123 + } 1124 + 1125 + .game-count { 1126 + font-size: 0.875rem; 1127 + color: var(--sky-gray); 1128 + font-weight: normal; 952 1129 } 953 1130 954 1131 .toggle-label {
+2 -2
src/routes/api/games/+server.ts
··· 19 19 20 20 const { boardSize = 19 } = await event.request.json(); 21 21 22 - if (![9, 13, 19].includes(boardSize)) { 23 - throw error(400, 'Invalid board size'); 22 + if (![5, 7, 9, 13, 19].includes(boardSize)) { 23 + throw error(400, 'Invalid board size. Supported: 5x5, 7x7, 9x9, 13x13, 19x19'); 24 24 } 25 25 26 26 try {
+163 -6
src/routes/api/games/[id]/score/+server.ts
··· 3 3 import { getSession, getAgent } from '$lib/server/auth'; 4 4 import { getDb } from '$lib/server/db'; 5 5 import { calculateScore } from '$lib/server/scoring'; 6 - import { fetchGameActionsFromPds } from '$lib/atproto-client'; 6 + import { fetchGameActionsFromPds, fetchCloudGoProfile, resolvePdsHost } from '$lib/atproto-client'; 7 7 import { buildBoardStateFromMoves, calculateTerritory } from '$lib/server/board-svg'; 8 + import { calculateNewRating, getDefaultRating } from '$lib/server/elo'; 9 + import type { ProfileRecord } from '$lib/types'; 8 10 9 11 /** 10 12 * GET: Calculate suggested scores using tenuki's scoring engine 11 13 */ 12 14 export const GET: RequestHandler = async (event) => { 13 - const { params } = event; 15 + const { params, url } = event; 14 16 const { id: rkey } = params; 17 + 18 + // Parse dead stones from query parameter (comma-separated or JSON array) 19 + const deadStonesParam = url.searchParams.get('deadStones'); 20 + let deadStones: string[] = []; 21 + 22 + if (deadStonesParam) { 23 + try { 24 + // Try parsing as JSON array first 25 + deadStones = JSON.parse(deadStonesParam); 26 + } catch { 27 + // Fallback to comma-separated values 28 + deadStones = deadStonesParam.split(',').filter(s => s.trim()); 29 + } 30 + } 31 + 32 + console.log('[SCORE] Starting GET request for rkey:', rkey, 'with', deadStones.length, 'dead stones'); 15 33 16 34 try { 35 + console.log('[SCORE] Getting database instance...'); 17 36 const db = getDb(); 18 37 38 + console.log('[SCORE] Querying game from database...'); 19 39 const game = await db 20 40 .selectFrom('games') 21 41 .selectAll() ··· 23 43 .executeTakeFirst(); 24 44 25 45 if (!game) { 46 + console.log('[SCORE] Game not found'); 26 47 throw error(404, 'Game not found'); 27 48 } 28 49 50 + console.log('[SCORE] Game found:', { 51 + id: game.id, 52 + player_one: game.player_one, 53 + player_two: game.player_two, 54 + board_size: game.board_size, 55 + status: game.status, 56 + }); 57 + 58 + // Validate board size 59 + if (!game.board_size || typeof game.board_size !== 'number' || game.board_size < 1 || game.board_size > 25) { 60 + console.error('[SCORE] Invalid board_size for game:', game.id, game.board_size); 61 + throw error(500, 'Invalid game board size'); 62 + } 63 + 64 + console.log('[SCORE] Board size validated:', game.board_size); 65 + 29 66 // Fetch moves from PDS 67 + console.log('[SCORE] Fetching moves from PDS...'); 68 + const startFetch = Date.now(); 30 69 const { moves } = await fetchGameActionsFromPds( 31 70 game.player_one, 32 71 game.player_two, 33 72 game.id // AT URI 34 73 ); 74 + console.log('[SCORE] Fetched', moves.length, 'moves in', Date.now() - startFetch, 'ms'); 35 75 36 76 if (moves.length === 0) { 77 + console.log('[SCORE] No moves to score'); 37 78 return json({ 38 79 suggested: null, 39 80 territory: null, ··· 43 84 44 85 // Calculate score using tenuki 45 86 // Default komi of 6.5 for territory scoring 46 - const score = calculateScore(moves, game.board_size, 6.5); 87 + console.log('[SCORE] Calculating score with tenuki and dead stones...'); 88 + const startScore = Date.now(); 89 + const score = calculateScore(moves, game.board_size, 6.5, deadStones); 90 + console.log('[SCORE] Score calculated in', Date.now() - startScore, 'ms:', score); 47 91 48 92 // Build board state and calculate territory 93 + console.log('[SCORE] Building board state...'); 94 + const startBoardState = Date.now(); 49 95 const { boardState } = await buildBoardStateFromMoves( 50 96 moves.map(m => ({ x: m.x, y: m.y, color: m.color })), 51 - game.board_size 97 + game.board_size, 98 + deadStones 52 99 ); 100 + console.log('[SCORE] Board state built in', Date.now() - startBoardState, 'ms'); 101 + 102 + console.log('[SCORE] Calculating territory...'); 103 + const startTerritory = Date.now(); 53 104 const territoryMap = calculateTerritory(boardState, game.board_size); 105 + console.log('[SCORE] Territory calculated in', Date.now() - startTerritory, 'ms:', { 106 + blackTerritory: territoryMap.blackTerritory, 107 + whiteTerritory: territoryMap.whiteTerritory, 108 + }); 54 109 110 + console.log('[SCORE] Returning successful response'); 55 111 return json({ 56 112 suggested: { 57 113 blackScore: Math.round(score.black), ··· 66 122 message: 'Scores calculated automatically. Please verify and adjust if needed (e.g., for dead stones).', 67 123 }); 68 124 } catch (err) { 69 - console.error('Failed to calculate suggested scores:', err); 125 + console.error('[SCORE] Failed to calculate suggested scores:', err); 70 126 // Return null suggestion on error rather than failing 71 127 return json({ 72 128 suggested: null, ··· 85 141 86 142 const { id: rkey } = params; 87 143 const body = await event.request.json(); 88 - const { blackScore, whiteScore } = body; 144 + const { blackScore, whiteScore, deadStones = [] } = body; 89 145 90 146 if (typeof blackScore !== 'number' || typeof whiteScore !== 'number') { 91 147 throw error(400, 'Invalid score values'); ··· 95 151 throw error(400, 'Scores must be non-negative'); 96 152 } 97 153 154 + if (!Array.isArray(deadStones)) { 155 + throw error(400, 'deadStones must be an array'); 156 + } 157 + 158 + // Validate dead stone notation format 159 + const deadStonePattern = /^[bw][A-T](0[1-9]|1[0-9]|2[0-5])$/; 160 + for (const stone of deadStones) { 161 + if (typeof stone !== 'string' || !deadStonePattern.test(stone)) { 162 + throw error(400, `Invalid dead stone notation: ${stone}`); 163 + } 164 + } 165 + 166 + console.log('[SCORE POST] Submitting scores with', deadStones.length, 'dead stones'); 167 + 98 168 try { 99 169 const db = getDb(); 100 170 ··· 145 215 whiteScore, 146 216 blackScorer: session.did, 147 217 whiteScorer: session.did, 218 + deadStones: deadStones.length > 0 ? deadStones : undefined, 148 219 createdAt: game.created_at, 149 220 }; 150 221 ··· 166 237 } 167 238 } 168 239 240 + // Update the current player's ranking and stats 241 + try { 242 + const agent = await getAgent(event); 243 + if (agent && game.player_two) { 244 + await updateCurrentPlayerRanking( 245 + session.did, 246 + session.did === game.player_one ? game.player_two : game.player_one, 247 + winner, 248 + agent 249 + ); 250 + } 251 + } catch (err) { 252 + console.error('Failed to update player ranking:', err); 253 + // Don't fail the request if ranking update fails 254 + } 255 + 169 256 return json({ success: true }); 170 257 } catch (err) { 171 258 console.error('Failed to submit scores:', err); 172 259 throw error(500, 'Failed to submit scores'); 173 260 } 174 261 }; 262 + 263 + /** 264 + * Update the current player's ranking after a game is scored 265 + * This is called when the authenticated user submits or views a scored game 266 + */ 267 + async function updateCurrentPlayerRanking( 268 + currentPlayerDid: string, 269 + opponentDid: string, 270 + winnerDid: string | null, 271 + agent: any 272 + ): Promise<void> { 273 + if (!winnerDid) return; 274 + 275 + // Fetch both players' profiles 276 + const currentPlayerProfile = await fetchCloudGoProfile(currentPlayerDid); 277 + const opponentProfile = await fetchCloudGoProfile(opponentDid); 278 + 279 + const currentPlayerRating = currentPlayerProfile?.ranking ?? getDefaultRating(); 280 + const opponentRating = opponentProfile?.ranking ?? getDefaultRating(); 281 + 282 + // Calculate new rating 283 + const won = winnerDid === currentPlayerDid; 284 + const actualScore = won ? 1 : 0; 285 + const newRating = calculateNewRating(currentPlayerRating, opponentRating, actualScore); 286 + 287 + const now = new Date().toISOString(); 288 + 289 + const record: ProfileRecord = { 290 + $type: 'boo.sky.go.profile', 291 + wins: (currentPlayerProfile?.wins ?? 0) + (won ? 1 : 0), 292 + losses: (currentPlayerProfile?.losses ?? 0) + (won ? 0 : 1), 293 + ranking: newRating, 294 + status: currentPlayerProfile?.status ?? 'offline', 295 + createdAt: currentPlayerProfile?.createdAt ?? now, 296 + updatedAt: now, 297 + }; 298 + 299 + try { 300 + // Check if profile exists to decide between create and update 301 + if (currentPlayerProfile) { 302 + const result = await agent.post('com.atproto.repo.putRecord', { 303 + input: { 304 + repo: currentPlayerDid, 305 + collection: 'boo.sky.go.profile', 306 + rkey: 'self', 307 + record, 308 + }, 309 + }); 310 + 311 + if (!result.ok) { 312 + console.error('Failed to update profile:', result.data); 313 + } 314 + } else { 315 + const result = await agent.post('com.atproto.repo.createRecord', { 316 + input: { 317 + repo: currentPlayerDid, 318 + collection: 'boo.sky.go.profile', 319 + rkey: 'self', 320 + record, 321 + }, 322 + }); 323 + 324 + if (!result.ok) { 325 + console.error('Failed to create profile:', result.data); 326 + } 327 + } 328 + } catch (err) { 329 + console.error('Failed to update profile:', err); 330 + } 331 + }
+180
src/routes/api/profile/+server.ts
··· 1 + import { json, error } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { getSession, getAgent } from "$lib/server/auth"; 4 + import { getDb } from "$lib/server/db"; 5 + import { fetchGameRecord, fetchCloudGoProfile } from "$lib/atproto-client"; 6 + import { calculateNewRating, getDefaultRating } from "$lib/server/elo"; 7 + 8 + export const POST: RequestHandler = async (event) => { 9 + const session = await getSession(event); 10 + 11 + if (!session) { 12 + throw error(401, "Not authenticated"); 13 + } 14 + 15 + const { status, wins, losses } = await event.request.json(); 16 + 17 + if (status && !["playing", "watching", "offline"].includes(status)) { 18 + throw error(400, "Invalid status. Must be: playing, watching, or offline"); 19 + } 20 + 21 + try { 22 + const agent = await getAgent(event); 23 + if (!agent) { 24 + throw error(401, "Failed to get authenticated agent"); 25 + } 26 + 27 + const now = new Date().toISOString(); 28 + 29 + // Check if profile exists 30 + let existingProfile = null; 31 + try { 32 + const getResult = await (agent as any).get("com.atproto.repo.getRecord", { 33 + params: { 34 + repo: session.did, 35 + collection: "boo.sky.go.profile", 36 + rkey: "self", 37 + }, 38 + }); 39 + if (getResult.ok) { 40 + existingProfile = getResult.data.value; 41 + } 42 + } catch (err) { 43 + console.error("Failed to get profile:", err); 44 + // Profile doesn't exist yet 45 + } 46 + 47 + // Recalculate stats from completed games if status is being updated 48 + let calculatedWins = existingProfile?.wins ?? 0; 49 + let calculatedLosses = existingProfile?.losses ?? 0; 50 + let calculatedRanking = existingProfile?.ranking ?? 1200; 51 + 52 + if (status) { 53 + try { 54 + const stats = await recalculatePlayerStats(session.did); 55 + calculatedWins = stats.wins; 56 + calculatedLosses = stats.losses; 57 + calculatedRanking = stats.ranking; 58 + } catch (err) { 59 + console.error("Failed to recalculate stats:", err); 60 + // Fall back to existing values 61 + } 62 + } 63 + 64 + const record = { 65 + $type: "boo.sky.go.profile", 66 + wins: wins !== undefined ? wins : calculatedWins, 67 + losses: losses !== undefined ? losses : calculatedLosses, 68 + ranking: calculatedRanking, 69 + status: status || existingProfile?.status || "offline", 70 + createdAt: existingProfile?.createdAt || now, 71 + updatedAt: now, 72 + }; 73 + 74 + if (existingProfile) { 75 + // Update existing profile 76 + const result = await (agent as any).post("com.atproto.repo.putRecord", { 77 + input: { 78 + repo: session.did, 79 + collection: "boo.sky.go.profile", 80 + rkey: "self", 81 + record, 82 + }, 83 + }); 84 + 85 + if (!result.ok) { 86 + throw new Error(`Failed to update profile: ${result.data.message}`); 87 + } 88 + } else { 89 + // Create new profile 90 + const result = await (agent as any).post( 91 + "com.atproto.repo.createRecord", 92 + { 93 + input: { 94 + repo: session.did, 95 + collection: "boo.sky.go.profile", 96 + rkey: "self", 97 + record, 98 + }, 99 + }, 100 + ); 101 + 102 + if (!result.ok) { 103 + throw new Error(`Failed to create profile: ${result.data.message}`); 104 + } 105 + } 106 + 107 + return json({ success: true, profile: record }); 108 + } catch (err) { 109 + console.error("Failed to update profile:", err); 110 + throw error(500, "Failed to update profile"); 111 + } 112 + }; 113 + 114 + /** 115 + * Recalculate a player's wins, losses, and ranking from all completed games 116 + */ 117 + async function recalculatePlayerStats( 118 + did: string, 119 + ): Promise<{ wins: number; losses: number; ranking: number }> { 120 + const db = getDb(); 121 + 122 + // Fetch all completed games where the player participated 123 + const completedGames = await db 124 + .selectFrom("games") 125 + .selectAll() 126 + .where("status", "=", "completed") 127 + .where((eb) => 128 + eb.or([eb("player_one", "=", did), eb("player_two", "=", did)]), 129 + ) 130 + .execute(); 131 + 132 + let wins = 0; 133 + let losses = 0; 134 + let currentRanking = getDefaultRating(); 135 + 136 + // Sort by updated_at to process games in chronological order 137 + completedGames.sort( 138 + (a, b) => 139 + new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime(), 140 + ); 141 + 142 + // Process each game to calculate wins/losses and update ranking 143 + for (const game of completedGames) { 144 + if (!game.player_two) continue; // Skip games without opponent 145 + 146 + try { 147 + // Fetch the game record to get winner 148 + const gameRecord = await fetchGameRecord(game.player_one, game.rkey); 149 + if (!gameRecord?.winner) continue; 150 + 151 + const won = gameRecord.winner === did; 152 + const opponentDid = 153 + game.player_one === did ? game.player_two : game.player_one; 154 + 155 + // Update win/loss count 156 + if (won) { 157 + wins++; 158 + } else { 159 + losses++; 160 + } 161 + 162 + // Fetch opponent's ranking at time of calculation 163 + const opponentProfile = await fetchCloudGoProfile(opponentDid); 164 + const opponentRanking = opponentProfile?.ranking ?? getDefaultRating(); 165 + 166 + // Calculate new ranking 167 + const actualScore = won ? 1 : 0; 168 + currentRanking = calculateNewRating( 169 + currentRanking, 170 + opponentRanking, 171 + actualScore, 172 + ); 173 + } catch (err) { 174 + console.error(`Failed to process game ${game.id}:`, err); 175 + // Continue processing other games 176 + } 177 + } 178 + 179 + return { wins, losses, ranking: currentRanking }; 180 + }
+1 -1
src/routes/auth/login/+server.ts
··· 45 45 const { url } = await oauth.authorize({ 46 46 target, 47 47 scope: 48 - "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create", 48 + "atproto repo:app.bsky.feed.post?action=create com.atproto.repo.uploadBlob blob:image/png repo:boo.sky.go.game?action=create repo:boo.sky.go.game?action=update repo:boo.sky.go.move?action=create repo:boo.sky.go.pass?action=create repo:boo.sky.go.resign?action=create repo:boo.sky.go.reaction?action=create repo:boo.sky.go.profile?action=create repo:boo.sky.go.profile?action=update", 49 49 state: { startedAt: Date.now() }, 50 50 }); 51 51
+12 -7
src/routes/game/[id]/+page.server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import type { PageServerLoad } from './$types'; 3 3 import { getSession } from '$lib/server/auth'; 4 - import { findGameByRkey } from '$lib/atproto-client'; 4 + import { getDb } from '$lib/server/db'; 5 5 6 6 export const load: PageServerLoad = async (event) => { 7 7 const session = await getSession(event); 8 8 const { id: rkey } = event.params; 9 9 10 - // Fetch game from Constellation 11 - const game = await findGameByRkey(rkey); 10 + const db = getDb(); 11 + 12 + const game = await db 13 + .selectFrom('games') 14 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 15 + .where('rkey', '=', rkey) 16 + .executeTakeFirst(); 12 17 13 18 if (!game) { 14 19 throw error(404, 'Game not found'); ··· 17 22 return { 18 23 session, 19 24 gameRkey: game.rkey, 20 - creatorDid: game.creatorDid, 21 - playerTwoDid: game.playerTwo || null, 22 - gameAtUri: game.uri, 23 - boardSize: game.boardSize, 25 + creatorDid: game.player_one, 26 + playerTwoDid: game.player_two, 27 + gameAtUri: game.id, 28 + boardSize: game.board_size, 24 29 status: game.status, 25 30 }; 26 31 };
+535 -48
src/routes/game/[id]/+page.svelte
··· 8 8 fetchGameRecord, 9 9 fetchGameActionsFromPds, 10 10 fetchGameReactions, 11 + fetchUserProfile, 12 + fetchCloudGoProfile, 11 13 type ReactionWithAuthor, 14 + type UserProfile, 12 15 } from '$lib/atproto-client'; 16 + import type { ProfileRecord } from '$lib/types'; 13 17 import type { MoveRecord, PassRecord, GameRecord, ResignRecord } from '$lib/types'; 14 18 import { onMount, onDestroy } from 'svelte'; 15 19 import { browser } from '$app/environment'; 16 20 import { goto } from '$app/navigation'; 17 21 import { page } from '$app/stores'; 18 22 import { calculateLiberties } from '$lib/liberty-calculator'; 23 + import { formatElapsedTime, isStale } from '$lib/time-utils'; 24 + import { calculateScore, calculateTerritory, buildBoardState } from '$lib/client-scoring'; 25 + import type { TerritoryMap } from '$lib/client-scoring'; 19 26 20 27 let { data }: { data: PageData } = $props(); 21 28 ··· 26 33 let blackScore = $state(0); 27 34 let whiteScore = $state(0); 28 35 let suggestedScores = $state<{ blackScore: number; whiteScore: number; winner: string } | null>(null); 29 - let territoryData = $state<{ territory: Array<Array<'black' | 'white' | 'neutral' | null>>; blackTerritory: number; whiteTerritory: number } | null>(null); 36 + let territoryData = $state<TerritoryMap | null>(null); 30 37 let loadingSuggestions = $state(false); 38 + let deadStones = $state<string[]>([]); 39 + let markingDeadStones = $state(false); 31 40 let firehose: GameFirehose | null = null; 32 41 let showMoveNotification = $state(false); 33 42 let jetstreamConnected = $state(false); ··· 56 65 let resigns = $state<ResignRecord[]>([]); 57 66 let playerOneHandle = $state<string>(data.creatorDid); 58 67 let playerTwoHandle = $state<string | null>(data.playerTwoDid); 68 + let playerOneProfile = $state<UserProfile | null>(null); 69 + let playerTwoProfile = $state<UserProfile | null>(null); 70 + let playerOneCloudGoProfile = $state<ProfileRecord | null>(null); 71 + let playerTwoCloudGoProfile = $state<ProfileRecord | null>(null); 59 72 60 73 // Combined loading state for initial render 61 74 const loading = $derived(loadingGame || loadingMoves); 62 75 76 + // Deduplication helpers for Jetstream updates 77 + function moveExists( 78 + newMove: { moveNumber: number, x: number, y: number }, 79 + existingMoves: MoveRecord[] 80 + ): boolean { 81 + return existingMoves.some(m => 82 + m.moveNumber === newMove.moveNumber && 83 + m.x === newMove.x && 84 + m.y === newMove.y 85 + ); 86 + } 87 + 88 + function moveExistsByUri(uri: string | undefined, existingMoves: MoveRecord[]): boolean { 89 + if (!uri) return false; 90 + return existingMoves.some(m => m.uri === uri); 91 + } 92 + 93 + function passExists( 94 + newPass: { moveNumber: number }, 95 + existingPasses: PassRecord[] 96 + ): boolean { 97 + return existingPasses.some(p => p.moveNumber === newPass.moveNumber); 98 + } 99 + 100 + // Format move coordinates as board notation (e.g., A1, B2, T19) 101 + function formatMoveCoords(x: number, y: number): string { 102 + const col = String.fromCharCode(65 + x); // 0=A, 1=B 103 + const row = (y + 1).toString().padStart(2, '0'); // 0-indexed to 1-indexed, zero-padded 104 + return `${col}${row}`; 105 + } 106 + 63 107 // DB index is authoritative for status and playerTwo (set by join endpoint, 64 108 // which can't write to player one's PDS repo). PDS is authoritative for 65 109 // scores and winner (written by game creator). ··· 129 173 !territoryData && 130 174 !loadingSuggestions 131 175 ) { 132 - fetchSuggestedScores(); 176 + calculateSuggestedScores(); 133 177 } 134 178 }); 135 179 ··· 159 203 // return `at://${move.player}/boo.sky.go.move/${move.moveNumber}`; 160 204 } 161 205 206 + // Get ranking display color based on comparison 207 + function getRankingColor(myRanking: number | undefined, opponentRanking: number | undefined): string { 208 + if (!myRanking || !opponentRanking) return '#6b7280'; // gray if unknown 209 + if (myRanking > opponentRanking) return '#059669'; // green if higher 210 + if (myRanking < opponentRanking) return '#dc2626'; // red if lower 211 + return '#6b7280'; // gray if equal 212 + } 213 + 214 + // Get border color based on player status 215 + function getPlayerBorderColor(status: string | undefined): string { 216 + switch (status) { 217 + case 'playing': return '#059669'; 218 + case 'watching': return '#eab308'; 219 + case 'offline': return '#9ca3af'; 220 + default: return '#d1d5db'; 221 + } 222 + } 223 + 162 224 // Get reactions for the selected move 163 225 const selectedMoveReactions = $derived(() => { 164 226 if (!selectedMove) return []; ··· 210 272 } 211 273 212 274 async function loadGameData() { 213 - // Resolve handles (fire and forget) 275 + // Resolve handles and fetch profiles (fire and forget) 214 276 resolveDidToHandle(data.creatorDid).then((h) => { 215 277 playerOneHandle = h; 216 278 }); 279 + fetchUserProfile(data.creatorDid).then((p) => { 280 + playerOneProfile = p; 281 + }); 282 + fetchCloudGoProfile(data.creatorDid).then((p) => { 283 + playerOneCloudGoProfile = p; 284 + }); 285 + 217 286 if (data.playerTwoDid) { 218 287 resolveDidToHandle(data.playerTwoDid).then((h) => { 219 288 playerTwoHandle = h; 289 + }); 290 + fetchUserProfile(data.playerTwoDid).then((p) => { 291 + playerTwoProfile = p; 292 + }); 293 + fetchCloudGoProfile(data.playerTwoDid).then((p) => { 294 + playerTwoCloudGoProfile = p; 220 295 }); 221 296 } 222 297 ··· 224 299 const record = await fetchGameRecord(data.creatorDid, data.gameRkey); 225 300 if (record) { 226 301 gameRecord = record; 227 - // If the PDS record has playerTwo but the DB didn't, resolve that handle too 302 + // If the PDS record has playerTwo but the DB didn't, resolve that handle and fetch profiles 228 303 if (record.playerTwo && !data.playerTwoDid) { 229 304 resolveDidToHandle(record.playerTwo).then((h) => { 230 305 playerTwoHandle = h; 306 + }); 307 + fetchUserProfile(record.playerTwo).then((p) => { 308 + playerTwoProfile = p; 309 + }); 310 + fetchCloudGoProfile(record.playerTwo).then((p) => { 311 + playerTwoCloudGoProfile = p; 231 312 }); 232 313 } 233 314 } ··· 363 444 } 364 445 } 365 446 366 - async function fetchSuggestedScores() { 367 - if (loadingSuggestions || suggestedScores) return; 447 + // Convert (x, y, color) coordinates to dead stone notation 448 + function coordsToNotation(x: number, y: number, color: 'black' | 'white'): string { 449 + const col = String.fromCharCode(65 + x); // 0=A, 1=B, etc. 450 + const row = (y + 1).toString().padStart(2, '0'); // 0-indexed to 1-indexed, zero-padded 451 + const colorCode = color === 'black' ? 'b' : 'w'; 452 + return `${colorCode}${col}${row}`; 453 + } 454 + 455 + // Toggle a stone as dead/alive 456 + function handleToggleDeadStone(x: number, y: number, color: 'black' | 'white') { 457 + const notation = coordsToNotation(x, y, color); 458 + const index = deadStones.indexOf(notation); 459 + 460 + if (index >= 0) { 461 + // Remove from dead stones 462 + deadStones = deadStones.filter((_, i) => i !== index); 463 + } else { 464 + // Add to dead stones 465 + deadStones = [...deadStones, notation]; 466 + } 368 467 468 + // Recalculate scores with new dead stones 469 + calculateSuggestedScores(); 470 + } 471 + 472 + function calculateSuggestedScores() { 369 473 loadingSuggestions = true; 370 474 try { 371 - const response = await fetch(`/api/games/${data.gameRkey}/score`); 372 - if (response.ok) { 373 - const result = await response.json(); 374 - if (result.suggested) { 375 - suggestedScores = result.suggested; 376 - // Pre-populate the form with suggested values 377 - blackScore = result.suggested.blackScore; 378 - whiteScore = result.suggested.whiteScore; 379 - } 380 - if (result.territory) { 381 - territoryData = result.territory; 475 + console.log('[Game Page] Calculating scores client-side...'); 476 + 477 + // Calculate scores using client-side logic 478 + const score = calculateScore(moves, gameBoardSize, 6.5, deadStones); 479 + 480 + // Build board state with proper capture handling 481 + const boardState = buildBoardState(moves, gameBoardSize); 482 + 483 + // Remove dead stones from board state for territory calculation 484 + for (const notation of deadStones) { 485 + const color = notation[0] === 'b' ? 'black' : 'white'; 486 + const col = notation.charCodeAt(1) - 65; 487 + const row = parseInt(notation.slice(2)) - 1; 488 + 489 + if (row >= 0 && row < gameBoardSize && col >= 0 && col < gameBoardSize) { 490 + if (boardState[row][col] === color) { 491 + boardState[row][col] = null; 492 + } 382 493 } 383 494 } 495 + 496 + // Calculate territory 497 + territoryData = calculateTerritory(boardState, gameBoardSize); 498 + 499 + // Set suggested scores 500 + suggestedScores = { 501 + blackScore: Math.round(score.black), 502 + whiteScore: Math.round(score.white), 503 + winner: score.winner, 504 + }; 505 + 506 + // Pre-populate the form with suggested values 507 + blackScore = Math.round(score.black); 508 + whiteScore = Math.round(score.white); 509 + 510 + console.log('[Game Page] Scores calculated:', suggestedScores); 384 511 } catch (err) { 385 - console.error('Failed to fetch suggested scores:', err); 512 + console.error('[Game Page] Failed to calculate scores:', err); 386 513 } finally { 387 514 loadingSuggestions = false; 388 515 } ··· 399 526 body: JSON.stringify({ 400 527 blackScore, 401 528 whiteScore, 529 + deadStones, 402 530 }), 403 531 }); 404 532 ··· 577 705 async (update) => { 578 706 if (update.type === 'move' && update.record) { 579 707 if (boardRef && update.record.x !== undefined && update.record.y !== undefined) { 708 + // DEDUPLICATION: Check if this move already exists 709 + const isDuplicateByUri = moveExistsByUri(update.uri, moves); 710 + const isDuplicateByCoords = moveExists({ 711 + moveNumber: update.record.moveNumber, 712 + x: update.record.x, 713 + y: update.record.y 714 + }, moves); 715 + 716 + if (isDuplicateByUri || isDuplicateByCoords) { 717 + console.log('Skipping duplicate move from Jetstream:', update.record.moveNumber); 718 + return; // Skip this update 719 + } 720 + 580 721 boardRef.playMove(update.record.x, update.record.y, update.record.color); 581 722 582 723 moves = [...moves, { ··· 589 730 color: update.record.color, 590 731 captureCount: update.record.captureCount || 0, 591 732 createdAt: update.record.createdAt, 733 + uri: update.uri, // Include URI from Jetstream 592 734 }]; 593 735 594 736 if (data.session && update.record.player !== data.session.did) { ··· 600 742 } 601 743 } else if (update.type === 'pass') { 602 744 if (update.record) { 745 + // Check for duplicate pass 746 + if (passExists({ moveNumber: update.record.moveNumber }, passes)) { 747 + console.log('Skipping duplicate pass from Jetstream:', update.record.moveNumber); 748 + return; 749 + } 750 + 603 751 passes = [...passes, { 604 752 $type: 'boo.sky.go.pass', 605 753 game: data.gameAtUri, ··· 780 928 </p> 781 929 <p><strong>Board:</strong> {gameBoardSize}x{gameBoardSize}</p> 782 930 <p><strong>Moves:</strong> {#if loadingMoves}<span class="skeleton-text">...</span>{:else}{moves.length}{/if}</p> 931 + {#if gameStatus === 'active' && !loadingMoves && moves.length > 0} 932 + <p class="last-move-indicator" class:stale={isStale(moves[moves.length - 1].createdAt, 2)}> 933 + <strong>Last move:</strong> {formatElapsedTime(moves[moves.length - 1].createdAt)} 934 + </p> 935 + {/if} 783 936 <a href={getShareUrl()} target="_blank" rel="noopener noreferrer" class="share-button-small"> 784 937 <svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"/></svg> 785 938 Share ··· 788 941 789 942 <div class="info-card cloud-card"> 790 943 <h3>Players</h3> 791 - <p> 792 - <span class="player-black">⚫</span> 793 - <a href="https://bsky.app/profile/{gamePlayerOne}" target="_blank" rel="noopener noreferrer" class="player-link"> 794 - {playerOneHandle} 795 - </a> 796 - {#if data.session && data.session.did === gamePlayerOne} 797 - <span class="you-label">(you)</span> 944 + <div class="players-container"> 945 + <!-- Player One (Black) --> 946 + <div class="player-section"> 947 + <a href="/profile/{gamePlayerOne}" class="player-avatar-link"> 948 + {#if playerOneProfile?.avatar} 949 + <img 950 + src={playerOneProfile.avatar} 951 + alt={playerOneHandle} 952 + class="player-avatar" 953 + style="border-color: {getPlayerBorderColor(playerOneCloudGoProfile?.status)}" 954 + /> 955 + {:else} 956 + <div 957 + class="player-avatar player-avatar-fallback" 958 + style="border-color: {getPlayerBorderColor(playerOneCloudGoProfile?.status)}" 959 + > 960 + {playerOneHandle.charAt(0).toUpperCase()} 961 + </div> 962 + {/if} 963 + </a> 964 + <div class="player-info"> 965 + <div class="player-name-row"> 966 + <span class="player-stone">⚫</span> 967 + <a href="/profile/{gamePlayerOne}" class="player-name"> 968 + {playerOneHandle} 969 + </a> 970 + {#if data.session && data.session.did === gamePlayerOne} 971 + <span class="you-label">(you)</span> 972 + {/if} 973 + <a 974 + href="https://bsky.app/profile/{gamePlayerOne}" 975 + target="_blank" 976 + rel="noopener noreferrer" 977 + class="bluesky-link" 978 + title="View on Bluesky" 979 + > 980 + <svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"/></svg> 981 + </a> 982 + </div> 983 + {#if playerOneCloudGoProfile?.ranking} 984 + <div 985 + class="player-ranking" 986 + style="color: {getRankingColor(playerOneCloudGoProfile.ranking, playerTwoCloudGoProfile?.ranking)}" 987 + > 988 + {playerOneCloudGoProfile.ranking} 989 + </div> 990 + {/if} 991 + </div> 992 + </div> 993 + 994 + <div class="players-divider"></div> 995 + 996 + <!-- Player Two (White) --> 997 + {#if gamePlayerTwo} 998 + <div class="player-section"> 999 + <a href="/profile/{gamePlayerTwo}" class="player-avatar-link"> 1000 + {#if playerTwoProfile?.avatar} 1001 + <img 1002 + src={playerTwoProfile.avatar} 1003 + alt={playerTwoHandle || gamePlayerTwo} 1004 + class="player-avatar" 1005 + style="border-color: {getPlayerBorderColor(playerTwoCloudGoProfile?.status)}" 1006 + /> 1007 + {:else} 1008 + <div 1009 + class="player-avatar player-avatar-fallback" 1010 + style="border-color: {getPlayerBorderColor(playerTwoCloudGoProfile?.status)}" 1011 + > 1012 + {(playerTwoHandle || gamePlayerTwo).charAt(0).toUpperCase()} 1013 + </div> 1014 + {/if} 1015 + </a> 1016 + <div class="player-info"> 1017 + <div class="player-name-row"> 1018 + <span class="player-stone">⚪</span> 1019 + <a href="/profile/{gamePlayerTwo}" class="player-name"> 1020 + {playerTwoHandle || gamePlayerTwo} 1021 + </a> 1022 + {#if data.session && data.session.did === gamePlayerTwo} 1023 + <span class="you-label">(you)</span> 1024 + {/if} 1025 + <a 1026 + href="https://bsky.app/profile/{gamePlayerTwo}" 1027 + target="_blank" 1028 + rel="noopener noreferrer" 1029 + class="bluesky-link" 1030 + title="View on Bluesky" 1031 + > 1032 + <svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"/></svg> 1033 + </a> 1034 + </div> 1035 + {#if playerTwoCloudGoProfile?.ranking} 1036 + <div 1037 + class="player-ranking" 1038 + style="color: {getRankingColor(playerTwoCloudGoProfile.ranking, playerOneCloudGoProfile?.ranking)}" 1039 + > 1040 + {playerTwoCloudGoProfile.ranking} 1041 + </div> 1042 + {/if} 1043 + </div> 1044 + </div> 1045 + {:else} 1046 + <div class="player-section waiting-section"> 1047 + <div class="player-avatar player-avatar-fallback waiting-avatar">?</div> 1048 + <p class="waiting">Waiting for opponent...</p> 1049 + </div> 798 1050 {/if} 799 - </p> 800 - {#if gamePlayerTwo} 801 - <p> 802 - <span class="player-white">⚪</span> 803 - <a href="https://bsky.app/profile/{gamePlayerTwo}" target="_blank" rel="noopener noreferrer" class="player-link"> 804 - {playerTwoHandle || gamePlayerTwo} 805 - </a> 806 - {#if data.session && data.session.did === gamePlayerTwo} 807 - <span class="you-label">(you)</span> 808 - {/if} 809 - </p> 810 - {:else} 811 - <p class="waiting">Waiting for opponent...</p> 812 - {/if} 1051 + </div> 813 1052 </div> 814 1053 815 1054 {#if gameStatus === 'completed' && !loadingMoves} ··· 888 1127 currentTurn={currentTurn()} 889 1128 territoryData={gameStatus === 'completed' && gameBlackScore === null ? territoryData : null} 890 1129 libertyData={beginnerMode ? libertyData : null} 1130 + deadStones={deadStones} 1131 + markingDeadStones={markingDeadStones} 1132 + onToggleDeadStone={handleToggleDeadStone} 891 1133 /> 892 1134 {/if} 893 1135 ··· 956 1198 </div> 957 1199 {/if} 958 1200 <form onsubmit={(e) => { e.preventDefault(); handleScoreSubmit(); }} class="score-form"> 1201 + <div class="dead-stone-controls"> 1202 + <button 1203 + type="button" 1204 + class="toggle-marking-button" 1205 + class:active={markingDeadStones} 1206 + onclick={() => markingDeadStones = !markingDeadStones} 1207 + > 1208 + {markingDeadStones ? '✓ Marking Dead Stones' : 'Mark Dead Stones'} 1209 + </button> 1210 + {#if deadStones.length > 0} 1211 + <span class="dead-stones-count"> 1212 + {deadStones.length} stone{deadStones.length !== 1 ? 's' : ''} marked 1213 + <button 1214 + type="button" 1215 + class="clear-dead-stones" 1216 + onclick={() => { deadStones = []; calculateSuggestedScores(); }} 1217 + > 1218 + Clear 1219 + </button> 1220 + </span> 1221 + {/if} 1222 + {#if markingDeadStones} 1223 + <p class="marking-instructions"> 1224 + Click stones on the board to mark them as dead 1225 + </p> 1226 + {/if} 1227 + </div> 1228 + 959 1229 <div class="score-input-group"> 960 1230 <label for="black-score"> 961 1231 <span class="player-black">⚫</span> Black Score: ··· 988 1258 <button type="submit" class="submit-score-button" disabled={isSubmitting}> 989 1259 {isSubmitting ? 'Submitting...' : 'Submit Scores'} 990 1260 </button> 991 - <button type="button" class="cancel-button" onclick={() => showScoreInput = false}> 1261 + <button type="button" class="cancel-button" onclick={() => { 1262 + showScoreInput = false; 1263 + markingDeadStones = false; 1264 + deadStones = []; 1265 + suggestedScores = null; 1266 + }}> 992 1267 Cancel 993 1268 </button> 994 1269 </div> 995 1270 </form> 996 1271 {/if} 997 1272 {:else} 998 - <button class="show-score-input-button" onclick={() => { showScoreInput = true; fetchSuggestedScores(); }}> 1273 + <button class="show-score-input-button" onclick={() => { showScoreInput = true; calculateSuggestedScores(); }}> 999 1274 Calculate & Enter Scores 1000 1275 </button> 1001 1276 {/if} ··· 1061 1336 <span class="move-number">#{move.moveNumber}</span> 1062 1337 <span class="move-coords"> 1063 1338 {move.color === 'black' ? '⚫' : '⚪'} 1064 - ({move.x}, {move.y}) 1339 + {formatMoveCoords(move.x, move.y)} 1065 1340 {#if move.captureCount > 0} 1066 1341 <span class="captures">+{move.captureCount}</span> 1067 1342 {/if} ··· 1502 1777 color: var(--sky-slate); 1503 1778 } 1504 1779 1780 + .last-move-indicator { 1781 + font-size: 0.875rem; 1782 + color: var(--sky-gray); 1783 + margin: 0.75rem 0; 1784 + } 1785 + 1786 + .last-move-indicator.stale { 1787 + color: var(--sky-apricot-dark); 1788 + font-weight: 600; 1789 + } 1790 + 1505 1791 .status-waiting { 1506 1792 color: var(--sky-apricot-dark); 1507 1793 font-weight: 600; ··· 1517 1803 font-weight: 600; 1518 1804 } 1519 1805 1520 - .player-black, .player-white { 1806 + .players-container { 1807 + display: flex; 1808 + gap: 1.5rem; 1809 + align-items: center; 1810 + justify-content: space-around; 1811 + } 1812 + 1813 + .player-section { 1814 + display: flex; 1815 + flex-direction: column; 1816 + align-items: center; 1817 + gap: 0.75rem; 1818 + flex: 1; 1819 + text-align: center; 1820 + } 1821 + 1822 + .waiting-section { 1823 + justify-content: center; 1824 + opacity: 0.6; 1825 + } 1826 + 1827 + .players-divider { 1828 + width: 1px; 1829 + height: 120px; 1830 + background: var(--sky-blue-pale); 1831 + align-self: center; 1832 + } 1833 + 1834 + @media (max-width: 768px) { 1835 + .players-container { 1836 + flex-direction: column; 1837 + gap: 1rem; 1838 + } 1839 + 1840 + .player-section { 1841 + flex-direction: row; 1842 + text-align: left; 1843 + gap: 1rem; 1844 + } 1845 + 1846 + .players-divider { 1847 + width: 100%; 1848 + height: 1px !important; 1849 + } 1850 + } 1851 + 1852 + .player-avatar-link { 1853 + flex-shrink: 0; 1854 + } 1855 + 1856 + .player-avatar { 1857 + width: 80px; 1858 + height: 80px; 1859 + border-radius: 50%; 1860 + border: 4px solid; 1861 + object-fit: cover; 1862 + transition: transform 0.2s, box-shadow 0.2s; 1863 + cursor: pointer; 1864 + } 1865 + 1866 + .player-avatar:hover { 1867 + transform: scale(1.05); 1868 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 1869 + } 1870 + 1871 + .player-avatar-fallback { 1872 + background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); 1873 + display: flex; 1874 + align-items: center; 1875 + justify-content: center; 1876 + font-weight: 700; 1877 + color: var(--sky-slate-dark); 1878 + font-size: 2rem; 1879 + } 1880 + 1881 + .waiting-avatar { 1882 + background: var(--sky-blue-pale); 1883 + border-color: var(--sky-gray-light) !important; 1884 + color: var(--sky-gray); 1885 + } 1886 + 1887 + .player-info { 1888 + display: flex; 1889 + flex-direction: column; 1890 + gap: 0.25rem; 1891 + min-width: 0; 1892 + align-items: center; 1893 + } 1894 + 1895 + .player-name-row { 1896 + display: flex; 1897 + align-items: center; 1898 + gap: 0.5rem; 1899 + flex-wrap: wrap; 1900 + justify-content: center; 1901 + } 1902 + 1903 + @media (max-width: 768px) { 1904 + .player-info { 1905 + align-items: flex-start; 1906 + } 1907 + 1908 + .player-name-row { 1909 + justify-content: flex-start; 1910 + } 1911 + } 1912 + 1913 + .player-stone { 1521 1914 font-size: 1.25rem; 1915 + flex-shrink: 0; 1522 1916 } 1523 1917 1524 - .player-link { 1525 - color: var(--sky-slate); 1918 + .player-name { 1919 + color: var(--sky-slate-dark); 1526 1920 text-decoration: none; 1527 - font-weight: 500; 1921 + font-weight: 600; 1922 + font-size: 1rem; 1528 1923 transition: color 0.2s; 1529 1924 } 1530 1925 1531 - .player-link:hover { 1926 + .player-name:hover { 1532 1927 color: var(--sky-apricot-dark); 1533 1928 } 1534 1929 1535 1930 .you-label { 1536 1931 color: var(--sky-gray); 1537 1932 font-size: 0.875rem; 1538 - margin-left: 0.25rem; 1933 + } 1934 + 1935 + .bluesky-link { 1936 + color: #0085ff; 1937 + display: inline-flex; 1938 + align-items: center; 1939 + transition: all 0.2s; 1940 + text-decoration: none; 1941 + } 1942 + 1943 + .bluesky-link:hover { 1944 + color: #0066cc; 1945 + transform: scale(1.1); 1946 + } 1947 + 1948 + .bluesky-link svg { 1949 + width: 16px; 1950 + height: 16px; 1951 + } 1952 + 1953 + .player-ranking { 1954 + font-size: 1.25rem; 1955 + font-weight: 700; 1956 + } 1957 + 1958 + @media (max-width: 768px) { 1959 + .player-ranking { 1960 + margin-left: 1.75rem; 1961 + } 1539 1962 } 1540 1963 1541 1964 .waiting { 1542 1965 color: var(--sky-gray-light); 1543 1966 font-style: italic; 1967 + margin: 0; 1544 1968 } 1545 1969 1546 1970 .beginner-mode-label { ··· 1812 2236 background: var(--sky-gray-light); 1813 2237 cursor: not-allowed; 1814 2238 box-shadow: none; 2239 + } 2240 + 2241 + .dead-stone-controls { 2242 + margin-bottom: 1.5rem; 2243 + padding: 1rem; 2244 + background: rgba(229, 168, 120, 0.05); 2245 + border-radius: 0.5rem; 2246 + border: 1px solid rgba(229, 168, 120, 0.2); 2247 + } 2248 + 2249 + .toggle-marking-button { 2250 + padding: 0.75rem 1.25rem; 2251 + font-size: 0.95rem; 2252 + background: white; 2253 + color: var(--sky-apricot-dark); 2254 + border: 2px solid var(--sky-apricot); 2255 + border-radius: 0.5rem; 2256 + cursor: pointer; 2257 + transition: all 0.2s; 2258 + font-weight: 600; 2259 + } 2260 + 2261 + .toggle-marking-button:hover { 2262 + background: var(--sky-apricot-light); 2263 + transform: translateY(-1px); 2264 + } 2265 + 2266 + .toggle-marking-button.active { 2267 + background: var(--sky-apricot); 2268 + color: white; 2269 + } 2270 + 2271 + .dead-stones-count { 2272 + display: inline-flex; 2273 + align-items: center; 2274 + gap: 0.5rem; 2275 + margin-left: 1rem; 2276 + font-size: 0.9rem; 2277 + color: var(--sky-gray-dark); 2278 + } 2279 + 2280 + .clear-dead-stones { 2281 + padding: 0.25rem 0.5rem; 2282 + font-size: 0.8rem; 2283 + background: var(--sky-gray-light); 2284 + color: var(--sky-gray-dark); 2285 + border: none; 2286 + border-radius: 0.25rem; 2287 + cursor: pointer; 2288 + transition: all 0.2s; 2289 + } 2290 + 2291 + .clear-dead-stones:hover { 2292 + background: var(--sky-gray); 2293 + color: white; 2294 + } 2295 + 2296 + .marking-instructions { 2297 + margin-top: 0.75rem; 2298 + margin-bottom: 0; 2299 + font-size: 0.85rem; 2300 + color: var(--sky-gray-dark); 2301 + font-style: italic; 1815 2302 } 1816 2303 1817 2304 .cancel-button {
+162 -7
src/routes/profile/[did]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 - import { fetchUserProfile, resolveDidToHandle, fetchMoveCount, fetchGameRecord, type UserProfile } from '$lib/atproto-client'; 4 + import { fetchUserProfile, resolveDidToHandle, fetchMoveCount, fetchGameRecord, fetchCloudGoProfile, type UserProfile } from '$lib/atproto-client'; 5 + import type { ProfileRecord } from '$lib/types'; 5 6 6 7 let { data }: { data: PageData } = $props(); 7 8 8 9 let profile: UserProfile | null = $state(null); 10 + let cloudGoProfile: ProfileRecord | null = $state(null); 9 11 let handles = $state<Record<string, string>>({}); 10 12 let moveCounts = $state<Record<string, number | null>>({}); 11 13 let opponentProfiles = $state<Record<string, UserProfile | null>>({}); 12 14 let gameWinners = $state<Record<string, string | null>>({}); 13 15 let archivePage = $state(1); 16 + let editingStatus = $state(false); 17 + let selectedStatus = $state<'playing' | 'watching' | 'offline'>('offline'); 14 18 const ARCHIVE_PAGE_SIZE = 6; 19 + 20 + const isOwnProfile = $derived(data.session?.did === data.profileDid); 15 21 16 22 interface OpponentHistory { 17 23 did: string; ··· 146 152 async function loadProfileData() { 147 153 // Reset state 148 154 profile = null; 155 + cloudGoProfile = null; 149 156 handles = {}; 150 157 moveCounts = {}; 151 158 opponentProfiles = {}; ··· 154 161 155 162 // Fetch profile 156 163 profile = await fetchUserProfile(data.profileDid); 164 + cloudGoProfile = await fetchCloudGoProfile(data.profileDid); 165 + if (cloudGoProfile) { 166 + selectedStatus = cloudGoProfile.status; 167 + } 157 168 158 169 if (data.games && data.games.length > 0) { 159 170 // Collect unique DIDs ··· 202 213 loadProfileData(); 203 214 }); 204 215 216 + async function updateStatus() { 217 + try { 218 + const response = await fetch('/api/profile', { 219 + method: 'POST', 220 + headers: { 'Content-Type': 'application/json' }, 221 + body: JSON.stringify({ status: selectedStatus }), 222 + }); 223 + 224 + if (!response.ok) { 225 + throw new Error('Failed to update status'); 226 + } 227 + 228 + cloudGoProfile = await fetchCloudGoProfile(data.profileDid); 229 + editingStatus = false; 230 + } catch (err) { 231 + console.error('Failed to update status:', err); 232 + alert('Failed to update status'); 233 + } 234 + } 235 + 236 + function getStatusColor(status: string | undefined): string { 237 + switch (status) { 238 + case 'playing': return '#059669'; 239 + case 'watching': return '#eab308'; 240 + case 'offline': return '#6b7280'; 241 + default: return '#6b7280'; 242 + } 243 + } 244 + 245 + function getStatusLabel(status: string | undefined): string { 246 + switch (status) { 247 + case 'playing': return 'Playing'; 248 + case 'watching': return 'Watching'; 249 + case 'offline': return 'Offline'; 250 + default: return 'Unknown'; 251 + } 252 + } 253 + 205 254 onMount(() => { 206 255 loadProfileData(); 207 256 }); ··· 223 272 </div> 224 273 {/if} 225 274 <div class="profile-details"> 226 - <h1 class="profile-name">{profile?.displayName || profile?.handle || data.profileDid}</h1> 275 + <div class="profile-name-row"> 276 + <h1 class="profile-name">{profile?.displayName || profile?.handle || data.profileDid}</h1> 277 + {#if cloudGoProfile} 278 + <span class="status-badge" style="background-color: {getStatusColor(cloudGoProfile.status)}"> 279 + {getStatusLabel(cloudGoProfile.status)} 280 + </span> 281 + {/if} 282 + </div> 227 283 {#if profile?.handle} 228 284 <p class="profile-handle">@{profile.handle}</p> 229 285 {/if} 230 286 {#if profile?.description} 231 287 <p class="profile-description">{profile.description}</p> 232 288 {/if} 289 + {#if isOwnProfile} 290 + {#if editingStatus} 291 + <div class="status-editor"> 292 + <select bind:value={selectedStatus} class="status-select"> 293 + <option value="playing">Playing</option> 294 + <option value="watching">Watching</option> 295 + <option value="offline">Offline</option> 296 + </select> 297 + <button onclick={updateStatus} class="button button-primary button-xs">Save</button> 298 + <button onclick={() => editingStatus = false} class="button button-secondary button-xs">Cancel</button> 299 + </div> 300 + {:else} 301 + <button onclick={() => editingStatus = true} class="button button-secondary button-xs edit-status-btn"> 302 + Edit Status 303 + </button> 304 + {/if} 305 + {/if} 233 306 </div> 234 307 </div> 235 308 <div class="profile-stats"> ··· 253 326 <div class="stat-value">{losses}</div> 254 327 <div class="stat-label">Losses</div> 255 328 </div> 329 + {#if cloudGoProfile} 330 + <div class="stat stat-ranking"> 331 + <div class="stat-value">{cloudGoProfile.ranking}</div> 332 + <div class="stat-label">Ranking</div> 333 + </div> 334 + {/if} 256 335 </div> 257 336 </div> 258 337 ··· 398 477 {#each paginatedArchivedGames as game} 399 478 {@const resignedBy = getResignedBy(game)} 400 479 {@const opponentDid = getOpponentDid(game)} 401 - <a href="/game/{game.rkey}" class="archive-card"> 480 + {@const userWon = didUserWin(game)} 481 + <a href="/game/{game.rkey}" class="archive-card" class:won={userWon === true} class:lost={userWon === false}> 402 482 <img 403 483 src="/api/games/{game.rkey}/board?size=70" 404 484 alt="Board preview" ··· 500 580 flex: 1; 501 581 } 502 582 583 + .profile-name-row { 584 + display: flex; 585 + align-items: center; 586 + gap: 0.75rem; 587 + flex-wrap: wrap; 588 + } 589 + 503 590 .profile-name { 504 - font-size: 2rem; 591 + font-size: clamp(1.25rem, 5vw, 2rem); 505 592 font-weight: 700; 506 593 color: var(--sky-slate-dark); 507 594 margin: 0; 508 595 } 509 596 597 + .status-badge { 598 + display: inline-block; 599 + padding: 0.25rem 0.75rem; 600 + border-radius: 9999px; 601 + font-size: 0.75rem; 602 + font-weight: 600; 603 + color: white; 604 + text-transform: uppercase; 605 + letter-spacing: 0.05em; 606 + } 607 + 608 + .status-editor { 609 + display: flex; 610 + gap: 0.5rem; 611 + align-items: center; 612 + margin-top: 0.75rem; 613 + } 614 + 615 + .status-select { 616 + padding: 0.375rem 0.75rem; 617 + border: 2px solid var(--sky-blue-pale); 618 + border-radius: 0.375rem; 619 + font-size: 0.875rem; 620 + background: var(--sky-white); 621 + color: var(--sky-slate-dark); 622 + } 623 + 624 + .status-select:focus { 625 + outline: none; 626 + border-color: var(--sky-apricot); 627 + } 628 + 629 + .edit-status-btn { 630 + margin-top: 0.5rem; 631 + } 632 + 633 + .button-xs { 634 + padding: 0.375rem 0.75rem; 635 + font-size: 0.75rem; 636 + } 637 + 510 638 .profile-handle { 511 639 color: var(--sky-gray); 512 640 font-size: 1rem; ··· 521 649 522 650 .profile-stats { 523 651 display: flex; 524 - gap: 2rem; 652 + flex-wrap: wrap; 653 + gap: clamp(0.75rem, 3vw, 2rem); 525 654 padding-top: 1rem; 526 655 border-top: 1px solid var(--sky-blue-pale); 527 656 } 528 657 529 658 .stat { 530 659 text-align: center; 660 + flex: 1 1 auto; 661 + min-width: 0; 531 662 } 532 663 533 664 .stat-value { 534 - font-size: 1.75rem; 665 + font-size: clamp(1.25rem, 4vw, 1.75rem); 535 666 font-weight: 700; 536 667 color: var(--sky-slate-dark); 537 668 } 538 669 539 670 .stat-label { 540 - font-size: 0.85rem; 671 + font-size: clamp(0.65rem, 2vw, 0.85rem); 541 672 color: var(--sky-gray); 542 673 text-transform: uppercase; 543 674 letter-spacing: 0.05em; ··· 549 680 550 681 .stat-losses .stat-value { 551 682 color: #dc2626; 683 + } 684 + 685 + .stat-ranking .stat-value { 686 + color: #ca8a04; 552 687 } 553 688 554 689 .card { ··· 784 919 border-color: var(--sky-apricot); 785 920 box-shadow: 0 4px 12px rgba(90, 122, 144, 0.1); 786 921 transform: translateY(-2px) rotate(0deg); 922 + } 923 + 924 + .archive-card.won { 925 + border-color: #059669; 926 + border-width: 2px; 927 + } 928 + 929 + .archive-card.won:hover { 930 + border-color: #047857; 931 + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.2); 932 + } 933 + 934 + .archive-card.lost { 935 + border-color: #dc2626; 936 + border-width: 2px; 937 + } 938 + 939 + .archive-card.lost:hover { 940 + border-color: #b91c1c; 941 + box-shadow: 0 4px 12px rgba(220, 38, 38, 0.2); 787 942 } 788 943 789 944 .archive-board-img {