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