1
0
Fork 0

feat(poker): add floating action popups with staggered blind intro

Snapshot diffs surface fold, check, bet, call, raise, all-in, and pot-win text over each seat, leavers announce centrally, and new hands open with a small-blind then big-blind sequence before the first real turn. Popups fire exactly once per event with reduced-motion fallbacks.

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:07:20 -04:00
parent 56e3d253c9
commit e9e89929f9
4 changed files with 603 additions and 0 deletions

View file

@ -0,0 +1,12 @@
"use client";
import { cn } from "@/lib/utils";
import type { ActivePokerPopup } from "./usePokerPopups";
export function PokerPopupBubble({ popup }: { readonly popup: ActivePokerPopup }) {
return (
<span aria-hidden="true" className={cn("poker-popup", `poker-popup-${popup.tone}`)}>
{popup.text}
</span>
);
}

View file

@ -0,0 +1,175 @@
import type { PokerActionKind, PublicPlayerView } from "@/lib/minigames/poker";
import { BLIND_STAGGER_MS, type PokerSoundSnapshot } from "./pokerSoundEvents";
export type PokerPopupTone = "action" | "wager" | "win" | "fold" | "leave";
export interface PokerPopupEvent {
readonly key: string;
readonly seatId: number;
readonly text: string;
readonly tone: PokerPopupTone;
/** ms to wait before this popup appears (staggered blind sequence), default 0. */
readonly delay?: number;
}
function playerBySeat(
players: readonly PublicPlayerView[],
seatId: number,
): PublicPlayerView | null {
return players.find((player) => player.seatId === seatId) ?? null;
}
function wagerAction(
previousBet: number,
nextBet: number,
): Extract<PokerActionKind, "bet" | "call" | "raise"> {
if (previousBet === 0 && nextBet > 0) return "bet";
if (nextBet > previousBet) return "raise";
return "call";
}
function wagerLabel(
action: Extract<PokerActionKind, "bet" | "call" | "raise">,
amount: number,
allIn: boolean,
): string {
const formatted = `+${amount.toLocaleString()}`;
if (allIn) return `All-in ${formatted}`;
if (action === "bet") return `Bet ${formatted}`;
if (action === "raise") return `Raise ${formatted}`;
return `Call ${formatted}`;
}
export function popupEventsForTransition(
previous: PokerSoundSnapshot | null,
next: PokerSoundSnapshot,
): readonly PokerPopupEvent[] {
if (!previous || previous.version === next.version) return [];
const previousView = previous.you;
const nextView = next.you;
if (!nextView) return [];
const events: PokerPopupEvent[] = [];
const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
const sameHandAndStreet =
previousView !== null &&
previousView.handNumber === nextView.handNumber &&
previousView.street === nextView.street;
// New hand: present the blind posts as a staggered mini-sequence (small
// blind, then big blind) so the hand opens with a readable flow before the
// first real turn. The previous view may be the waiting room (no seat view
// yet) when this is the game's first dealt hand.
if (
nextView.phase === "betting" &&
(previousView === null || nextView.handNumber > previousView.handNumber)
) {
events.push({
key: `${transitionKey}:popup:blind-sb`,
seatId: nextView.smallBlindSeat,
text: `Small blind +${nextView.config.smallBlind.toLocaleString()}`,
tone: "wager",
delay: 0,
});
events.push({
key: `${transitionKey}:popup:blind-bb`,
seatId: nextView.bigBlindSeat,
text: `Big blind +${nextView.config.bigBlind.toLocaleString()}`,
tone: "wager",
delay: BLIND_STAGGER_MS,
});
return events;
}
if (!previousView) return events;
if (sameHandAndStreet && previousView.phase === "betting" && nextView.phase === "betting") {
for (const nextPlayer of nextView.players) {
const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
if (!previousPlayer) continue;
if (!previousPlayer.folded && nextPlayer.folded) {
events.push({
key: `${transitionKey}:popup:fold:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: "Fold",
tone: "fold",
});
continue;
}
if (nextPlayer.folded) continue;
// Same committed-growth rule as the sound mapper: blinds post on a new
// hand (handNumber differs), so any mid-street increase is the player's
// own wager, even when they already acted earlier in the street.
const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
if (committedDelta > 0) {
events.push({
key: `${transitionKey}:popup:wager:${nextPlayer.seatId}:${committedDelta}`,
seatId: nextPlayer.seatId,
text: wagerLabel(
wagerAction(previousView.currentBet, nextView.currentBet),
committedDelta,
nextPlayer.allIn,
),
tone: "wager",
});
} else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
events.push({
key: `${transitionKey}:popup:check:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: "Check",
tone: "action",
});
}
}
}
// Leaving is any of: a vanished seat (removed between hands), a mid-game
// leaver whose view flips the left flag immediately, or the later eliminated
// forfeit. Busted stacks are never eliminated or left, so they never read
// as a leave; the left||eliminated pair fires exactly once per player.
for (const previousPlayer of previousView.players) {
const nextPlayer = playerBySeat(nextView.players, previousPlayer.seatId);
if (!nextPlayer) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: `${previousPlayer.name} left the table`,
tone: "leave",
});
continue;
}
const leftBefore = previousPlayer.left || previousPlayer.eliminated;
const leftNow = nextPlayer.left || nextPlayer.eliminated;
if (leftNow && !leftBefore) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: `${previousPlayer.name} left the table`,
tone: "leave",
});
}
}
// A hand ends in exactly one of showdown or hand-complete, and both phases
// carry showdown results, so every winner gets one "+N" popup, never twice.
const handEnded =
(nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
(nextView.phase === "showdown" && previousView.phase !== "showdown");
if (handEnded && nextView.showdown) {
for (const result of nextView.showdown.results) {
if (result.chipsWon <= 0) continue;
events.push({
key: `${transitionKey}:popup:win:${result.seatId}:${result.chipsWon}`,
seatId: result.seatId,
text: `+${result.chipsWon.toLocaleString()}`,
tone: "win",
});
}
}
return events;
}

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { popupEventsForTransition, type PokerPopupEvent } from "./pokerPopups";
import type { PokerSoundSnapshot } from "./pokerSoundEvents";
export type ActivePokerPopup = PokerPopupEvent;
const POPUP_LIFETIME_MS = 1500;
const LEAVE_POPUP_LIFETIME_MS = 3000;
const MAX_ACTIVE_POPUPS = 12;
const MAX_SEEN_KEYS = 256;
const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = {
action: POPUP_LIFETIME_MS,
wager: POPUP_LIFETIME_MS,
win: POPUP_LIFETIME_MS,
fold: POPUP_LIFETIME_MS,
leave: LEAVE_POPUP_LIFETIME_MS,
};
export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActivePokerPopup[] {
const [popups, setPopups] = useState<readonly ActivePokerPopup[]>([]);
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
const seenKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
if (!snapshot) {
previousSnapshotRef.current = null;
return;
}
const previousSnapshot = previousSnapshotRef.current;
if (previousSnapshot) {
const fresh = popupEventsForTransition(previousSnapshot, snapshot).filter((event) => {
if (seenKeysRef.current.has(event.key)) return false;
seenKeysRef.current.add(event.key);
return true;
});
while (seenKeysRef.current.size > MAX_SEEN_KEYS) {
const oldest = seenKeysRef.current.values().next().value;
if (typeof oldest !== "string") break;
seenKeysRef.current.delete(oldest);
}
if (fresh.length > 0) {
const immediate: ActivePokerPopup[] = [];
for (const event of fresh) {
const delay = event.delay ?? 0;
if (delay > 0) {
timersRef.current.push(
setTimeout(() => {
setPopups((current) => [...current, event].slice(-MAX_ACTIVE_POPUPS));
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}, delay),
);
} else {
immediate.push(event);
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}
}
if (immediate.length > 0) {
setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS));
}
}
}
previousSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
return () => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
};
}, []);
return popups;
}

