285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Payload } from "payload";
|
|
|
|
import * as bus from "@/lib/realtime/bus";
|
|
import { TURN_TIMEOUT_MS } from "@/lib/minigames/blackjack/constants";
|
|
import {
|
|
applyBlackjackAction,
|
|
clearTablesForTests,
|
|
createTable,
|
|
forceExpireTable,
|
|
getSnapshotFor,
|
|
joinTable,
|
|
leaveTable,
|
|
NEXT_ROUND_DELAY_MS,
|
|
renameTable,
|
|
spectateTable,
|
|
startGame,
|
|
} from "@/lib/minigames/blackjack/tables";
|
|
|
|
/**
|
|
* Regression tests for the blackjack registry: game start, seating, lobby
|
|
* rules, and snapshots. Database-free except for the fire-and-forget award
|
|
* path, which skips users who never played a round.
|
|
*/
|
|
|
|
beforeEach(() => {
|
|
clearTablesForTests();
|
|
});
|
|
|
|
describe("blackjack table registry: startGame", () => {
|
|
it("starts a solo game immediately against the dealer", () => {
|
|
const tableId = createTable(9001, "Host");
|
|
const snapshot = startGame(tableId, 9001);
|
|
expect(snapshot.inGame).toBe(true);
|
|
expect(snapshot.you?.phase).toBe("betting");
|
|
expect(snapshot.you?.activeSeat).toBe(0);
|
|
expect(snapshot.you?.yourTurn).toBe(true);
|
|
expect(snapshot.you?.legalActions.canBet).toBe(true);
|
|
});
|
|
|
|
it("starts a six-player game with every seat backed by a user", () => {
|
|
const tableId = createTable(9101, "Host");
|
|
joinTable(tableId, 9102, "Second");
|
|
joinTable(tableId, 9103, "Third");
|
|
joinTable(tableId, 9104, "Fourth");
|
|
joinTable(tableId, 9105, "Fifth");
|
|
joinTable(tableId, 9106, "Sixth");
|
|
|
|
const snapshot = startGame(tableId, 9101);
|
|
const seatedIds = snapshot.seats.filter((id) => id !== null);
|
|
expect(seatedIds).toHaveLength(6);
|
|
const players = snapshot.you?.players ?? [];
|
|
expect(players).toHaveLength(6);
|
|
for (const player of players) {
|
|
expect(player.name).not.toBe("Player null");
|
|
expect(player.name.length).toBeGreaterThan(0);
|
|
const userId = snapshot.seats[player.seatId];
|
|
expect(userId).not.toBeNull();
|
|
}
|
|
expect(snapshot.seats).toHaveLength(6);
|
|
});
|
|
|
|
it("keeps the seat map padded to six with trailing nulls", () => {
|
|
const tableId = createTable(9201, "Host");
|
|
joinTable(tableId, 9202, "Second");
|
|
startGame(tableId, 9201);
|
|
const snapshot = getSnapshotFor(tableId, 9201);
|
|
expect(snapshot.seats).toEqual([9201, 9202, null, null, null, null]);
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: lobby rules", () => {
|
|
it("rejects a second table for a user already seated elsewhere", () => {
|
|
const first = createTable(9301, "Host");
|
|
createTable(9302, "Other");
|
|
expect(() => joinTable(first, 9302, "Other")).toThrow("already seated at another table");
|
|
});
|
|
|
|
it("lets the host start with a single player and rejects unstarted actions", () => {
|
|
const tableId = createTable(9401, "Host");
|
|
expect(() => applyBlackjackAction(tableId, 9401, "bet", 10)).toThrow(
|
|
"The game has not started yet.",
|
|
);
|
|
startGame(tableId, 9401);
|
|
expect(() => startGame(tableId, 9999)).toThrow("Only the host can start the game.");
|
|
});
|
|
|
|
it("queues mid-game joins for the next round", () => {
|
|
const tableId = createTable(9501, "Host");
|
|
joinTable(tableId, 9502, "Second");
|
|
startGame(tableId, 9501);
|
|
const latecomer = joinTable(tableId, 9503, "Latecomer");
|
|
// Pending joiners have a seat in the map but no engine hand until the
|
|
// next round starts, so their snapshot carries no private view yet.
|
|
expect(latecomer.you).toBeNull();
|
|
expect(latecomer.seats).toContain(9503);
|
|
expect(() => applyBlackjackAction(tableId, 9503, "bet", 10)).toThrow(
|
|
"You will join the game at the next round.",
|
|
);
|
|
});
|
|
|
|
it("spectates a running game with the public view and no seat", () => {
|
|
const tableId = createTable(9601, "Host");
|
|
joinTable(tableId, 9602, "Guest");
|
|
startGame(tableId, 9601);
|
|
const snapshot = spectateTable(tableId, 9603);
|
|
expect(snapshot.spectator).toBe(true);
|
|
expect(snapshot.you?.viewerSeat).toBe(-1);
|
|
expect(snapshot.you?.players).toHaveLength(2);
|
|
expect(snapshot.you?.yourTurn).toBe(false);
|
|
expect(snapshot.you?.legalActions.canBet).toBe(false);
|
|
expect(() => spectateTable(createTable(9604, "Idle"), 9603)).toThrow(
|
|
"No game is running at this table.",
|
|
);
|
|
});
|
|
|
|
it("compacts the seat map and promotes the host when leaving pre-game", () => {
|
|
const tableId = createTable(9701, "Host");
|
|
joinTable(tableId, 9702, "Second");
|
|
joinTable(tableId, 9703, "Third");
|
|
leaveTable(tableId, 9701);
|
|
const snapshot = getSnapshotFor(tableId, 9702);
|
|
expect(snapshot.seats).toEqual([9702, 9703, null, null, null, null]);
|
|
expect(snapshot.hostSeatId).toBe(0);
|
|
});
|
|
|
|
it("marks mid-game leavers as left in every snapshot", () => {
|
|
const tableId = createTable(9801, "Host");
|
|
joinTable(tableId, 9802, "Guest");
|
|
startGame(tableId, 9801);
|
|
leaveTable(tableId, 9802);
|
|
const host = getSnapshotFor(tableId, 9801);
|
|
expect(host.you?.players.find((player) => player.seatId === 1)?.left).toBe(true);
|
|
expect(host.you?.players.find((player) => player.seatId === 0)?.left).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: gameplay through the registry", () => {
|
|
it("runs a solo betting round end to end", () => {
|
|
const tableId = createTable(9901, "Host");
|
|
startGame(tableId, 9901);
|
|
let afterBet = applyBlackjackAction(tableId, 9901, "bet", 25);
|
|
// The shoe is random: an ace upcard opens an insurance offer, and a
|
|
// dealt natural settles the round on the spot. Walk whichever path comes.
|
|
if (afterBet.you?.phase === "insurance") {
|
|
afterBet = applyBlackjackAction(tableId, 9901, "decline-insurance");
|
|
}
|
|
if (afterBet.you?.phase === "player") {
|
|
afterBet = applyBlackjackAction(tableId, 9901, "stand");
|
|
}
|
|
expect(afterBet.you?.phase).toBe("round-complete");
|
|
expect(afterBet.you?.yourHands).toHaveLength(1);
|
|
expect(afterBet.you?.yourHands[0]?.bet).toBe(25);
|
|
expect(afterBet.version).toBeGreaterThan(1);
|
|
expect(afterBet.you?.roundResult).not.toBeNull();
|
|
expect(afterBet.you?.roundResult?.hands).toHaveLength(1);
|
|
expect(afterBet.nextRoundAt).not.toBeNull();
|
|
// The next round has not auto-started yet (the registry timer owns that).
|
|
expect(() => applyBlackjackAction(tableId, 9901, "bet", 10)).toThrow(
|
|
"not accepting bets",
|
|
);
|
|
});
|
|
|
|
it("validates bet limits through the registry", () => {
|
|
const tableId = createTable(9911, "Host");
|
|
startGame(tableId, 9911);
|
|
expect(() => applyBlackjackAction(tableId, 9911, "bet", 9)).toThrow("minimum bet");
|
|
expect(() => applyBlackjackAction(tableId, 9911, "bet", 501)).toThrow("maximum bet");
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: table management", () => {
|
|
it("renames tables as host with length truncation", () => {
|
|
const tableId = createTable(9921, "Host");
|
|
const renamed = renameTable(tableId, 9921, " High Roller Room ");
|
|
expect(renamed.name).toBe("High Roller Room");
|
|
const longName = "X".repeat(60);
|
|
expect(renameTable(tableId, 9921, longName).name).toHaveLength(48);
|
|
expect(() => renameTable(tableId, 9999, "Nope")).toThrow("Only the host");
|
|
});
|
|
|
|
it("caps live tables at the registry maximum", () => {
|
|
for (let index = 0; index < 20; index += 1) {
|
|
createTable(10000 + index, `Host ${index}`);
|
|
}
|
|
expect(() => createTable(10999, "Overflow")).toThrow("maximum number of blackjack tables");
|
|
});
|
|
|
|
it("disposes a table once everyone leaves", () => {
|
|
const tableId = createTable(11001, "Host");
|
|
leaveTable(tableId, 11001);
|
|
expect(() => getSnapshotFor(tableId, 11001)).toThrow("Table not found.");
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: mid-game leavers", () => {
|
|
it("resolves the acting seat immediately when the leaver is on the clock", () => {
|
|
const tableId = createTable(11101, "Host");
|
|
joinTable(tableId, 11102, "Guest");
|
|
startGame(tableId, 11101);
|
|
// Seat 0 (the host) acts first in the betting phase.
|
|
expect(getSnapshotFor(tableId, 11102).you?.activeSeat).toBe(0);
|
|
|
|
leaveTable(tableId, 11101);
|
|
|
|
const guest = getSnapshotFor(tableId, 11102);
|
|
expect(guest.you?.activeSeat).toBe(1);
|
|
expect(guest.you?.players.find((player) => player.seatId === 0)?.sittingOut).toBe(true);
|
|
expect(guest.turnDeadline).not.toBeNull();
|
|
// The guest is on the clock right away; their bet is accepted.
|
|
expect(() => applyBlackjackAction(tableId, 11102, "bet", 10)).not.toThrow();
|
|
});
|
|
|
|
it("does not advance the turn when a non-acting player leaves mid-game", () => {
|
|
const tableId = createTable(11151, "Host");
|
|
joinTable(tableId, 11152, "Guest");
|
|
startGame(tableId, 11151);
|
|
const before = getSnapshotFor(tableId, 11151);
|
|
const deadline = before.turnDeadline;
|
|
|
|
leaveTable(tableId, 11152);
|
|
|
|
const after = getSnapshotFor(tableId, 11151);
|
|
expect(after.you?.activeSeat).toBe(0);
|
|
expect(after.turnDeadline).toBe(deadline);
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: mid-game joins", () => {
|
|
it("seats a mid-game joiner exactly once at the next round", () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const tableId = createTable(11201, "Host");
|
|
startGame(tableId, 11201);
|
|
joinTable(tableId, 11202, "Latecomer");
|
|
|
|
// Host times out of betting (empty round settles), then the next-round
|
|
// pause applies the pending join.
|
|
vi.advanceTimersByTime(TURN_TIMEOUT_MS);
|
|
vi.advanceTimersByTime(NEXT_ROUND_DELAY_MS);
|
|
|
|
const snapshot = getSnapshotFor(tableId, 11201);
|
|
expect(snapshot.seats.filter((id) => id === 11202)).toHaveLength(1);
|
|
expect(snapshot.seats).toHaveLength(6);
|
|
const latecomerPlayers = (snapshot.you?.players ?? []).filter(
|
|
(player) => player.name === "Latecomer",
|
|
);
|
|
expect(latecomerPlayers).toHaveLength(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("blackjack table registry: force end", () => {
|
|
it("terminates every seated player and spectator", async () => {
|
|
const broadcastSpy = vi.spyOn(bus, "broadcastToUser");
|
|
const tableId = createTable(11301, "Host");
|
|
joinTable(tableId, 11302, "Guest");
|
|
startGame(tableId, 11301);
|
|
spectateTable(tableId, 11303);
|
|
// The notification write is stubbed out; only the broadcast is asserted.
|
|
const payloadStub = {
|
|
logger: { error: vi.fn() },
|
|
create: vi.fn().mockRejectedValue(new Error("notifications unavailable")),
|
|
} as unknown as Payload;
|
|
|
|
forceExpireTable(tableId, payloadStub);
|
|
|
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11301, {
|
|
tableId,
|
|
reason: "force-ended",
|
|
});
|
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11302, {
|
|
tableId,
|
|
reason: "force-ended",
|
|
});
|
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11303, {
|
|
tableId,
|
|
reason: "force-ended",
|
|
});
|
|
expect(() => getSnapshotFor(tableId, 11301)).toThrow("Table not found.");
|
|
broadcastSpy.mockRestore();
|
|
});
|
|
});
|