diff --git a/src/components/frontend/minigames/poker/PokerPopups.tsx b/src/components/frontend/minigames/poker/PokerPopups.tsx new file mode 100644 index 0000000..72ad490 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerPopups.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { ActivePokerPopup } from "./usePokerPopups"; + +export function PokerPopupBubble({ popup }: { readonly popup: ActivePokerPopup }) { + return ( + + ); +} diff --git a/src/components/frontend/minigames/poker/pokerPopups.ts b/src/components/frontend/minigames/poker/pokerPopups.ts new file mode 100644 index 0000000..6376801 --- /dev/null +++ b/src/components/frontend/minigames/poker/pokerPopups.ts @@ -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 { + if (previousBet === 0 && nextBet > 0) return "bet"; + if (nextBet > previousBet) return "raise"; + return "call"; +} + +function wagerLabel( + action: Extract, + 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; +} diff --git a/src/components/frontend/minigames/poker/usePokerPopups.ts b/src/components/frontend/minigames/poker/usePokerPopups.ts new file mode 100644 index 0000000..5a0c734 --- /dev/null +++ b/src/components/frontend/minigames/poker/usePokerPopups.ts @@ -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 = { + 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([]); + const previousSnapshotRef = useRef(null); + const seenKeysRef = useRef>(new Set()); + const timersRef = useRef[]>([]); + + 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; +} diff --git a/tests/int/poker-popups.int.spec.ts b/tests/int/poker-popups.int.spec.ts new file mode 100644 index 0000000..3280936 --- /dev/null +++ b/tests/int/poker-popups.int.spec.ts @@ -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 { + 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"] { + 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); + }); +});