diff --git a/src/components/frontend/minigames/poker/PokerSoundToggle.tsx b/src/components/frontend/minigames/poker/PokerSoundToggle.tsx
new file mode 100644
index 0000000..43d57df
--- /dev/null
+++ b/src/components/frontend/minigames/poker/PokerSoundToggle.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Volume2, VolumeX } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface PokerSoundToggleProps {
+ readonly muted: boolean;
+ readonly volume: number;
+ readonly onToggleMuted: () => void;
+ readonly onVolumeChange: (volume: number) => void;
+}
+
+export function PokerSoundToggle({
+ muted,
+ volume,
+ onToggleMuted,
+ onVolumeChange,
+}: PokerSoundToggleProps) {
+ return (
+
+
+ onVolumeChange(Number(event.target.value))}
+ aria-label="Poker sound volume"
+ className="poker-sound-volume"
+ />
+
+ );
+}
diff --git a/src/components/frontend/minigames/poker/pokerSound.ts b/src/components/frontend/minigames/poker/pokerSound.ts
new file mode 100644
index 0000000..1d49d05
--- /dev/null
+++ b/src/components/frontend/minigames/poker/pokerSound.ts
@@ -0,0 +1,264 @@
+import type { PokerSoundEvent } from "./pokerSoundEvents";
+
+export const DEFAULT_POKER_SOUND_VOLUME = 0.28;
+
+type OscillatorShape = OscillatorType;
+
+function assertNever(value: never): never {
+ throw new Error(`Unknown poker sound event: ${JSON.stringify(value)}`);
+}
+
+function clampVolume(value: number): number {
+ if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
+ return Math.min(1, Math.max(0, value));
+}
+
+export class PokerSoundEngine {
+ private context: AudioContext | null = null;
+ private masterGain: GainNode | null = null;
+ private jazzTimer: number | null = null;
+ private jazzRunning = false;
+ private jazzNextAt = 0;
+ private muted = false;
+ private volume = DEFAULT_POKER_SOUND_VOLUME;
+
+ public setMuted(muted: boolean): void {
+ this.muted = muted;
+ this.setMasterGain();
+ if (muted) {
+ this.stopJazzSchedule();
+ } else if (this.context) {
+ this.startJazzSchedule();
+ }
+ }
+
+ public setVolume(volume: number): void {
+ this.volume = clampVolume(volume);
+ this.setMasterGain();
+ }
+
+ public unlock(): void {
+ const context = this.ensureContext();
+ if (!context) return;
+ if (context.state === "suspended") {
+ void context.resume().catch(() => undefined);
+ }
+ if (!this.muted) this.startJazzSchedule();
+ }
+
+ public playUiClick(): void {
+ this.unlock();
+ const context = this.context;
+ if (!context || this.muted) return;
+ this.scheduleTone(720, 0.045, 0.045, context.currentTime, "triangle");
+ }
+
+ public playEvent(event: PokerSoundEvent): void {
+ const context = this.context;
+ if (!context || this.muted) return;
+
+ switch (event.type) {
+ case "hand-start":
+ this.playStreetDeal(2, context.currentTime);
+ return;
+ case "turn-gained":
+ this.playTurnGained(context.currentTime);
+ return;
+ case "turn-lost":
+ this.scheduleTone(330, 0.12, 0.07, context.currentTime, "sine");
+ return;
+ case "street-deal":
+ this.playStreetDeal(event.cardCount, context.currentTime);
+ return;
+ case "wager":
+ this.playWager(event.action, context.currentTime);
+ return;
+ case "fold":
+ this.scheduleTone(180, 0.16, 0.08, context.currentTime, "triangle");
+ return;
+ case "check":
+ this.scheduleTone(520, 0.07, 0.045, context.currentTime, "triangle");
+ return;
+ case "showdown":
+ this.playShowdown(context.currentTime);
+ return;
+ case "hand-result":
+ this.playHandResult(event.result, context.currentTime);
+ return;
+ case "game-complete":
+ this.playGameComplete(context.currentTime);
+ return;
+ default:
+ return assertNever(event);
+ }
+ }
+
+ public dispose(): void {
+ this.stopJazzSchedule();
+ const context = this.context;
+ this.context = null;
+ this.masterGain = null;
+ if (context && context.state !== "closed") void context.close().catch(() => undefined);
+ }
+
+ private ensureContext(): AudioContext | null {
+ if (this.context) return this.context;
+ if (typeof window === "undefined" || typeof window.AudioContext !== "function") return null;
+
+ try {
+ const context = new window.AudioContext();
+ const masterGain = context.createGain();
+ masterGain.connect(context.destination);
+ this.context = context;
+ this.masterGain = masterGain;
+ this.setMasterGain();
+ return context;
+ } catch (error) {
+ if (error instanceof Error) return null;
+ return null;
+ }
+ }
+
+ private setMasterGain(): void {
+ if (!this.masterGain || !this.context) return;
+ const target = this.muted ? 0 : this.volume;
+ this.masterGain.gain.setTargetAtTime(target, this.context.currentTime, 0.035);
+ }
+
+ private scheduleTone(
+ frequency: number,
+ duration: number,
+ level: number,
+ start: number,
+ shape: OscillatorShape,
+ detune = 0,
+ ): void {
+ const context = this.context;
+ const masterGain = this.masterGain;
+ if (!context || !masterGain) return;
+
+ const oscillator = context.createOscillator();
+ const gain = context.createGain();
+ oscillator.type = shape;
+ oscillator.frequency.setValueAtTime(frequency, start);
+ oscillator.detune.setValueAtTime(detune, start);
+ gain.gain.setValueAtTime(0.0001, start);
+ gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, level), start + 0.012);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ oscillator.connect(gain);
+ gain.connect(masterGain);
+ oscillator.start(start);
+ oscillator.stop(start + duration + 0.04);
+ }
+
+ private scheduleNoise(duration: number, level: number, start: number, highPass = 900): void {
+ const context = this.context;
+ const masterGain = this.masterGain;
+ if (!context || !masterGain) return;
+
+ const frameCount = Math.max(1, Math.floor(context.sampleRate * duration));
+ const buffer = context.createBuffer(1, frameCount, context.sampleRate);
+ const channel = buffer.getChannelData(0);
+ for (let index = 0; index < channel.length; index += 1) {
+ channel[index] = Math.random() * 2 - 1;
+ }
+
+ const source = context.createBufferSource();
+ const filter = context.createBiquadFilter();
+ const gain = context.createGain();
+ source.buffer = buffer;
+ filter.type = "highpass";
+ filter.frequency.setValueAtTime(highPass, start);
+ gain.gain.setValueAtTime(level, start);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ source.connect(filter);
+ filter.connect(gain);
+ gain.connect(masterGain);
+ source.start(start);
+ source.stop(start + duration + 0.04);
+ }
+
+ private playTurnGained(start: number): void {
+ this.scheduleTone(660, 0.16, 0.08, start, "sine");
+ this.scheduleTone(880, 0.2, 0.07, start + 0.11, "sine");
+ }
+
+ private playStreetDeal(cardCount: number, start: number): void {
+ for (let index = 0; index < cardCount; index += 1) {
+ const offset = index * 0.075;
+ this.scheduleNoise(0.12, 0.035, start + offset, 1200);
+ this.scheduleTone(440 + index * 35, 0.08, 0.025, start + offset, "triangle");
+ }
+ }
+
+ private playWager(action: "bet" | "call" | "raise", start: number): void {
+ this.scheduleNoise(0.07, 0.08, start, 650);
+ const frequency = action === "raise" ? 620 : action === "bet" ? 540 : 460;
+ this.scheduleTone(frequency, 0.1, 0.055, start + 0.035, "triangle");
+ }
+
+ private playShowdown(start: number): void {
+ this.scheduleNoise(0.22, 0.045, start, 700);
+ this.scheduleTone(392, 0.45, 0.05, start, "sine");
+ this.scheduleTone(494, 0.45, 0.045, start + 0.04, "sine");
+ this.scheduleTone(587, 0.5, 0.04, start + 0.08, "sine");
+ }
+
+ private playHandResult(result: "win" | "loss", start: number): void {
+ if (result === "win") {
+ this.scheduleTone(523, 0.22, 0.06, start, "triangle");
+ this.scheduleTone(659, 0.25, 0.06, start + 0.12, "triangle");
+ this.scheduleTone(784, 0.34, 0.07, start + 0.24, "triangle");
+ return;
+ }
+ this.scheduleTone(330, 0.24, 0.055, start, "sine");
+ this.scheduleTone(247, 0.34, 0.05, start + 0.16, "sine");
+ }
+
+ private playGameComplete(start: number): void {
+ [392, 494, 587, 784].forEach((frequency, index) => {
+ this.scheduleTone(frequency, 0.32, 0.055, start + index * 0.13, "triangle");
+ });
+ }
+
+ private startJazzSchedule(): void {
+ if (this.jazzRunning || !this.context || this.muted) return;
+ this.jazzRunning = true;
+ this.jazzNextAt = Math.max(this.context.currentTime + 0.08, this.jazzNextAt);
+ this.scheduleJazzBar();
+ }
+
+ private scheduleJazzBar(): void {
+ const context = this.context;
+ if (!context || !this.jazzRunning || this.muted) return;
+
+ const beat = 60 / 92;
+ const start = Math.max(context.currentTime + 0.04, this.jazzNextAt);
+ const chordSets = [
+ [261.63, 329.63, 392],
+ [220, 277.18, 329.63],
+ [246.94, 311.13, 369.99],
+ [196, 246.94, 293.66],
+ ];
+ const chord = chordSets[Math.floor(start / (beat * 4)) % chordSets.length] ?? chordSets[0];
+
+ chord.forEach((frequency, index) => {
+ this.scheduleTone(frequency, beat * 2.8, 0.012, start, "sine", index * 4 - 4);
+ });
+ for (let beatIndex = 0; beatIndex < 4; beatIndex += 1) {
+ const beatStart = start + beatIndex * beat;
+ this.scheduleTone(82.41 + beatIndex * 3, beat * 0.7, 0.018, beatStart, "triangle");
+ this.scheduleNoise(0.045, 0.012, beatStart + beat * 0.5, 3200);
+ this.scheduleNoise(0.035, 0.009, beatStart + beat * 0.75, 4200);
+ }
+
+ this.jazzNextAt = start + beat * 4;
+ this.jazzTimer = window.setTimeout(() => this.scheduleJazzBar(), beat * 4 * 1000 - 80);
+ }
+
+ private stopJazzSchedule(): void {
+ if (this.jazzTimer !== null) window.clearTimeout(this.jazzTimer);
+ this.jazzTimer = null;
+ this.jazzRunning = false;
+ }
+}
diff --git a/src/components/frontend/minigames/poker/pokerSoundEvents.ts b/src/components/frontend/minigames/poker/pokerSoundEvents.ts
new file mode 100644
index 0000000..ab8331d
--- /dev/null
+++ b/src/components/frontend/minigames/poker/pokerSoundEvents.ts
@@ -0,0 +1,234 @@
+import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
+import type { PokerActionKind, PokerStreet, PublicPlayerView } from "@/lib/minigames/poker";
+
+type PokerSoundView = NonNullable;
+
+export type PokerSoundSnapshot = Pick & {
+ readonly you: Pick<
+ PokerSoundView,
+ | "communityCards"
+ | "currentBet"
+ | "config"
+ | "finalPlacements"
+ | "handNumber"
+ | "phase"
+ | "players"
+ | "showdown"
+ | "smallBlindSeat"
+ | "bigBlindSeat"
+ | "street"
+ | "toActSeat"
+ | "viewerSeat"
+ | "yourTurn"
+ > | null;
+};
+
+export type PokerSoundEvent =
+ | { readonly type: "hand-start"; readonly key: string; readonly handNumber: number }
+ | { readonly type: "turn-gained"; readonly key: string }
+ | { readonly type: "turn-lost"; readonly key: string }
+ | {
+ readonly type: "street-deal";
+ readonly key: string;
+ readonly street: Extract;
+ readonly cardCount: number;
+ }
+ | {
+ readonly type: "wager";
+ readonly key: string;
+ readonly action: Extract;
+ readonly amount: number;
+ readonly seatId: number;
+ /** ms delay before playback (staggered blind sequence), default 0. */
+ readonly delay?: number;
+ }
+ | { readonly type: "fold"; readonly key: string; readonly seatId: number }
+ | { readonly type: "check"; readonly key: string; readonly seatId: number }
+ | { readonly type: "showdown"; readonly key: string }
+ | {
+ readonly type: "hand-result";
+ readonly key: string;
+ readonly result: "win" | "loss";
+ readonly netChange: number;
+ }
+ | { readonly type: "game-complete"; readonly key: string; readonly placement: number | null };
+
+const STREET_CARD_COUNTS: Record, number> = {
+ flop: 3,
+ turn: 1,
+ river: 1,
+};
+
+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 resultForViewer(
+ snapshot: PokerSoundSnapshot,
+): { readonly result: "win" | "loss"; readonly netChange: number } | null {
+ const view = snapshot.you;
+ if (!view) return null;
+ const showdown = view.showdown;
+ const result = showdown?.results.find((item) => item.seatId === view.viewerSeat);
+ if (!result) return null;
+ return {
+ result: result.netChange > 0 ? "win" : "loss",
+ netChange: result.netChange,
+ };
+}
+
+/** Gap between the small-blind and big-blind steps of the new-hand sequence. */
+export const BLIND_STAGGER_MS = 700;
+
+export function soundEventsForTransition(
+ previous: PokerSoundSnapshot | null,
+ next: PokerSoundSnapshot,
+): readonly PokerSoundEvent[] {
+ if (!previous || previous.version === next.version) return [];
+
+ const previousView = previous.you;
+ const nextView = next.you;
+ if (!nextView) return [];
+ const events: PokerSoundEvent[] = [];
+ const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
+
+ // The hand-start chime and staggered blind sequence also cover the very
+ // first dealt hand of a game: the previous view is the waiting room (no
+ // seat view yet), so only the blind seats and config are needed.
+ if (
+ nextView.phase === "betting" &&
+ (previousView === null || nextView.handNumber > previousView.handNumber)
+ ) {
+ events.push({
+ type: "hand-start",
+ key: `${transitionKey}:hand-start:${nextView.handNumber}`,
+ handNumber: nextView.handNumber,
+ });
+ events.push({
+ type: "wager",
+ key: `${transitionKey}:blind-sb`,
+ action: "bet",
+ amount: nextView.config.smallBlind,
+ seatId: nextView.smallBlindSeat,
+ delay: 0,
+ });
+ events.push({
+ type: "wager",
+ key: `${transitionKey}:blind-bb`,
+ action: "bet",
+ amount: nextView.config.bigBlind,
+ seatId: nextView.bigBlindSeat,
+ delay: BLIND_STAGGER_MS,
+ });
+ return events;
+ }
+
+ if (!previousView) return events;
+
+ if (nextView.phase === "betting" && previousView.yourTurn !== nextView.yourTurn) {
+ events.push({
+ type: nextView.yourTurn ? "turn-gained" : "turn-lost",
+ key: `${transitionKey}:turn:${nextView.yourTurn ? "gained" : "lost"}:${nextView.toActSeat ?? "none"}`,
+ });
+ }
+
+ if (
+ previousView.handNumber === nextView.handNumber &&
+ previousView.street !== nextView.street &&
+ nextView.street !== "preflop"
+ ) {
+ events.push({
+ type: "street-deal",
+ key: `${transitionKey}:street:${nextView.communityCards.length}`,
+ street: nextView.street,
+ cardCount: STREET_CARD_COUNTS[nextView.street],
+ });
+ }
+
+ const sameHandAndStreet =
+ previousView.handNumber === nextView.handNumber && previousView.street === nextView.street;
+ 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({
+ type: "fold",
+ key: `${transitionKey}:fold:${nextPlayer.seatId}`,
+ seatId: nextPlayer.seatId,
+ });
+ continue;
+ }
+
+ if (nextPlayer.folded) continue;
+
+ // Committed chips only grow from a player's own actions mid-street, so
+ // any increase is a wager even when the player already acted (e.g. bet
+ // the flop, then call a raise later in the same street).
+ const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
+ if (committedDelta > 0) {
+ events.push({
+ type: "wager",
+ key: `${transitionKey}:wager:${nextPlayer.seatId}:${committedDelta}`,
+ action: wagerAction(previousView.currentBet, nextView.currentBet),
+ amount: committedDelta,
+ seatId: nextPlayer.seatId,
+ });
+ } else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
+ events.push({
+ type: "check",
+ key: `${transitionKey}:check:${nextPlayer.seatId}`,
+ seatId: nextPlayer.seatId,
+ });
+ }
+ }
+ }
+
+ if (nextView.phase === "showdown" && previousView.phase !== "showdown") {
+ events.push({ type: "showdown", key: `${transitionKey}:showdown` });
+ }
+
+ // A hand ends in exactly one of showdown (cards revealed) or hand-complete
+ // (everyone else folded), and both phases carry showdown results, so the
+ // viewer win/loss jingle fires on entry to either one, never twice.
+ const handEnded =
+ (nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
+ (nextView.phase === "showdown" && previousView.phase !== "showdown");
+ if (handEnded) {
+ const result = resultForViewer(next);
+ if (result) {
+ events.push({
+ type: "hand-result",
+ key: `${transitionKey}:result:${result.result}`,
+ result: result.result,
+ netChange: result.netChange,
+ });
+ }
+ }
+
+ if (nextView.phase === "game-complete" && previousView.phase !== "game-complete") {
+ const placement =
+ nextView.finalPlacements?.find((item) => item.seatId === nextView.viewerSeat)?.placement ??
+ null;
+ events.push({
+ type: "game-complete",
+ key: `${transitionKey}:game-complete:${placement ?? "unknown"}`,
+ placement,
+ });
+ }
+
+ return events;
+}
diff --git a/src/components/frontend/minigames/poker/usePokerSounds.ts b/src/components/frontend/minigames/poker/usePokerSounds.ts
new file mode 100644
index 0000000..5c45c17
--- /dev/null
+++ b/src/components/frontend/minigames/poker/usePokerSounds.ts
@@ -0,0 +1,162 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
+import { soundEventsForTransition, type PokerSoundSnapshot } from "./pokerSoundEvents";
+import { DEFAULT_POKER_SOUND_VOLUME, PokerSoundEngine } from "./pokerSound";
+
+const SOUND_SETTINGS_KEY = "polaris:poker-sound-settings";
+
+export interface PokerSoundSettings {
+ readonly muted: boolean;
+ readonly volume: number;
+}
+
+export interface UsePokerSoundsResult extends PokerSoundSettings {
+ readonly toggleMuted: () => void;
+ readonly setVolume: (volume: number) => void;
+ readonly playUiClick: () => void;
+}
+
+const DEFAULT_SOUND_SETTINGS: PokerSoundSettings = {
+ muted: false,
+ volume: DEFAULT_POKER_SOUND_VOLUME,
+};
+
+export type PokerSoundControls = ReturnType;
+
+function isStoredSettings(
+ value: unknown,
+): value is { readonly muted: unknown; readonly volume: unknown } {
+ return typeof value === "object" && value !== null && "muted" in value && "volume" in value;
+}
+
+function clampVolume(value: number): number {
+ if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
+ return Math.min(1, Math.max(0, value));
+}
+
+function readStoredSettings(): PokerSoundSettings {
+ if (typeof window === "undefined") return DEFAULT_SOUND_SETTINGS;
+
+ try {
+ const raw = window.localStorage.getItem(SOUND_SETTINGS_KEY);
+ if (!raw) return DEFAULT_SOUND_SETTINGS;
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ !isStoredSettings(parsed) ||
+ typeof parsed.muted !== "boolean" ||
+ typeof parsed.volume !== "number"
+ ) {
+ return DEFAULT_SOUND_SETTINGS;
+ }
+ return { muted: parsed.muted, volume: clampVolume(parsed.volume) };
+ } catch (error) {
+ if (error instanceof Error) return DEFAULT_SOUND_SETTINGS;
+ return DEFAULT_SOUND_SETTINGS;
+ }
+}
+
+function writeStoredSettings(settings: PokerSoundSettings): void {
+ if (typeof window === "undefined") return;
+
+ try {
+ window.localStorage.setItem(SOUND_SETTINGS_KEY, JSON.stringify(settings));
+ } catch (error) {
+ if (error instanceof Error) return;
+ }
+}
+
+export function usePokerSounds(snapshot: PokerSnapshot | null): UsePokerSoundsResult {
+ const [settings, setSettings] = useState(DEFAULT_SOUND_SETTINGS);
+ const engineRef = useRef(null);
+ const previousSnapshotRef = useRef(null);
+ const playedEventKeysRef = useRef>(new Set());
+ const timersRef = useRef[]>([]);
+ const [settingsLoaded, setSettingsLoaded] = useState(false);
+
+ const ensureEngine = useCallback((): PokerSoundEngine => {
+ const engine = engineRef.current ?? new PokerSoundEngine();
+ engineRef.current = engine;
+ engine.setVolume(settings.volume);
+ engine.setMuted(settings.muted);
+ engine.unlock();
+ return engine;
+ }, [settings.muted, settings.volume]);
+
+ const playUiClick = useCallback(() => {
+ ensureEngine().playUiClick();
+ }, [ensureEngine]);
+
+ const toggleMuted = useCallback(() => {
+ const nextMuted = !settings.muted;
+ const engine = ensureEngine();
+ engine.setMuted(nextMuted);
+ setSettings((current) => ({ ...current, muted: nextMuted }));
+ }, [ensureEngine, settings.muted]);
+
+ const setVolume = useCallback(
+ (volume: number) => {
+ const nextVolume = clampVolume(volume);
+ const engine = ensureEngine();
+ engine.setVolume(nextVolume);
+ setSettings((current) => ({ ...current, volume: nextVolume }));
+ },
+ [ensureEngine],
+ );
+
+ useEffect(() => {
+ setSettings(readStoredSettings());
+ setSettingsLoaded(true);
+ }, []);
+
+ useEffect(() => {
+ if (settingsLoaded) writeStoredSettings(settings);
+ }, [settings, settingsLoaded]);
+
+ useEffect(() => {
+ if (!snapshot) {
+ previousSnapshotRef.current = null;
+ return;
+ }
+ const previousSnapshot = previousSnapshotRef.current;
+ if (previousSnapshot) {
+ const events = soundEventsForTransition(previousSnapshot, snapshot);
+ const engine = engineRef.current;
+ if (engine) {
+ for (const event of events) {
+ if (playedEventKeysRef.current.has(event.key)) continue;
+ playedEventKeysRef.current.add(event.key);
+ if (event.type === "wager" && event.delay && event.delay > 0) {
+ timersRef.current.push(setTimeout(() => engine.playEvent(event), event.delay));
+ } else {
+ engine.playEvent(event);
+ }
+ }
+ }
+ while (playedEventKeysRef.current.size > 256) {
+ const oldest = playedEventKeysRef.current.values().next().value;
+ if (typeof oldest !== "string") break;
+ playedEventKeysRef.current.delete(oldest);
+ }
+ }
+ previousSnapshotRef.current = snapshot;
+ }, [snapshot]);
+
+ useEffect(() => {
+ return () => {
+ for (const timer of timersRef.current) clearTimeout(timer);
+ timersRef.current = [];
+ engineRef.current?.dispose();
+ engineRef.current = null;
+ };
+ }, []);
+
+ return {
+ muted: settings.muted,
+ volume: settings.volume,
+ toggleMuted,
+ setVolume,
+ playUiClick,
+ };
+}
diff --git a/tests/int/poker-sound.int.spec.ts b/tests/int/poker-sound.int.spec.ts
new file mode 100644
index 0000000..948382d
--- /dev/null
+++ b/tests/int/poker-sound.int.spec.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from "vitest";
+import type { Card, PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
+import {
+ soundEventsForTransition,
+ type PokerSoundSnapshot,
+} from "@/components/frontend/minigames/poker/pokerSoundEvents";
+
+const card = (rank: Card["rank"], suit: Card["suit"]): Card => ({ rank, suit });
+
+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(netChange: number): ShowdownOutcome {
+ return {
+ pots: [],
+ results: [
+ {
+ seatId: 0,
+ handRank: null,
+ bestHand: null,
+ chipsWon: 100,
+ netChange,
+ placement: netChange > 0 ? 1 : 2,
+ },
+ ],
+ };
+}
+
+describe("poker sound transition events", () => {
+ it("emits a street deal descriptor for flop, turn, and river transitions", () => {
+ const flop = soundEventsForTransition(
+ snapshot(1),
+ snapshot(
+ 2,
+ view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
+ ),
+ );
+ const turn = soundEventsForTransition(
+ snapshot(
+ 2,
+ view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
+ ),
+ snapshot(
+ 3,
+ view({
+ street: "turn",
+ communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
+ }),
+ ),
+ );
+ const river = soundEventsForTransition(
+ snapshot(
+ 3,
+ view({
+ street: "turn",
+ communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
+ }),
+ ),
+ snapshot(
+ 4,
+ view({
+ street: "river",
+ communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c"), card(4, "h")],
+ }),
+ ),
+ );
+
+ expect(flop).toEqual([
+ expect.objectContaining({ type: "street-deal", street: "flop", cardCount: 3 }),
+ ]);
+ expect(turn).toEqual([
+ expect.objectContaining({ type: "street-deal", street: "turn", cardCount: 1 }),
+ ]);
+ expect(river).toEqual([
+ expect.objectContaining({ type: "street-deal", street: "river", cardCount: 1 }),
+ ]);
+ });
+
+ it("emits turn gained and turn lost descriptors", () => {
+ const gained = soundEventsForTransition(
+ snapshot(1),
+ snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
+ );
+ const lost = soundEventsForTransition(
+ snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
+ snapshot(3, view({ yourTurn: false, toActSeat: 1 })),
+ );
+
+ expect(gained.map((event) => event.type)).toContain("turn-gained");
+ expect(lost.map((event) => event.type)).toContain("turn-lost");
+ });
+
+ it("emits wager, check, and fold descriptors from player transitions", () => {
+ const previous = snapshot(1, view({ players: [player(0), player(1)] }));
+ const next = snapshot(
+ 2,
+ view({
+ currentBet: 20,
+ players: [
+ player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
+ player(1, { folded: true, hasActed: true }),
+ ],
+ }),
+ );
+ const wagerEvents = soundEventsForTransition(previous, next);
+ const checkEvents = soundEventsForTransition(
+ previous,
+ snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
+ );
+
+ expect(wagerEvents.map((event) => event.type)).toEqual(["wager", "fold"]);
+ expect(checkEvents.map((event) => event.type)).toEqual(["check"]);
+ });
+
+ it("emits win and loss results plus game completion placement", () => {
+ const win = soundEventsForTransition(
+ snapshot(1),
+ snapshot(2, view({ phase: "hand-complete", showdown: showdown(40) })),
+ );
+ const loss = soundEventsForTransition(
+ snapshot(1),
+ snapshot(2, view({ phase: "hand-complete", showdown: showdown(-40) })),
+ );
+ const complete = soundEventsForTransition(
+ snapshot(1),
+ snapshot(2, view({ phase: "game-complete", finalPlacements: [{ seatId: 0, placement: 1 }] })),
+ );
+
+ expect(win).toEqual([expect.objectContaining({ type: "hand-result", result: "win" })]);
+ expect(loss).toEqual([expect.objectContaining({ type: "hand-result", result: "loss" })]);
+ expect(complete).toEqual([expect.objectContaining({ type: "game-complete", placement: 1 })]);
+ });
+
+ it("returns no events for an identical snapshot version", () => {
+ const current = snapshot(7, view({ yourTurn: true }));
+ expect(soundEventsForTransition(current, current)).toEqual([]);
+ });
+
+ it("announces a new hand with a two-card deal and a staggered blind sequence", () => {
+ const events = soundEventsForTransition(snapshot(5), snapshot(6, view({ handNumber: 2 })));
+
+ expect(events).toEqual([
+ expect.objectContaining({ type: "hand-start", handNumber: 2 }),
+ expect.objectContaining({
+ type: "wager",
+ amount: 10,
+ seatId: 0,
+ delay: 0,
+ }),
+ expect.objectContaining({
+ type: "wager",
+ amount: 20,
+ seatId: 1,
+ delay: 700,
+ }),
+ ]);
+ });
+
+ it("plays the staggered blind sequence for the game's first hand out of the waiting room", () => {
+ const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
+ const events = soundEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
+
+ expect(events.map((event) => event.type)).toEqual(["hand-start", "wager", "wager"]);
+ expect(events[1]).toEqual(expect.objectContaining({ amount: 10, delay: 0 }));
+ expect(events[2]).toEqual(expect.objectContaining({ amount: 20, delay: 700 }));
+ });
+
+ it("plays the showdown reveal plus the win jingle on showdown entry", () => {
+ const events = soundEventsForTransition(
+ snapshot(1),
+ snapshot(2, view({ phase: "showdown", showdown: showdown(180) })),
+ );
+
+ expect(events.map((event) => event.type)).toEqual(["showdown", "hand-result"]);
+ expect(events[1]).toEqual(expect.objectContaining({ result: "win", netChange: 180 }));
+ });
+
+ it("still clacks chips when the same player wagers again later in the street", () => {
+ const before = snapshot(
+ 9,
+ view({
+ currentBet: 20,
+ players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
+ }),
+ );
+ const after = snapshot(
+ 10,
+ view({
+ currentBet: 60,
+ players: [
+ player(0, { committed: 60, totalCommitted: 60, hasActed: true }),
+ player(1, { committed: 40, totalCommitted: 40, hasActed: true }),
+ ],
+ }),
+ );
+
+ const events = soundEventsForTransition(before, after);
+ expect(events.filter((event) => event.type === "wager")).toHaveLength(2);
+ });
+});