diff --git a/src/lib/minigames/blackjack/tables.ts b/src/lib/minigames/blackjack/tables.ts index de0340b..02aa21e 100644 --- a/src/lib/minigames/blackjack/tables.ts +++ b/src/lib/minigames/blackjack/tables.ts @@ -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(); 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); diff --git a/tests/int/blackjack-tables.int.spec.ts b/tests/int/blackjack-tables.int.spec.ts index 26a637f..ea7c4a1 100644 --- a/tests/int/blackjack-tables.int.spec.ts +++ b/tests/int/blackjack-tables.int.spec.ts @@ -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(); + }); +});