1
0
Fork 0

feat(poker): mark leavers immediately and auto-end when one player remains

Leavers now flip a left flag on the very next snapshot, the felt announces who left, and a game left with a single seated player resolves at once with a bell notification instead of waiting out the hand timer.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 17:06:57 -04:00
parent 559ee64258
commit f7bb8f1e9f
6 changed files with 110 additions and 6 deletions

View file

@ -30,7 +30,7 @@ import {
STARTING_STACK, STARTING_STACK,
TURN_TIMEOUT_MS, TURN_TIMEOUT_MS,
} from "./constants"; } from "./constants";
import { advanceState, createTableState } from "./engine"; import { advanceState, awardFoldOut, createTableState } from "./engine";
import { awardPokerGameResult, type PokerPlacementResult } from "./stats"; import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
import type { import type {
PokerActionKind, 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<void> {
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. */ /** Fire-and-forget XP/stats award on game completion; never blocks the table. */
async function awardGameResults(record: PokerTableRecord): Promise<void> { async function awardGameResults(record: PokerTableRecord): Promise<void> {
const state = record.state; const state = record.state;
@ -308,7 +348,18 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot
const seated = seatIndex >= 0 && !record.leftUsers.has(userId); const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
const state = record.state; const state = record.state;
const inEngine = seated && state !== null && seatIndex < state.players.length; 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; const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
return { return {
tableId: record.id, tableId: record.id,
@ -481,6 +532,9 @@ export function leaveTable(tableId: string, userId: number): void {
record.version += 1; record.version += 1;
rekeyTurnTimer(record); rekeyTurnTimer(record);
scheduleDisposalIfEmpty(record); scheduleDisposalIfEmpty(record);
if (record.inGame && seatedCountOf(record) === 1 && record.pendingJoins.length === 0) {
finalizeForLastPlayer(record);
}
pushTable(record); pushTable(record);
} }

View file

@ -163,6 +163,8 @@ export interface PublicPlayerView {
readonly allIn: boolean; readonly allIn: boolean;
readonly hasActed: boolean; readonly hasActed: boolean;
readonly eliminated: 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. */ /** Populated only for the viewer or for live hands at showdown. */
readonly holeCards: readonly Card[] | null; readonly holeCards: readonly Card[] | null;
readonly cardCount: number; readonly cardCount: number;

View file

@ -27,9 +27,8 @@ function projectPlayer(
): PublicPlayerView { ): PublicPlayerView {
const isViewer = viewerSeat !== null && player.seatId === viewerSeat; const isViewer = viewerSeat !== null && player.seatId === viewerSeat;
const reveal = (state.phase === "showdown" && !player.folded) || isViewer; const reveal = (state.phase === "showdown" && !player.folded) || isViewer;
const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0 const holeCards: readonly Card[] | null =
? player.holeCards reveal && player.holeCards.length > 0 ? player.holeCards : null;
: null;
return { return {
seatId: player.seatId, seatId: player.seatId,
name: player.name, name: player.name,
@ -40,6 +39,9 @@ function projectPlayer(
allIn: player.allIn, allIn: player.allIn,
hasActed: player.hasActed, hasActed: player.hasActed,
eliminated: player.eliminated, 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, holeCards,
cardCount: player.holeCards.length, cardCount: player.holeCards.length,
}; };

View file

@ -57,6 +57,8 @@ export function notificationLabel(type?: string | null): string {
switch (type) { switch (type) {
case "poker:force-ended": case "poker:force-ended":
return "Poker game ended"; return "Poker game ended";
case "poker:everyone-left":
return "Everyone left the table";
case "market:offer": case "market:offer":
return "New offer"; return "New offer";
case "market:counter": case "market:counter":

View file

@ -18,6 +18,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
{ label: "Ticket replies", value: "ticket:reply" }, { label: "Ticket replies", value: "ticket:reply" },
{ label: "Ticket status changes", value: "ticket:status" }, { label: "Ticket status changes", value: "ticket:status" },
{ label: "Poker", value: "poker:force-ended" }, { label: "Poker", value: "poker:force-ended" },
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
] as const; ] as const;
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"]; export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value); export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);

View file

@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest"; 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 * 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();
});
});