1
0
Fork 0
polaris-task-force/src/lib/minigames/blackjack/tables.ts
Z8MB1E 474aff734d feat(blackjack): add blackjack minigame with realtime table wiring
Engine, felt, controls, sounds, card skins, and dev popups; SSE events in the realtime hook, nav and command palette entries, stats migration, and the six blackjack test suites.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:38:05 -04:00

896 lines
31 KiB
TypeScript

/**
* 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<number>;
/** Users whose buy-in is currently staked on the table (pool deducted). */
chipBoughtIn: Set<number>;
/** Users whose chips have been settled back to their pool (idempotency). */
chipSettled: Set<number>;
/** Pending joiners who already paid the buy-in and wait to seat next round. */
pendingChipPaid: Set<number>;
turnTimer: ReturnType<typeof setTimeout> | null;
/** Version the pending turn timer was scheduled for (stale timers bail). */
turnTimerVersion: number | null;
turnDeadlineAt: string | null;
nextRoundTimer: ReturnType<typeof setTimeout> | null;
nextRoundAt: string | null;
pendingJoins: PendingJoin[];
awayUsers: Set<number>;
/** Users who left mid-game; kept in seatMap so the UI can show "left". */
leftUsers: Set<number>;
/** Users watching the running game without a seat. */
spectators: Set<number>;
userNames: Map<number, string>;
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<string, BlackjackTableRecord>;
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<void> {
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<void> {
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<Payload | null> {
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<void> {
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<void> {
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<BlackjackSnapshot> {
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<BlackjackSnapshot> {
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<void> {
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();
}