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); + }); +});