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>
896 lines
31 KiB
TypeScript
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();
|
|
}
|