fix(blackjack): resolve leaver turns, dedupe pending joins, terminate spectators
This commit is contained in:
parent
fad3d8399b
commit
27b5b4537f
2 changed files with 138 additions and 12 deletions
|
|
@ -169,15 +169,14 @@ function scheduleTurnTimer(record: BlackjackTableRecord): void {
|
|||
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;
|
||||
/**
|
||||
* Auto-act for whoever must act: sit out / decline insurance / stand. Shared
|
||||
* by the turn timer and by leaveTable so a mid-game leaver who is on the
|
||||
* clock does not hold the table hostage until their timer expires.
|
||||
*/
|
||||
function resolveActingSeat(record: BlackjackTableRecord): boolean {
|
||||
const state = record.state;
|
||||
if (!state || state.toActSeat === null) return;
|
||||
if (!state || state.toActSeat === null) return false;
|
||||
const seat = state.toActSeat;
|
||||
let result;
|
||||
if (state.phase === "betting") {
|
||||
|
|
@ -187,12 +186,23 @@ function onTurnTimeout(record: BlackjackTableRecord): void {
|
|||
} else if (state.phase === "player") {
|
||||
result = applyAction(state, seat, "stand");
|
||||
} else {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!result.success) return;
|
||||
if (!result.success) return false;
|
||||
record.state = result.state;
|
||||
record.version += 1;
|
||||
afterMutation(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
resolveActingSeat(record);
|
||||
}
|
||||
|
||||
function scheduleNextRound(record: BlackjackTableRecord): void {
|
||||
|
|
@ -252,6 +262,14 @@ function onNextRoundTimeout(record: BlackjackTableRecord): void {
|
|||
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
|
||||
|
||||
let next = startNextRound(current, dropIndexes);
|
||||
// joinTable pre-seats a mid-game joiner into seatMap AND queues them in
|
||||
// pendingJoins. Clear the pre-seated slot so the loop below seats each
|
||||
// joiner exactly once; after compaction the cleared slot is the first
|
||||
// null, so the joiner lands back on the same seat index.
|
||||
for (const pending of record.pendingJoins) {
|
||||
const preSeated = record.seatMap.indexOf(pending.userId);
|
||||
if (preSeated >= 0) record.seatMap[preSeated] = null;
|
||||
}
|
||||
for (const pending of record.pendingJoins) {
|
||||
// Paid pending joiners bring their buy-in into the engine now.
|
||||
if (record.pendingChipPaid.has(pending.userId)) {
|
||||
|
|
@ -625,9 +643,16 @@ export function forceExpireTable(tableId: string, payload: Payload): void {
|
|||
const record = tables.get(tableId);
|
||||
if (!record) throw new Error("Table not found.");
|
||||
|
||||
// Everyone still watching the table: seated players (leavers are already
|
||||
// out) plus spectators, who would otherwise never learn the game ended.
|
||||
const affected = new Set<number>();
|
||||
for (const userId of record.seatMap) {
|
||||
if (userId === null || record.leftUsers.has(userId)) continue;
|
||||
affected.add(userId);
|
||||
}
|
||||
for (const spectatorId of record.spectators) affected.add(spectatorId);
|
||||
|
||||
for (const userId of affected) {
|
||||
broadcastToUser("blackjack-terminated", userId, {
|
||||
tableId: record.id,
|
||||
reason: "force-ended",
|
||||
|
|
@ -770,9 +795,14 @@ export function leaveTable(tableId: string, userId: number): void {
|
|||
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.
|
||||
// session awarded) when the next betting round starts. When the leaver
|
||||
// is the acting seat, resolve their turn immediately so the table is
|
||||
// not held hostage until their timer expires.
|
||||
const seatIndex = record.seatMap.indexOf(userId);
|
||||
const acting = record.state !== null && record.state.toActSeat === seatIndex;
|
||||
record.leftUsers.add(userId);
|
||||
record.awayUsers.add(userId);
|
||||
if (acting) resolveActingSeat(record);
|
||||
} else {
|
||||
const seatIndex = record.seatMap.indexOf(userId);
|
||||
record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
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,
|
||||
|
|
@ -187,3 +192,94 @@ describe("blackjack table registry: table management", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue