diff --git a/src/lib/minigames/poker/tables.ts b/src/lib/minigames/poker/tables.ts index b98dd39..4841cbe 100644 --- a/src/lib/minigames/poker/tables.ts +++ b/src/lib/minigames/poker/tables.ts @@ -30,7 +30,7 @@ import { STARTING_STACK, TURN_TIMEOUT_MS, } from "./constants"; -import { advanceState, createTableState } from "./engine"; +import { advanceState, awardFoldOut, createTableState } from "./engine"; import { awardPokerGameResult, type PokerPlacementResult } from "./stats"; import type { PokerActionKind, @@ -256,6 +256,46 @@ function countHandWins(record: PokerTableRecord): void { } } +/** In-app bell notification: the game auto-ended because everyone else left. */ +async function notifyLastPlayer(record: PokerTableRecord): Promise { + 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: "poker:everyone-left", + title: "Poker game ended", + message: "Everyone left the table, so the game ended. Check your final placement.", + link: "/minigames/poker", + }); + } catch { + // Payload unavailable in this runtime; the game already ended client-side. + } +} + +/** + * One seated player remains and every other seat left mid-game: award the + * remaining player the live pot (leavers forfeit their committed chips) and + * complete the game immediately instead of waiting out the next-hand timer. + */ +function finalizeForLastPlayer(record: PokerTableRecord): void { + const current = record.state; + if (!current || current.phase === "game-complete") return; + clearTurnTimer(record); + clearNextHandTimer(record); + let next = eliminateLeavers(current, record); + if (next.phase === "betting") next = awardFoldOut(next); + record.state = advanceState(next); + record.nextHandAt = null; + record.version += 1; + afterMutation(record); + void notifyLastPlayer(record); +} + /** Fire-and-forget XP/stats award on game completion; never blocks the table. */ async function awardGameResults(record: PokerTableRecord): Promise { const state = record.state; @@ -308,7 +348,18 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot const seated = seatIndex >= 0 && !record.leftUsers.has(userId); const state = record.state; const inEngine = seated && state !== null && seatIndex < state.players.length; - const you = state && inEngine ? buildPrivateView(state, seatIndex) : null; + const base = state && inEngine ? buildPrivateView(state, seatIndex) : null; + 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, @@ -481,6 +532,9 @@ export function leaveTable(tableId: string, userId: number): void { record.version += 1; rekeyTurnTimer(record); scheduleDisposalIfEmpty(record); + if (record.inGame && seatedCountOf(record) === 1 && record.pendingJoins.length === 0) { + finalizeForLastPlayer(record); + } pushTable(record); } diff --git a/src/lib/minigames/poker/types.ts b/src/lib/minigames/poker/types.ts index 651f806..9f01b44 100644 --- a/src/lib/minigames/poker/types.ts +++ b/src/lib/minigames/poker/types.ts @@ -163,6 +163,8 @@ export interface PublicPlayerView { readonly allIn: boolean; readonly hasActed: boolean; readonly eliminated: boolean; + /** True from the moment the user leaves mid-game (before the forfeit lands). */ + readonly left: boolean; /** Populated only for the viewer or for live hands at showdown. */ readonly holeCards: readonly Card[] | null; readonly cardCount: number; diff --git a/src/lib/minigames/poker/views.ts b/src/lib/minigames/poker/views.ts index 2c4e7d7..84bcb8f 100644 --- a/src/lib/minigames/poker/views.ts +++ b/src/lib/minigames/poker/views.ts @@ -27,9 +27,8 @@ function projectPlayer( ): PublicPlayerView { const isViewer = viewerSeat !== null && player.seatId === viewerSeat; const reveal = (state.phase === "showdown" && !player.folded) || isViewer; - const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0 - ? player.holeCards - : null; + const holeCards: readonly Card[] | null = + reveal && player.holeCards.length > 0 ? player.holeCards : null; return { seatId: player.seatId, name: player.name, @@ -40,6 +39,9 @@ function projectPlayer( allIn: player.allIn, hasActed: player.hasActed, eliminated: player.eliminated, + // The engine has no leave concept; the registry layer overrides this + // for users recorded in leftUsers (see buildSnapshot in tables.ts). + left: false, holeCards, cardCount: player.holeCards.length, }; diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 54fac6f..6e6e043 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -57,6 +57,8 @@ export function notificationLabel(type?: string | null): string { switch (type) { case "poker:force-ended": return "Poker game ended"; + case "poker:everyone-left": + return "Everyone left the table"; case "market:offer": return "New offer"; case "market:counter": diff --git a/src/lib/notifications/notificationTypes.ts b/src/lib/notifications/notificationTypes.ts index cab87ae..8b17750 100644 --- a/src/lib/notifications/notificationTypes.ts +++ b/src/lib/notifications/notificationTypes.ts @@ -18,6 +18,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [ { label: "Ticket replies", value: "ticket:reply" }, { label: "Ticket status changes", value: "ticket:status" }, { label: "Poker", value: "poker:force-ended" }, + { label: "Poker game auto-ended", value: "poker:everyone-left" }, ] as const; export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"]; export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value); diff --git a/tests/int/poker-tables.int.spec.ts b/tests/int/poker-tables.int.spec.ts index 7ba6d75..fcde141 100644 --- a/tests/int/poker-tables.int.spec.ts +++ b/tests/int/poker-tables.int.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { createTable, joinTable, startGame } from "@/lib/minigames/poker/tables"; +import { + createTable, + getSnapshotFor, + joinTable, + leaveTable, + startGame, +} from "@/lib/minigames/poker/tables"; /** * Regression tests for the registry's game-start path. Guards against the @@ -56,3 +62,40 @@ describe("poker table registry: startGame", () => { } }); }); + +describe("poker table registry: last player standing", () => { + it("ends the game immediately when everyone else leaves mid-hand", () => { + const tableId = createTable(9301, "Host"); + joinTable(tableId, 9302, "Guest"); + startGame(tableId, 9301); + + leaveTable(tableId, 9302); + + const remaining = getSnapshotFor(tableId, 9301); + expect(remaining.you?.phase).toBe("game-complete"); + const players = remaining.you?.players ?? []; + const hostPlayer = players.find((player) => player.seatId === remaining.you?.viewerSeat); + const guestPlayer = players.find((player) => player.seatId !== remaining.you?.viewerSeat); + expect(hostPlayer?.eliminated).toBe(false); + expect(hostPlayer?.left).toBe(false); + // The leaver's committed blind is forfeited (leavers take nothing from + // the pot), so the host only receives their own small blind back. + expect(hostPlayer?.stack).toBe(1000); + expect(guestPlayer?.eliminated).toBe(true); + expect(guestPlayer?.left).toBe(true); + }); + + it("does not end the game while two or more players remain seated", () => { + const tableId = createTable(9401, "Host"); + joinTable(tableId, 9402, "Second"); + joinTable(tableId, 9403, "Third"); + startGame(tableId, 9401); + + leaveTable(tableId, 9403); + + const snapshot = getSnapshotFor(tableId, 9401); + expect(snapshot.you?.phase).not.toBe("game-complete"); + const third = getSnapshotFor(tableId, 9403); + expect(third.you).toBeNull(); + }); +});