View file

@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
import {
popupEventsForTransition,
type PokerPopupEvent,
} from "@/components/frontend/minigames/poker/pokerPopups";
import {
isTopSeatPosition,
seatPosition,
} from "@/components/frontend/minigames/poker/seatPosition";
import type { PokerSoundSnapshot } from "@/components/frontend/minigames/poker/pokerSoundEvents";
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
return {
seatId,
name: `Player ${seatId + 1}`,
stack: 1000,
committed: 0,
totalCommitted: 0,
folded: false,
allIn: false,
hasActed: false,
eliminated: false,
left: false,
holeCards: null,
cardCount: 2,
...overrides,
};
}
function view(overrides: Partial<PokerSoundSnapshot["you"]> = {}): PokerSoundSnapshot["you"] {
return {
communityCards: [],
config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: 30000 },
currentBet: 0,
smallBlindSeat: 0,
bigBlindSeat: 1,
finalPlacements: null,
handNumber: 1,
phase: "betting",
players: [player(0), player(1)],
showdown: null,
street: "preflop",
toActSeat: 0,
viewerSeat: 0,
yourTurn: false,
...overrides,
};
}
function snapshot(
version: number,
nextView: PokerSoundSnapshot["you"] = view(),
): PokerSoundSnapshot {
return { version, you: nextView };
}
function showdown(results: Array<{ seatId: number; chipsWon: number }>): ShowdownOutcome {
return {
pots: [],
results: results.map((item) => ({
seatId: item.seatId,
handRank: null,
bestHand: null,
chipsWon: item.chipsWon,
netChange: item.chipsWon,
placement: 1,
})),
};
}
function bySeat(events: readonly PokerPopupEvent[], seatId: number): PokerPopupEvent | undefined {
return events.find((event) => event.seatId === seatId);
}
describe("poker popup transition events", () => {
it("labels folds, checks, and wagers with amounts over the acting seat", () => {
const events = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1)] })),
snapshot(
2,
view({
currentBet: 20,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { folded: true, hasActed: true }),
],
}),
),
);
const checks = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1)] })),
snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
);
expect(bySeat(events, 0)).toEqual(
expect.objectContaining({ text: "Bet +20", tone: "wager", seatId: 0 }),
);
expect(bySeat(events, 1)).toEqual(
expect.objectContaining({ text: "Fold", tone: "fold", seatId: 1 }),
);
expect(checks).toEqual([expect.objectContaining({ text: "Check", tone: "action", seatId: 1 })]);
});
it("distinguishes calls from raises and calls out all-ins", () => {
const raise = popupEventsForTransition(
snapshot(
4,
view({
currentBet: 20,
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
}),
),
snapshot(
5,
view({
currentBet: 60,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
],
}),
),
);
const call = popupEventsForTransition(
snapshot(
6,
view({
currentBet: 60,
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
}),
),
snapshot(
7,
view({
currentBet: 60,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
],
}),
),
);
const allIn = popupEventsForTransition(
snapshot(8, view({ players: [player(0), player(1)] })),
snapshot(
9,
view({
currentBet: 1000,
players: [
player(0),
player(1, { committed: 1000, totalCommitted: 1000, hasActed: true, allIn: true }),
],
}),
),
);
expect(bySeat(raise, 1)?.text).toBe("Raise +60");
expect(bySeat(call, 1)?.text).toBe("Call +60");
expect(bySeat(allIn, 1)?.text).toBe("All-in +1,000");
});
it("pops winnings over every winning seat on showdown and hand-complete", () => {
const multiWinner = showdown([
{ seatId: 0, chipsWon: 120 },
{ seatId: 1, chipsWon: 0 },
{ seatId: 2, chipsWon: 340 },
]);
const showdownEvents = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1), player(2)] })),
snapshot(
2,
view({
phase: "showdown",
players: [player(0), player(1), player(2)],
showdown: multiWinner,
}),
),
);
const foldOutEvents = popupEventsForTransition(
snapshot(3, view({ players: [player(0), player(1)] })),
snapshot(
4,
view({
phase: "hand-complete",
players: [player(0), player(1, { folded: true })],
showdown: showdown([{ seatId: 0, chipsWon: 45 }]),
}),
),
);
expect(showdownEvents).toEqual([
expect.objectContaining({ text: "+120", tone: "win", seatId: 0 }),
expect.objectContaining({ text: "+340", tone: "win", seatId: 2 }),
]);
expect(foldOutEvents).toEqual([
expect.objectContaining({ text: "+45", tone: "win", seatId: 0 }),
]);
});
it("stays silent on identical versions and plays the blind sequence at a new hand", () => {
const current = snapshot(7, view({ yourTurn: true }));
expect(popupEventsForTransition(current, current)).toEqual([]);
const blinds = popupEventsForTransition(
snapshot(5),
snapshot(
6,
view({
handNumber: 2,
currentBet: 20,
players: [
player(0, { committed: 10, totalCommitted: 10 }),
player(1, { committed: 20, totalCommitted: 20 }),
],
}),
),
);
expect(blinds).toEqual([
expect.objectContaining({
text: "Small blind +10",
tone: "wager",
seatId: 0,
delay: 0,
}),
expect.objectContaining({
text: "Big blind +20",
tone: "wager",
seatId: 1,
delay: 700,
}),
]);
});
it("plays the blind sequence for the game's first hand out of the waiting room", () => {
const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
const events = popupEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
expect(events).toEqual([
expect.objectContaining({ text: "Small blind +10", seatId: 0, delay: 0 }),
expect.objectContaining({ text: "Big blind +20", seatId: 1, delay: 700 }),
]);
});
});
describe("player leave detection", () => {
it("announces players who vanish from the table between transitions", () => {
const events = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1), player(2)] })),
snapshot(2, view({ players: [player(0), player(1)] })),
);
expect(events).toEqual([
expect.objectContaining({
text: "Player 3 left the table",
tone: "leave",
seatId: 2,
}),
]);
});
it("announces a mid-game leaver the moment the left flag flips", () => {
const events = popupEventsForTransition(
snapshot(3, view({ players: [player(0), player(1)] })),
snapshot(4, view({ players: [player(0), player(1, { left: true })] })),
);
expect(events).toEqual([
expect.objectContaining({
text: "Player 2 left the table",
tone: "leave",
seatId: 1,
}),
]);
});
it("announces the eliminated forfeit only when the player was not already marked left", () => {
const forfeitOnly = popupEventsForTransition(
snapshot(5, view({ players: [player(0), player(1)] })),
snapshot(6, view({ players: [player(0), player(1, { eliminated: true })] })),
);
const alreadyLeft = popupEventsForTransition(
snapshot(7, view({ players: [player(0), player(1, { left: true })] })),
snapshot(8, view({ players: [player(0), player(1, { left: true, eliminated: true })] })),
);
expect(forfeitOnly).toEqual([
expect.objectContaining({ text: "Player 2 left the table", tone: "leave", seatId: 1 }),
]);
expect(alreadyLeft).toEqual([]);
});
it("never mistakes a busted stack for a leaver", () => {
const events = popupEventsForTransition(
snapshot(5, view({ players: [player(0), player(1)] })),
snapshot(6, view({ players: [player(0), player(1, { stack: 0 })] })),
);
expect(events).toEqual([]);
});
});
describe("isTopSeatPosition", () => {
it("marks top-row arc classes as top seats", () => {
expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true);
expect(isTopSeatPosition("poker-seat-arc-center-left")).toBe(true);
expect(isTopSeatPosition("poker-seat-arc-center-right")).toBe(true);
});
it("marks side and corner arc classes as non-top seats", () => {
expect(isTopSeatPosition("poker-seat-arc-left")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-right")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-far-left")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-far-right")).toBe(false);
});
it("matches real seat layouts: one and three opponents put a seat on top, two do not", () => {
expect(isTopSeatPosition(seatPosition(0, 1))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 2))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 2))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 3))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 3))).toBe(false);
expect(isTopSeatPosition(seatPosition(2, 3))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 4))).toBe(true);
expect(isTopSeatPosition(seatPosition(2, 4))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 4))).toBe(false);
expect(isTopSeatPosition(seatPosition(3, 4))).toBe(false);
});
});