1
0
Fork 0

feat(poker): add synthesized sfx and generative background jazz

Web audio synth with zero audio assets: deal whooshes, chip clacks, turn chimes, showdown and win stingers, plus a lazy 92bpm jazz loop. Transitions drive playback from pure snapshot mappers with staggered delays for the blind sequence, a header mute and volume toggle persists, and everything no-ops safely without audio support.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 17:07:12 -04:00
parent 9b2b0db553
commit 56e3d253c9
5 changed files with 945 additions and 0 deletions

View file

@ -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 (
<div className="poker-sound-toggle" aria-label="Poker sound controls">
<Button
type="button"
variant="outline"
size="icon"
onClick={onToggleMuted}
aria-label={muted ? "Unmute poker sounds" : "Mute poker sounds"}
title={muted ? "Unmute poker sounds" : "Mute poker sounds"}
>
{muted ? <VolumeX aria-hidden="true" /> : <Volume2 aria-hidden="true" />}
</Button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
aria-label="Poker sound volume"
className="poker-sound-volume"
/>
</div>
);
}

View file

@ -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;
}
}

View file

@ -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<PokerSnapshot["you"]>;
export type PokerSoundSnapshot = Pick<PokerSnapshot, "version"> & {
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<PokerStreet, "flop" | "turn" | "river">;
readonly cardCount: number;
}
| {
readonly type: "wager";
readonly key: string;
readonly action: Extract<PokerActionKind, "bet" | "call" | "raise">;
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<Extract<PokerStreet, "flop" | "turn" | "river">, 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<PokerActionKind, "bet" | "call" | "raise"> {
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;
}

View file

@ -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<typeof usePokerSounds>;
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<PokerSoundSettings>(DEFAULT_SOUND_SETTINGS);
const engineRef = useRef<PokerSoundEngine | null>(null);
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
const playedEventKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
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,
};
}

View file

@ -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> = {}): 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(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);
});
});