/** * Server-side multiplayer registry for the blackjack minigame. * * Holds live table records in a module-level Map (same pattern as * src/lib/realtime/bus.ts). SINGLE-INSTANCE ONLY: table state lives in * process memory, so this will not work across multiple server instances; * the SSE pipeline it pushes through has the same limitation. * * The pure engine (./engine, ./actions) never touches timers or I/O. This * wrapper owns turn timeouts, next-round pauses, lobby broadcasts, session * stat awards, and the per-user snapshot pushes. Seat map invariant: * seatMap is padded to MAX_PLAYERS slots with trailing nulls, so engine * player i always corresponds to seatMap[i] (drops reindex both together). */ import { getPayload } from "payload"; import type { Payload } from "payload"; import { broadcast, broadcastToUser } from "@/lib/realtime/bus"; import { notifyUser } from "@/lib/notifications"; import { creditChips, readChipBankState, spendChipBuyIn } from "@/lib/minigames/chips"; import { applyAction, sitOut } from "./actions"; import { BUY_IN, MAX_PLAYERS, MAX_TABLE_NAME_LENGTH, MIN_PLAYERS, TURN_TIMEOUT_MS, } from "./constants"; import { createTableState, startNextRound } from "./engine"; import { generateTableName } from "@/lib/minigames/tableNames"; import { awardBlackjackResult, type BlackjackSessionResult } from "./stats"; import type { BlackjackActionKind, BlackjackPlayerState, BlackjackSeatId, BlackjackTableState, PrivatePlayerView, } from "./types"; import { buildPrivateView } from "./views"; /** Maximum number of live tables; table creation rejects beyond this. */ export const MAX_TABLES = 20; /** Pause after a round settles before the next betting phase auto-starts. */ export const NEXT_ROUND_DELAY_MS = 3000; /** A table with no client interaction for this long is swept as abandoned. */ export const TABLE_EXPIRY_MS = 5 * 60_000; const TABLE_SWEEP_INTERVAL_MS = 30_000; export interface BlackjackTableSummary { tableId: string; name: string | null; hostName: string; seatedCount: number; maxSeats: number; inGame: boolean; createdAt: string; } export interface BlackjackSnapshot { tableId: string; name: string | null; version: number; /** ISO timestamp when the current turn auto-acts; null when no live turn. */ turnDeadline: string | null; /** ISO timestamp when the next betting round auto-starts after the pause. */ nextRoundAt: string | null; /** Full table state plus the viewer's options; null when not seated. */ you: PrivatePlayerView | null; /** seatId to userId (null = empty seat); lets the UI map names and hosts. */ seats: (number | null)[]; /** seatId to display name (null = empty or unknown); drives lobby seat labels. */ names: (string | null)[]; hostSeatId: number | null; /** True when the viewer is watching without a seat (you is a spectator view). */ spectator?: boolean; /** Whether a game is currently running (useful for viewers without a seat). */ inGame?: boolean; } interface PendingJoin { userId: number; name: string; } interface BlackjackTableRecord { id: string; name: string | null; version: number; hostUserId: number | null; /** Engine seat order, padded to MAX_PLAYERS with trailing nulls. */ seatMap: (number | null)[]; state: BlackjackTableState | null; inGame: boolean; /** Users already awarded their session stats this game (idempotency). */ awardedUsers: Set; /** Users whose buy-in is currently staked on the table (pool deducted). */ chipBoughtIn: Set; /** Users whose chips have been settled back to their pool (idempotency). */ chipSettled: Set; /** Pending joiners who already paid the buy-in and wait to seat next round. */ pendingChipPaid: Set; turnTimer: ReturnType | null; /** Version the pending turn timer was scheduled for (stale timers bail). */ turnTimerVersion: number | null; turnDeadlineAt: string | null; nextRoundTimer: ReturnType | null; nextRoundAt: string | null; pendingJoins: PendingJoin[]; awayUsers: Set; /** Users who left mid-game; kept in seatMap so the UI can show "left". */ leftUsers: Set; /** Users watching the running game without a seat. */ spectators: Set; userNames: Map; createdAt: string; /** Last client interaction (poll, action, join/leave/start); drives expiry. */ lastActivityAt: number; } // Anchored on globalThis because Next.js dev gives each client session its // own copy of server-action modules; a plain module-level Map would fork per // session and tables would vanish between users. Single-process still. type BlackjackTablesStore = Map; interface BlackjackGlobalRegistry { __blackjackTables?: BlackjackTablesStore; } const globalRegistry = globalThis as typeof globalThis & BlackjackGlobalRegistry; const tables: BlackjackTablesStore = (globalRegistry.__blackjackTables ??= new Map()); function isSeatedAt(record: BlackjackTableRecord, userId: number): boolean { return record.seatMap.includes(userId) && !record.leftUsers.has(userId); } function seatedCountOf(record: BlackjackTableRecord): number { let count = 0; for (const id of record.seatMap) { if (id !== null && !record.leftUsers.has(id)) count += 1; } return count; } function clearTurnTimer(record: BlackjackTableRecord): void { if (record.turnTimer) clearTimeout(record.turnTimer); record.turnTimer = null; record.turnTimerVersion = null; record.turnDeadlineAt = null; } /** * Lobby mutations (join/leave) bump the version without touching game state; * re-key the pending turn timer so it stays valid instead of going stale. */ function rekeyTurnTimer(record: BlackjackTableRecord): void { if (record.turnTimer !== null) record.turnTimerVersion = record.version; } function scheduleTurnTimer(record: BlackjackTableRecord): void { clearTurnTimer(record); const state = record.state; if (!state || state.toActSeat === null) return; if (state.phase !== "betting" && state.phase !== "insurance" && state.phase !== "player") { return; } record.turnTimerVersion = record.version; record.turnDeadlineAt = new Date(Date.now() + TURN_TIMEOUT_MS).toISOString(); record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS); } /** Turn timeout: sit out / decline insurance / stand for whoever is acting. */ function onTurnTimeout(record: BlackjackTableRecord): void { const scheduledVersion = record.turnTimerVersion; record.turnTimer = null; record.turnTimerVersion = null; record.turnDeadlineAt = null; if (scheduledVersion === null || scheduledVersion !== record.version) return; const state = record.state; if (!state || state.toActSeat === null) return; const seat = state.toActSeat; let result; if (state.phase === "betting") { result = sitOut(state, seat); } else if (state.phase === "insurance") { result = applyAction(state, seat, "decline-insurance"); } else if (state.phase === "player") { result = applyAction(state, seat, "stand"); } else { return; } if (!result.success) return; record.state = result.state; record.version += 1; afterMutation(record); } function scheduleNextRound(record: BlackjackTableRecord): void { if (record.nextRoundTimer) clearTimeout(record.nextRoundTimer); record.nextRoundAt = new Date(Date.now() + NEXT_ROUND_DELAY_MS).toISOString(); record.nextRoundTimer = setTimeout(() => onNextRoundTimeout(record), NEXT_ROUND_DELAY_MS); } function clearNextRoundTimer(record: BlackjackTableRecord): void { if (record.nextRoundTimer) clearTimeout(record.nextRoundTimer); record.nextRoundTimer = null; record.nextRoundAt = null; } function createFreshPlayer(seatId: BlackjackSeatId, name: string): BlackjackPlayerState { return { seatId, name, bankroll: BUY_IN, hands: [], insuranceBet: 0, insuranceAnswered: false, hasBet: false, sittingOut: false, bankrupt: false, roundsPlayed: 0, roundsWon: 0, blackjacksHit: 0, }; } /** * Next-round pause over: apply pending joins, drop leavers and bankrupts * (with a session-stat award for each), and start the next betting phase. */ function onNextRoundTimeout(record: BlackjackTableRecord): void { record.nextRoundTimer = null; const current = record.state; if (!current) return; const dropIndexes: number[] = []; const dropUsers: number[] = []; current.players.forEach((player, index) => { const userId = record.seatMap[index]; if (userId === null) return; if (record.leftUsers.has(userId) || player.bankrupt) { dropIndexes.push(index); dropUsers.push(userId); } }); void awardSessionResults(record, dropUsers); const survivors = record.seatMap.filter( (id, index) => !dropIndexes.includes(index) && id !== null, ); record.seatMap = [...survivors]; while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null); let next = startNextRound(current, dropIndexes); for (const pending of record.pendingJoins) { // Paid pending joiners bring their buy-in into the engine now. if (record.pendingChipPaid.has(pending.userId)) { record.pendingChipPaid.delete(pending.userId); record.chipBoughtIn.add(pending.userId); } const slot = record.seatMap.indexOf(null); if (slot < 0) break; record.seatMap[slot] = pending.userId; next = { ...next, players: [...next.players, createFreshPlayer(next.players.length, pending.name)], }; } record.pendingJoins = []; // Fresh joiners have no recorded session yet; nothing else to clean up. record.state = next; record.version += 1; afterMutation(record); } /** In-app bell notification: solo player keeps the table to themselves. */ async function notifySoloPlayer(record: BlackjackTableRecord): Promise { const lastUserId = record.seatMap.find( (id): id is number => id !== null && !record.leftUsers.has(id), ); if (lastUserId === undefined) return; try { const config = (await import("@payload-config")).default; const payload = await getPayload({ config }); await notifyUser(payload, { userId: lastUserId, type: "blackjack:everyone-left", title: "Everyone left the table", message: "The other players left. You can keep playing blackjack solo.", link: "/minigames/blackjack", }); } catch { // Payload unavailable in this runtime; nothing to notify. } } /** Fire-and-forget XP/stats award; never blocks the table. */ async function awardSessionResults( record: BlackjackTableRecord, userIds: readonly number[], ): Promise { const state = record.state; if (!state) return; const results: BlackjackSessionResult[] = []; const refunds: { userId: number; amount: number }[] = []; for (const userId of userIds) { if (record.awardedUsers.has(userId) || record.chipSettled.has(userId)) continue; const seatIndex = record.seatMap.indexOf(userId); if (seatIndex < 0 || seatIndex >= state.players.length) continue; const player = state.players[seatIndex]; if (!player) continue; if (player.roundsPlayed <= 0) { // Never played a hand: the untouched buy-in returns to the pool. record.chipSettled.add(userId); refunds.push({ userId, amount: player.bankroll }); continue; } record.awardedUsers.add(userId); // Settled up front: awardBlackjackResult credits the final bankroll back // to the player's pool inside its profile update. record.chipSettled.add(userId); results.push({ userId, roundsPlayed: player.roundsPlayed, roundsWon: player.roundsWon, blackjacksHit: player.blackjacksHit, finalBankroll: player.bankroll, }); } if (results.length === 0 && refunds.length === 0) return; try { const config = (await import("@payload-config")).default; const payload = await getPayload({ config }); try { if (results.length > 0) { await awardBlackjackResult(payload, results); } for (const refund of refunds) { if (refund.amount <= 0) continue; try { await creditChips(payload, refund.userId, refund.amount); } catch (error) { payload.logger.error( `[blackjack] Failed to settle chips for user ${refund.userId}: ${error}`, ); } } } catch (error) { payload.logger.error( `[blackjack] Failed to award session results for table ${record.id}: ${error}`, ); } } catch { // Payload unavailable in this runtime; skip awarding. } } /** Award every not-yet-awarded seated player (disposal / force expiry). */ function awardAllRemaining(record: BlackjackTableRecord): void { const userIds = record.seatMap.filter((id): id is number => id !== null); void awardSessionResults(record, userIds); } /** Shared post-mutation pipeline: timers, award triggers, push. */ function afterMutation(record: BlackjackTableRecord): void { const state = record.state; if (state && state.phase === "round-complete") { clearTurnTimer(record); scheduleNextRound(record); } else { scheduleTurnTimer(record); } pushTable(record); } function buildSnapshot(record: BlackjackTableRecord, userId: number): BlackjackSnapshot { const seatIndex = record.seatMap.indexOf(userId); const seated = seatIndex >= 0 && !record.leftUsers.has(userId); const spectator = !seated && record.spectators.has(userId); const state = record.state; const inEngine = seated && state !== null && seatIndex < state.players.length; let base = state && inEngine ? buildPrivateView(state, seatIndex) : null; if (base === null && spectator && state) { // Spectators receive the public view with no seat: the synthetic viewer // seat (-1) never matches a real player, so all seats render as others. base = buildPrivateView(state, -1); } const you = base === null ? null : { ...base, players: base.players.map((player) => { const playerUserId = record.seatMap[player.seatId]; if (playerUserId === null || !record.leftUsers.has(playerUserId)) return player; return { ...player, left: true }; }), }; const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1; return { tableId: record.id, name: record.name, version: record.version, turnDeadline: record.turnDeadlineAt, nextRoundAt: record.nextRoundAt, you, seats: [...record.seatMap], names: record.seatMap.map((id) => (id === null ? null : (record.userNames.get(id) ?? null))), hostSeatId: hostSeat >= 0 ? hostSeat : null, spectator: spectator || undefined, inGame: record.inGame || undefined, }; } /** Push a per-user snapshot to every seated user and spectator, then refresh the lobby. */ function pushTable(record: BlackjackTableRecord): void { for (const userId of record.seatMap) { if (userId === null || record.leftUsers.has(userId)) continue; broadcastToUser("blackjack-state", userId, { tableId: record.id, snapshot: buildSnapshot(record, userId), }); } for (const spectatorId of record.spectators) { broadcastToUser("blackjack-state", spectatorId, { tableId: record.id, snapshot: buildSnapshot(record, spectatorId), }); } broadcast("blackjack-tables", { tables: listSummaries() }); } /** * Remove the table entirely. Session stats are awarded for every player who * played at least one round and has not been awarded yet, so closed tabs and * emptied tables still record their sessions. */ function disposeTable(record: BlackjackTableRecord): void { clearTurnTimer(record); clearNextRoundTimer(record); awardAllRemaining(record); void settleUnsettledChips(record); tables.delete(record.id); broadcast("blackjack-tables", { tables: listSummaries() }); } /** Payload handle, or null when the config cannot load (tests, early boot). */ async function getPayloadOrNull(): Promise { try { const config = (await import("@payload-config")).default; return await getPayload({ config }); } catch { return null; } } /** * Credit the current bankroll (or an unseated pending joiner's full buy-in) * back to the pool for every bought-in player not yet settled. Marks each * user settled synchronously so concurrent teardowns cannot double-credit. */ async function settleUnsettledChips(record: BlackjackTableRecord): Promise { const candidates = [...record.chipBoughtIn, ...record.pendingChipPaid]; const state = record.state; const credits: { userId: number; amount: number }[] = []; for (const userId of candidates) { if (record.chipSettled.has(userId)) continue; const seatIndex = record.seatMap.indexOf(userId); const player = state && seatIndex >= 0 ? state.players[seatIndex] : undefined; const amount = player ? player.bankroll : BUY_IN; record.chipSettled.add(userId); credits.push({ userId, amount }); } if (credits.length === 0) return; const payload = await getPayloadOrNull(); if (!payload) return; for (const credit of credits) { if (credit.amount <= 0) continue; try { await creditChips(payload, credit.userId, credit.amount); } catch (error) { payload.logger.error( `[blackjack] Failed to settle chips for user ${credit.userId}: ${error}`, ); } } } /** In-app bell notification when a pending joiner cannot cover the buy-in. */ async function notifyBuyInFailed(record: BlackjackTableRecord, userId: number): Promise { try { const payload = await getPayloadOrNull(); if (!payload) return; await notifyUser(payload, { userId, type: "blackjack:buy-in-failed", title: "Not enough chips", message: `You need ${BUY_IN} chips to join the next round. Play the other minigames to earn more chips.`, link: "/minigames/blackjack", }); } catch { // Notifications are best-effort. } } /** * Buy-in-aware wrappers used by the server actions. The sync core above * (startGame/joinTable/leaveTable) stays registry-pure for tests; pool * accounting lives here where a Payload handle is available. */ /** * Start a session: settle any unsettled chips from a previous session on * this record, collect the buy-in from every seated player (all-or-nothing), * then start. Throws without moving chips when anyone cannot cover. */ export async function startGameWithBuyIn( tableId: string, userId: number, ): Promise { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); await settleUnsettledChips(record); const seated = record.seatMap.filter( (id): id is number => id !== null && !record.leftUsers.has(id), ); const payload = await getPayloadOrNull(); const paid: number[] = []; try { if (payload) { for (const seatedId of seated) { const bank = await readChipBankState(payload, seatedId); if (!bank) continue; // Untracked user (no profile): plays without pool accounting. if (bank.chipBank < BUY_IN) { const name = record.userNames.get(seatedId) ?? `Player ${seatedId}`; throw new Error( `${name} needs ${BUY_IN} chips to sit down and has ${bank.chipBank}. Play the other minigames to earn more chips.`, ); } } for (const seatedId of seated) { const result = await spendChipBuyIn(payload, seatedId, BUY_IN); if (result.settled) paid.push(seatedId); } } const snapshot = startGame(tableId, userId); record.chipBoughtIn = new Set(paid); record.chipSettled = new Set(); record.pendingChipPaid = new Set(); return snapshot; } catch (error) { if (paid.length > 0 && payload) { for (const paidId of paid) { try { await creditChips(payload, paidId, BUY_IN); } catch { // Best-effort refund. } } } throw error; } } /** * Join a table: mid-game joins pay the buy-in up front (pendingChipPaid) so * they can seat at the next round; the payment refunds if the join fails. */ export async function joinTableWithBuyIn( tableId: string, userId: number, displayName: string, ): Promise { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (record.inGame && !record.pendingChipPaid.has(userId)) { const payload = await getPayloadOrNull(); if (payload) { const bank = await readChipBankState(payload, userId); if (bank && bank.chipBank < BUY_IN) { throw new Error( `You need ${BUY_IN} chips to join this table mid-game and have ${bank.chipBank}. Play the other minigames to earn more chips.`, ); } if (bank) { await spendChipBuyIn(payload, userId, BUY_IN); record.pendingChipPaid.add(userId); try { return joinTable(tableId, userId, displayName); } catch (error) { record.pendingChipPaid.delete(userId); try { await creditChips(payload, userId, BUY_IN); } catch { // Best-effort refund. } throw error; } } } } return joinTable(tableId, userId, displayName); } /** Leave a table: pending joiners who paid but never seated get their buy-in back. */ export async function leaveTableWithBuyIn(tableId: string, userId: number): Promise { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); const wasPending = record.pendingChipPaid.has(userId); leaveTable(tableId, userId); if (!wasPending) return; record.pendingChipPaid.delete(userId); const payload = await getPayloadOrNull(); if (!payload) return; try { await creditChips(payload, userId, BUY_IN); } catch (error) { payload.logger.error( `[blackjack] Failed to refund pending buy-in for user ${userId}: ${error}`, ); } } export function forceExpireTable(tableId: string, payload: Payload): void { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); for (const userId of record.seatMap) { if (userId === null || record.leftUsers.has(userId)) continue; broadcastToUser("blackjack-terminated", userId, { tableId: record.id, reason: "force-ended", }); try { void notifyUser(payload, { userId, type: "blackjack:force-ended", title: "Blackjack game ended", message: "An administrator ended your blackjack game.", link: "/minigames/blackjack", }); } catch (error) { payload.logger.error( `[blackjack] Failed to notify user ${userId} for table ${record.id}: ${error}`, ); } } disposeTable(record); } /** Tables with nobody seated (everyone left) are removed immediately. */ function scheduleDisposalIfEmpty(record: BlackjackTableRecord): void { if (seatedCountOf(record) > 0) return; disposeTable(record); } function promoteHost(record: BlackjackTableRecord): void { const nextHost = record.seatMap.find((id) => id !== null && !record.leftUsers.has(id)); record.hostUserId = nextHost ?? null; } export function createTable(userId: number, displayName: string): string { if (tables.size >= MAX_TABLES) { throw new Error("The maximum number of blackjack tables exists. Try again later."); } const seatMap: (number | null)[] = [userId]; while (seatMap.length < MAX_PLAYERS) seatMap.push(null); const record: BlackjackTableRecord = { id: crypto.randomUUID(), name: generateTableName(), version: 1, hostUserId: userId, seatMap, state: null, inGame: false, awardedUsers: new Set(), chipBoughtIn: new Set(), chipSettled: new Set(), pendingChipPaid: new Set(), turnTimer: null, turnTimerVersion: null, turnDeadlineAt: null, nextRoundTimer: null, nextRoundAt: null, pendingJoins: [], awayUsers: new Set(), leftUsers: new Set(), spectators: new Set(), userNames: new Map([[userId, displayName]]), createdAt: new Date().toISOString(), lastActivityAt: Date.now(), }; tables.set(record.id, record); pushTable(record); return record.id; } export function listSummaries(): BlackjackTableSummary[] { return [...tables.values()].map((record) => ({ tableId: record.id, name: record.name, hostName: record.hostUserId !== null ? (record.userNames.get(record.hostUserId) ?? "Unknown") : "Unknown", seatedCount: seatedCountOf(record), maxSeats: MAX_PLAYERS, inGame: record.inGame, createdAt: record.createdAt, })); } export function getSnapshotFor(tableId: string, userId: number): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); record.lastActivityAt = Date.now(); return buildSnapshot(record, userId); } export function joinTable(tableId: string, userId: number, displayName: string): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (record.leftUsers.has(userId)) { throw new Error("You already left this table. You can rejoin when a new game starts."); } if (isSeatedAt(record, userId)) return buildSnapshot(record, userId); for (const other of tables.values()) { if (other.id !== record.id && isSeatedAt(other, userId)) { throw new Error("You are already seated at another table. Leave it first."); } } const slot = record.seatMap.indexOf(null); if (slot < 0) throw new Error("The table is full."); record.spectators.delete(userId); record.seatMap[slot] = userId; record.userNames.set(userId, displayName); record.lastActivityAt = Date.now(); if (record.inGame) record.pendingJoins.push({ userId, name: displayName }); scheduleDisposalIfEmpty(record); record.version += 1; rekeyTurnTimer(record); pushTable(record); return buildSnapshot(record, userId); } /** Register a spectator on a running game; they get the public view with no seat. */ export function spectateTable(tableId: string, userId: number): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (isSeatedAt(record, userId)) return buildSnapshot(record, userId); if (!record.inGame || !record.state) { throw new Error("No game is running at this table."); } record.spectators.add(userId); record.lastActivityAt = Date.now(); return buildSnapshot(record, userId); } export function leaveTable(tableId: string, userId: number): void { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (record.spectators.delete(userId)) { // Spectators just stop watching; no seat logic applies. record.lastActivityAt = Date.now(); return; } if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table."); if (record.inGame) { // Mid-game: keep the seat visible; the player is dropped (and their // session awarded) when the next betting round starts. record.leftUsers.add(userId); record.awayUsers.add(userId); } else { const seatIndex = record.seatMap.indexOf(userId); record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex); while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null); record.pendingJoins = record.pendingJoins.filter((pending) => pending.userId !== userId); } if (record.hostUserId === userId) promoteHost(record); record.lastActivityAt = Date.now(); record.version += 1; rekeyTurnTimer(record); scheduleDisposalIfEmpty(record); if (record.inGame && seatedCountOf(record) === 1) { void notifySoloPlayer(record); } pushTable(record); } export function startGame(tableId: string, userId: number): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (record.hostUserId !== userId) throw new Error("Only the host can start the game."); const seated = record.seatMap.filter( (id): id is number => id !== null && !record.leftUsers.has(id), ); if (seated.length < MIN_PLAYERS) { throw new Error("At least one seated player is needed to start the game."); } // Compact the seat map for the new game: leavers are out, order preserved. // Pad a COPY: aliasing `seated` here once spawned phantom "Player null" seats. const padded: (number | null)[] = [...seated]; while (padded.length < MAX_PLAYERS) padded.push(null); record.seatMap = padded; record.leftUsers.clear(); record.awayUsers.clear(); record.pendingJoins = []; record.awardedUsers = new Set(); record.nextRoundAt = null; if (record.nextRoundTimer) { clearTimeout(record.nextRoundTimer); record.nextRoundTimer = null; } record.state = createTableState( seated.map((id) => ({ name: record.userNames.get(id) ?? `Player ${id}` })), { startingBankroll: BUY_IN }, undefined, ); record.inGame = true; record.lastActivityAt = Date.now(); record.version += 1; afterMutation(record); return buildSnapshot(record, userId); } export function renameTable(tableId: string, userId: number, rawName: string): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); if (record.hostUserId !== userId) throw new Error("Only the host can rename the table."); const name = rawName.trim().replace(/\s+/g, " ").slice(0, MAX_TABLE_NAME_LENGTH).trim(); record.name = name.length > 0 ? name : null; record.lastActivityAt = Date.now(); record.version += 1; pushTable(record); return buildSnapshot(record, userId); } export function applyBlackjackAction( tableId: string, userId: number, kind: BlackjackActionKind, amount?: number, ): BlackjackSnapshot { const record = tables.get(tableId); if (!record) throw new Error("Table not found."); const state = record.state; if (!state) throw new Error("The game has not started yet."); if (record.leftUsers.has(userId)) throw new Error("You are not seated at this table."); const seatIndex = record.seatMap.indexOf(userId); if (seatIndex < 0) throw new Error("You are not seated at this table."); if (seatIndex >= state.players.length) { throw new Error("You will join the game at the next round."); } const result = applyAction(state, seatIndex, kind, amount); if (!result.success) throw new Error(result.error); record.state = result.state; record.lastActivityAt = Date.now(); record.version += 1; afterMutation(record); return buildSnapshot(record, userId); } /** * Sweeps tables whose last client interaction is older than TABLE_EXPIRY_MS: * tabs closed without leaving, abandoned waiting rooms, gone games. Session * stats are awarded on disposal. Safe to run from several module copies in * dev because the store is shared via globalThis and disposal is idempotent. */ function sweepExpiredTables(): void { const now = Date.now(); let expired = false; for (const record of tables.values()) { if (now - record.lastActivityAt <= TABLE_EXPIRY_MS) continue; disposeTable(record); expired = true; } if (expired) broadcast("blackjack-tables", { tables: listSummaries() }); } const sweepInterval = setInterval(sweepExpiredTables, TABLE_SWEEP_INTERVAL_MS); sweepInterval.unref?.(); /** * Test-only helper: dispose every table without awards so vitest files can * start each run against a clean registry. */ export function clearTablesForTests(): void { for (const record of tables.values()) { clearTurnTimer(record); clearNextRoundTimer(record); } tables.clear(); }