1
0
Fork 0
polaris-task-force/tests/int/blackjack-tables.int.spec.ts

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();
});
});