From 44aa32600060e06cf58cbdd9bcbbb98f0ef4e331 Mon Sep 17 00:00:00 2001 From: Business Goose Date: Wed, 28 Jan 2026 23:36:01 -0800 Subject: [PATCH] Replace tenuki mini boards with server-rendered SVGs - Created shared board-svg.ts utility for generating board SVGs - Added /api/games/[id]/board endpoint that returns SVG images - Mini boards now use simple tags pointing to the SVG endpoint - SVGs are cached (30s for active games, 1hr for completed) - Removed client-side MiniBoard.svelte component - Refactored og-image to use shared buildBoardStateFromMoves This approach is: - Simpler (just img tags) - More performant (server-side rendering, caching) - Scalable (SVG format) - Consistent with OG image styling --- src/lib/components/MiniBoard.svelte | 92 ------------ src/lib/server/board-svg.ts | 160 +++++++++++++++++++++ src/routes/+page.svelte | 95 ++---------- src/routes/api/games/[id]/board/+server.ts | 67 +++++++++ src/routes/og-image/[id]/+server.ts | 35 +---- 5 files changed, 247 insertions(+), 202 deletions(-) delete mode 100644 src/lib/components/MiniBoard.svelte create mode 100644 src/lib/server/board-svg.ts create mode 100644 src/routes/api/games/[id]/board/+server.ts diff --git a/src/lib/components/MiniBoard.svelte b/src/lib/components/MiniBoard.svelte deleted file mode 100644 index 3e43769..0000000 --- a/src/lib/components/MiniBoard.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
-
-
- - diff --git a/src/lib/server/board-svg.ts b/src/lib/server/board-svg.ts new file mode 100644 index 0000000..bed0eac --- /dev/null +++ b/src/lib/server/board-svg.ts @@ -0,0 +1,160 @@ +/** + * Generate an SVG representation of a Go board with stones. + */ + +export interface BoardSvgOptions { + size?: number; + padding?: number; + showLastMove?: boolean; + backgroundColor?: string; +} + +export function generateBoardSvg( + boardSize: number, + boardState: Array>, + lastMove: { x: number; y: number } | null = null, + options: BoardSvgOptions = {} +): string { + const { + size: svgSize = 200, + padding = 10, + showLastMove = true, + backgroundColor = '#DEB887', + } = options; + + const innerSize = svgSize - padding * 2; + const step = innerSize / (boardSize - 1); + const stoneRadius = step * 0.45; + + // Generate unique IDs for gradients to avoid conflicts when multiple boards on page + const gradientSuffix = Math.random().toString(36).slice(2, 8); + + let svg = ``; + + // Defs for gradients + svg += ` + + + + + + + + + + + + + + `; + + // Board background + svg += ``; + + // Grid lines + for (let i = 0; i < boardSize; i++) { + const pos = padding + i * step; + // Horizontal lines + svg += ``; + // Vertical lines + svg += ``; + } + + // Star points (hoshi) + const starPoints = getStarPoints(boardSize); + const hoshiRadius = Math.max(1.5, step * 0.08); + for (const [x, y] of starPoints) { + const px = padding + x * step; + const py = padding + y * step; + svg += ``; + } + + // Stones + for (let y = 0; y < boardState.length; y++) { + for (let x = 0; x < (boardState[y]?.length || 0); x++) { + const stone = boardState[y][x]; + if (stone) { + const px = padding + x * step; + const py = padding + y * step; + const isLastMove = showLastMove && lastMove && lastMove.x === x && lastMove.y === y; + + if (stone === 'black') { + svg += ``; + if (isLastMove) { + svg += ``; + } + } else { + svg += ``; + if (isLastMove) { + svg += ``; + } + } + } + } + } + + svg += ''; + return svg; +} + +function getStarPoints(boardSize: number): Array<[number, number]> { + if (boardSize === 9) { + return [ + [2, 2], [6, 2], + [4, 4], + [2, 6], [6, 6], + ]; + } else if (boardSize === 13) { + return [ + [3, 3], [9, 3], + [6, 6], + [3, 9], [9, 9], + ]; + } else if (boardSize === 19) { + return [ + [3, 3], [9, 3], [15, 3], + [3, 9], [9, 9], [15, 9], + [3, 15], [9, 15], [15, 15], + ]; + } + return []; +} + +/** + * Build board state from moves using tenuki for proper capture handling. + */ +export async function buildBoardStateFromMoves( + moves: Array<{ x: number; y: number; color: 'black' | 'white' }>, + boardSize: number +): Promise<{ boardState: Array>; lastMove: { x: number; y: number } | null }> { + const tenuki = await import('tenuki'); + const game = new tenuki.default.Game({ boardSize }); + + let lastMove: { x: number; y: number } | null = null; + + for (const move of moves) { + try { + game.playAt(move.y, move.x); + lastMove = { x: move.x, y: move.y }; + } catch { + // Move might be invalid, skip + } + } + + // Extract board state + const boardState: Array> = Array.from( + { length: boardSize }, + () => Array.from({ length: boardSize }, () => null) + ); + + for (let y = 0; y < boardSize; y++) { + for (let x = 0; x < boardSize; x++) { + const intersection = game.intersectionAt(y, x); + if (intersection.value === 'black' || intersection.value === 'white') { + boardState[y][x] = intersection.value; + } + } + } + + return { boardState, lastMove }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0ab480b..de9aaf3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,7 @@ @@ -262,15 +225,12 @@
{#each currentGames as game}
- {#if gameMoves[game.id]} -
- -
- {:else} -
- ... -
- {/if} + Board preview
{game.title}
@@ -428,15 +388,12 @@ {@const myTurn = isMyTurn(game)} {@const playing = isMyGame(game)}
- {#if gameMoves[game.id]} -
- -
- {:else} -
- ... -
- {/if} + Board preview
{game.title} @@ -829,39 +786,15 @@ background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-white) 100%); } - .mini-board-wrapper { + .mini-board-img { flex-shrink: 0; width: 70px; height: 70px; border-radius: 6px; - overflow: hidden; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); border: 1px solid var(--sky-blue-pale); } - .mini-board-placeholder { - flex-shrink: 0; - width: 70px; - height: 70px; - border-radius: 6px; - background: linear-gradient(135deg, #DEB887 0%, #C4A067 100%); - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--sky-blue-pale); - } - - .mini-board-loading { - color: rgba(92, 72, 48, 0.5); - font-size: 0.75rem; - animation: pulse 1.5s ease-in-out infinite; - } - - @keyframes pulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } - } - .game-info { display: flex; flex-direction: column; diff --git a/src/routes/api/games/[id]/board/+server.ts b/src/routes/api/games/[id]/board/+server.ts new file mode 100644 index 0000000..1da7036 --- /dev/null +++ b/src/routes/api/games/[id]/board/+server.ts @@ -0,0 +1,67 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { fetchGameActionsFromPds } from '$lib/atproto-client'; +import { generateBoardSvg, buildBoardStateFromMoves } from '$lib/server/board-svg'; + +/** + * GET: Return an SVG representation of the current board state. + * Supports query params: + * - size: SVG size in pixels (default 100) + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { id: rkey } = params; + const size = Math.min(500, Math.max(50, parseInt(url.searchParams.get('size') || '100', 10))); + + const db = getDb(); + const game = await db + .selectFrom('games') + .selectAll() + .where('rkey', '=', rkey) + .executeTakeFirst(); + + if (!game) { + throw error(404, 'Game not found'); + } + + // Fetch moves + let boardState: Array> = Array.from( + { length: game.board_size }, + () => Array.from({ length: game.board_size }, () => null) + ); + let lastMove: { x: number; y: number } | null = null; + + try { + const { moves } = await fetchGameActionsFromPds( + game.player_one, + game.player_two, + game.id + ); + + if (moves.length > 0) { + const result = await buildBoardStateFromMoves( + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), + game.board_size + ); + boardState = result.boardState; + lastMove = result.lastMove; + } + } catch (err) { + console.error('Failed to fetch moves for board SVG:', err); + // Continue with empty board + } + + const svg = generateBoardSvg(game.board_size, boardState, lastMove, { size }); + + // Cache based on game status + // Active games: short cache (30 seconds) + // Completed games: longer cache (1 hour) + const maxAge = game.status === 'completed' ? 3600 : 30; + + return new Response(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': `public, max-age=${maxAge}`, + }, + }); +}; diff --git a/src/routes/og-image/[id]/+server.ts b/src/routes/og-image/[id]/+server.ts index 9acf314..beb80ca 100644 --- a/src/routes/og-image/[id]/+server.ts +++ b/src/routes/og-image/[id]/+server.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { gameTitle } from '$lib/game-titles'; import { resolveDidToHandle, fetchGameActionsFromPds } from '$lib/atproto-client'; -import tenuki from 'tenuki'; +import { buildBoardStateFromMoves } from '$lib/server/board-svg'; import { Resvg } from '@resvg/resvg-js'; export const GET: RequestHandler = async ({ params }) => { @@ -48,35 +48,12 @@ export const GET: RequestHandler = async ({ params }) => { console.log(`OG image: Found ${moves.length} moves for game ${gameRkey}`); if (moves.length > 0) { - // Create a tenuki game and replay moves - const tenukiGame = new tenuki.Game({ boardSize }); - - for (const move of moves) { - try { - // tenuki uses (y, x) format where y is row and x is column - const result = tenukiGame.playAt(move.y, move.x); - console.log(`OG image: Playing move at (${move.x}, ${move.y}) color=${move.color}, result=${result}`); - lastMove = { x: move.x, y: move.y }; - } catch (moveErr) { - console.error(`OG image: Failed to play move at (${move.x}, ${move.y}):`, moveErr); - } - } - - // Extract board state from tenuki using intersectionAt - // Initialize empty 2D board - boardState = Array.from({ length: boardSize }, () => - Array.from({ length: boardSize }, () => null as 'black' | 'white' | null) + const result = await buildBoardStateFromMoves( + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), + boardSize ); - - // Read each intersection - for (let y = 0; y < boardSize; y++) { - for (let x = 0; x < boardSize; x++) { - const intersection = tenukiGame.intersectionAt(y, x); - if (intersection.value === 'black' || intersection.value === 'white') { - boardState[y][x] = intersection.value; - } - } - } + boardState = result.boardState; + lastMove = result.lastMove; console.log(`OG image: Extracted board state, stones:`, boardState.flat().filter(s => s !== null).length); } -- 2.51.2