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:
parent
559ee64258
commit
f7bb8f1e9f
6 changed files with 110 additions and 6 deletions
|
|
@ -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<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. */
|
||||
async function awardGameResults(record: PokerTableRecord): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue