diff --git a/src/lib/bingo.ts b/src/lib/bingo.ts index 4345867..b05e119 100644 --- a/src/lib/bingo.ts +++ b/src/lib/bingo.ts @@ -1,42 +1,51 @@ -export const MAX_GRID_SIZE = 6; export const GRID_SIZE = 5; -if (GRID_SIZE > MAX_GRID_SIZE) { - throw new Error( - `GRID_SIZE (${GRID_SIZE}) exceeds MAX_GRID_SIZE (${MAX_GRID_SIZE}). Cards are capped at 6x6.` - ); +export function effectivePoolSize(tiles: { isFreeSpace: boolean }[]): number { + const extraFreeSpaces = Math.max(0, tiles.filter((t) => t.isFreeSpace).length - 1); + return tiles.length - extraFreeSpaces; } -// All 12 winning lines on a 5x5 board, expressed as tile positions (0..24). -export const WIN_LINES: number[][] = (() => { +export function getCardSize(tileCount: number): number { + let size = 5; + while ((size + 2) ** 2 <= tileCount) size += 2; + return size; +} + +export function getWinLines(gridSize: number): number[][] { const lines: number[][] = []; - for (let r = 0; r < GRID_SIZE; r++) { - lines.push(Array.from({ length: GRID_SIZE }, (_, c) => r * GRID_SIZE + c)); + for (let r = 0; r < gridSize; r++) { + lines.push(Array.from({ length: gridSize }, (_, c) => r * gridSize + c)); } - for (let c = 0; c < GRID_SIZE; c++) { - lines.push(Array.from({ length: GRID_SIZE }, (_, r) => r * GRID_SIZE + c)); + for (let c = 0; c < gridSize; c++) { + lines.push(Array.from({ length: gridSize }, (_, r) => r * gridSize + c)); } - lines.push(Array.from({ length: GRID_SIZE }, (_, i) => i * GRID_SIZE + i)); - lines.push(Array.from({ length: GRID_SIZE }, (_, i) => i * GRID_SIZE + (GRID_SIZE - 1 - i))); + lines.push(Array.from({ length: gridSize }, (_, i) => i * gridSize + i)); + lines.push(Array.from({ length: gridSize }, (_, i) => i * gridSize + (gridSize - 1 - i))); return lines; -})(); +} + +export const WIN_LINES = getWinLines(GRID_SIZE); -export function detectBingo(completedPositions: Set): { +export function detectBingo( + completedPositions: Set, + gridSize: number = GRID_SIZE +): { hasBingo: boolean; winningLines: number[][]; winningPositions: Set; } { - const winningLines = WIN_LINES.filter((line) => line.every((p) => completedPositions.has(p))); + const winLines = getWinLines(gridSize); + const winningLines = winLines.filter((line) => line.every((p) => completedPositions.has(p))); const winningPositions = new Set(winningLines.flat()); return { hasBingo: winningLines.length > 0, winningLines, winningPositions }; } -export function describeWinLine(line: number[]): string { - const rows = line.map((p) => Math.floor(p / GRID_SIZE)); - const cols = line.map((p) => p % GRID_SIZE); +export function describeWinLine(line: number[], gridSize: number = GRID_SIZE): string { + const rows = line.map((p) => Math.floor(p / gridSize)); + const cols = line.map((p) => p % gridSize); if (rows.every((r) => r === rows[0])) return `Row ${rows[0] + 1}`; if (cols.every((c) => c === cols[0])) return `Column ${cols[0] + 1}`; - if (line.every((p, i) => p === i * GRID_SIZE + i)) return 'Diagonal ↘'; + if (line.every((p, i) => p === i * gridSize + i)) return 'Diagonal ↘'; return 'Diagonal ↗'; } @@ -47,14 +56,15 @@ export function describeWinLine(line: number[]): string { */ export function bingoWinTransition( before: Set, - after: Set + after: Set, + gridSize: number = GRID_SIZE ): { justWon: boolean; lineLabel: string | null } { - const beforeRes = detectBingo(before); - const afterRes = detectBingo(after); + const beforeRes = detectBingo(before, gridSize); + const afterRes = detectBingo(after, gridSize); if (!afterRes.hasBingo || beforeRes.hasBingo) { return { justWon: false, lineLabel: null }; } const beforeKeys = new Set(beforeRes.winningLines.map((l) => l.join(','))); const newLine = afterRes.winningLines.find((l) => !beforeKeys.has(l.join(','))); - return { justWon: true, lineLabel: newLine ? describeWinLine(newLine) : null }; + return { justWon: true, lineLabel: newLine ? describeWinLine(newLine, gridSize) : null }; } diff --git a/src/routes/admin/tiles/+page.server.ts b/src/routes/admin/tiles/+page.server.ts index 5defbe4..6dd118c 100644 --- a/src/routes/admin/tiles/+page.server.ts +++ b/src/routes/admin/tiles/+page.server.ts @@ -3,22 +3,22 @@ import { eq, sql } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { db } from '$lib/server/db'; import { bingoTile } from '$lib/server/db/schema'; -import { GRID_SIZE } from '$lib/bingo'; +import { getCardSize, effectivePoolSize } from '$lib/bingo'; import { isAdmin } from '$lib/server/admin'; import { logActivity } from '$lib/server/activity'; import type { Actions, PageServerLoad } from './$types'; -const TARGET_TILES = GRID_SIZE * GRID_SIZE; - export const load: PageServerLoad = async () => { const tiles = await db .select({ id: bingoTile.id, label: bingoTile.label, position: bingoTile.position, isFreeSpace: bingoTile.isFreeSpace, isActive: bingoTile.isActive }) .from(bingoTile) .orderBy(bingoTile.position); + const gridSize = getCardSize(effectivePoolSize(tiles)); + const target = gridSize * gridSize; return { tiles, - target: TARGET_TILES, - gridSize: GRID_SIZE + target, + gridSize }; }; @@ -114,15 +114,16 @@ export const actions: Actions = { .select({ id: bingoTile.id, position: bingoTile.position }) .from(bingoTile); const total = existing.length + labels.length; + const minTarget = 25; - if (total < TARGET_TILES) { - const short = TARGET_TILES - total; + if (total < minTarget) { + const short = minTarget - total; return fail(400, { form: 'bulkAdd', - message: `Upload would result in ${total} tiles, short of the ${TARGET_TILES} (${GRID_SIZE}×${GRID_SIZE}) needed for a complete card. Add ${short} more row${short === 1 ? '' : 's'} to your CSV.`, + message: `Upload would result in ${total} tiles, short of the ${minTarget} needed for a complete card. Add ${short} more row${short === 1 ? '' : 's'} to your CSV.`, existing: existing.length, incoming: labels.length, - target: TARGET_TILES + target: minTarget }); } diff --git a/src/routes/admin/tiles/+page.svelte b/src/routes/admin/tiles/+page.svelte index b6e9a48..575e454 100644 --- a/src/routes/admin/tiles/+page.svelte +++ b/src/routes/admin/tiles/+page.svelte @@ -84,8 +84,8 @@

- One label per line. Will be appended to existing tiles. Total must equal exactly {data.target} - ({data.gridSize}×{data.gridSize}) after upload — partial cards are rejected. + One label per line. Will be appended to existing tiles. Total must reach at least 25 after + upload — partial cards are rejected.

{#if bulkError}

{bulkError}

diff --git a/src/routes/bingo/+page.server.ts b/src/routes/bingo/+page.server.ts index 5f5b7f3..05f44a4 100644 --- a/src/routes/bingo/+page.server.ts +++ b/src/routes/bingo/+page.server.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'; import { db } from '$lib/server/db'; import { bingoProgress, bingoTile, user } from '$lib/server/db/schema'; import { sql } from 'drizzle-orm'; -import { detectBingo, bingoWinTransition } from '$lib/bingo'; +import { detectBingo, bingoWinTransition, getCardSize, effectivePoolSize } from '$lib/bingo'; import { shuffleTilesForUser } from '$lib/server/cardShuffle'; import type { Actions, PageServerLoad } from './$types'; import { logActivity } from '$lib/server/activity'; @@ -25,6 +25,7 @@ async function resetUserBoard(userId: string, regenerateSeed: boolean): Promise< async function boardPositions(userId: string): Promise<{ ordered: { id: string; isFreeSpace: boolean }[]; positions: Set; + cardSize: number; }> { const tiles = await db .select({ id: bingoTile.id, position: bingoTile.position, isFreeSpace: bingoTile.isFreeSpace }) @@ -40,12 +41,13 @@ async function boardPositions(userId: string): Promise<{ .from(bingoProgress) .where(eq(bingoProgress.userId, userId)); const completed = new Set(progress.map((p) => p.tileId)); - const ordered = shuffleTilesForUser(tiles, u?.cardSeed ?? null); + const cardSize = getCardSize(effectivePoolSize(tiles)); + const ordered = shuffleTilesForUser(tiles, u?.cardSeed ?? null, cardSize); const positions = new Set(); ordered.forEach((t, idx) => { if (completed.has(t.id) || t.isFreeSpace) positions.add(idx); }); - return { ordered, positions }; + return { ordered, positions, cardSize }; } async function ensureCardSeed(userId: string, existing: string | null): Promise { @@ -78,8 +80,9 @@ export const load: PageServerLoad = async ({ locals }) => { .where(eq(user.id, locals.user.id)) .limit(1); + const cardSize = getCardSize(effectivePoolSize(tiles)); const seed = await ensureCardSeed(locals.user.id, me?.cardSeed ?? null); - const ordered = shuffleTilesForUser(tiles, seed); + const ordered = shuffleTilesForUser(tiles, seed, cardSize); const completed = new Set(progress.map((p) => p.tileId)); @@ -87,7 +90,7 @@ export const load: PageServerLoad = async ({ locals }) => { ordered.forEach((t, idx) => { if (completed.has(t.id) || t.isFreeSpace) completedPositions.add(idx); }); - const { hasBingo, winningPositions } = detectBingo(completedPositions); + const { hasBingo, winningPositions } = detectBingo(completedPositions, cardSize); return { tiles: ordered.map((t, idx) => ({ @@ -96,6 +99,8 @@ export const load: PageServerLoad = async ({ locals }) => { winning: winningPositions.has(idx) })), hasBingo, + gridSize: cardSize, + tooFewTiles: tiles.length < 25, verifiedAt: me?.bingoVerifiedAt ?? null, verifiedBy: me?.bingoVerifiedBy ?? null }; @@ -140,12 +145,12 @@ export const actions: Actions = { await logActivity({ userId: locals.user.id, type: 'tile_complete', detail: tile.label }); - const { ordered, positions } = await boardPositions(locals.user.id); + const { ordered, positions, cardSize } = await boardPositions(locals.user.id); const toggledIdx = ordered.findIndex((t) => t.id === tileId); if (toggledIdx >= 0) { const before = new Set(positions); before.delete(toggledIdx); - const { justWon, lineLabel } = bingoWinTransition(before, positions); + const { justWon, lineLabel } = bingoWinTransition(before, positions, cardSize); if (justWon) { await logActivity({ userId: locals.user.id, type: 'bingo_win', detail: lineLabel }); } diff --git a/src/routes/bingo/+page.svelte b/src/routes/bingo/+page.svelte index b398d08..48e6a03 100644 --- a/src/routes/bingo/+page.svelte +++ b/src/routes/bingo/+page.svelte @@ -1,6 +1,5 @@