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:
parent
56e3d253c9
commit
e9e89929f9
4 changed files with 603 additions and 0 deletions
12
src/components/frontend/minigames/poker/PokerPopups.tsx
Normal file
12
src/components/frontend/minigames/poker/PokerPopups.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/components/frontend/minigames/poker/pokerPopups.ts
Normal file
175
src/components/frontend/minigames/poker/pokerPopups.ts
Normal 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;
|
||||||
|
}
|
||||||
86
src/components/frontend/minigames/poker/usePokerPopups.ts
Normal file
86
src/components/frontend/minigames/poker/usePokerPopups.ts
Normal 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;
|
||||||
|
}
|
||||||
330
tests/int/poker-popups.int.spec.ts
Normal file
330
tests/int/poker-popups.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue