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,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue