Files
Pete/internal/web/static/js/casino-sfx.js
prosolis 39ed293f4f games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:15:11 -07:00

266 lines
10 KiB
JavaScript

// The noise the room makes.
//
// There are no audio files here and there is nothing to download. Every sound in
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
// the same bargain the weather engine takes with its clouds. A card is a short
// slap of noise through a bandpass; a chip is two detuned sines with a click on
// the front; a win is four notes going up. It costs about six kilobytes and no
// round trips, and it means a sound can be pitched, stretched and detuned per
// call instead of being the same wav 300 times.
//
// Two rules hold the whole file up.
//
// **Nothing is built until it's asked for.** A browser will not let a page make a
// noise before the user has touched it, and a page that builds an AudioContext on
// load gets one in the "suspended" state and a warning in the console for its
// trouble. So the context is made on the first sound anybody actually asks for —
// which, in a casino, is a click on a chip.
//
// **Muted means silent, not quiet.** When the room is muted nothing is
// constructed, nothing is scheduled and no context is opened. Mute is the
// default-off switch for the entire file, checked before anything else happens.
//
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var KEY = "pete.sfx.off";
var ctx = null;
var master = null;
var noiseBuf = null;
var muted = false;
var listeners = [];
try { muted = localStorage.getItem(KEY) === "1"; } catch (e) {}
// ---- the instrument --------------------------------------------------------
function boot() {
if (ctx) return ctx;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch (e) {
return null;
}
master = ctx.createGain();
master.gain.value = 0.45; // the room is furniture, not a nightclub
master.connect(ctx.destination);
return ctx;
}
// A second of white noise, made once and played at different speeds and through
// different filters for the rest of the session. Card flicks, riffles, the
// transient on a chip: all of them are this buffer wearing a different coat.
function noise() {
if (noiseBuf) return noiseBuf;
var n = ctx.sampleRate;
noiseBuf = ctx.createBuffer(1, n, n);
var d = noiseBuf.getChannelData(0);
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return noiseBuf;
}
// env is the shape of every sound in here: up fast, down slow, and never to
// zero — an exponential ramp to actual zero is undefined, and a browser that
// hits one either throws or clicks.
function env(g, t0, attack, decay, peak) {
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
}
// tone is one note.
function tone(t0, freq, o) {
o = o || {};
var osc = ctx.createOscillator();
var g = ctx.createGain();
osc.type = o.type || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide || o.decay || 0.1));
env(g, t0, o.attack || 0.004, o.decay || 0.12, o.gain == null ? 0.3 : o.gain);
osc.connect(g).connect(master);
osc.start(t0);
osc.stop(t0 + (o.attack || 0.004) + (o.decay || 0.12) + 0.02);
}
// hiss is a burst of the noise buffer through a filter, which is what a card,
// a riffle and the knock on a chip all are.
function hiss(t0, o) {
o = o || {};
var src = ctx.createBufferSource();
src.buffer = noise();
src.playbackRate.value = o.rate || 1;
var f = ctx.createBiquadFilter();
f.type = o.filter || "bandpass";
f.frequency.setValueAtTime(o.freq || 2000, t0);
if (o.sweepTo) f.frequency.exponentialRampToValueAtTime(o.sweepTo, t0 + (o.decay || 0.1));
f.Q.value = o.q == null ? 1.1 : o.q;
var g = ctx.createGain();
env(g, t0, o.attack || 0.003, o.decay || 0.09, o.gain == null ? 0.3 : o.gain);
src.connect(f).connect(g).connect(master);
src.start(t0);
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
}
// ---- the sounds ------------------------------------------------------------
//
// Each one takes the time it starts at, and a `v` — a small per-call variation,
// usually the index of the card or chip in a run. Four cards dealt with the
// identical sample four times is a machine gun; four cards each a semitone off
// is a hand dealing.
var SOUNDS = {
// A card landing: mostly air, with a slap on the front of it.
card: function (t, v) {
hiss(t, { freq: 1800 + v * 140, q: 0.9, decay: 0.075, rate: 1 + v * 0.05, gain: 0.3 });
hiss(t, { filter: "highpass", freq: 4200, decay: 0.02, gain: 0.16 });
},
// Dealing is the same card, lower and softer: it's being placed, not thrown.
deal: function (t, v) {
hiss(t, { freq: 1250 + v * 110, q: 1.1, decay: 0.085, rate: 0.9, gain: 0.24 });
},
// A card turning over — shorter, brighter, and it comes in two halves, which
// is what a flip actually sounds like.
flip: function (t) {
hiss(t, { freq: 2600, q: 1.4, decay: 0.035, gain: 0.22 });
hiss(t + 0.045, { freq: 1500, q: 1.0, decay: 0.06, gain: 0.24 });
},
// The riffle. One long sweep of noise with the filter climbing through it,
// and a rattle laid over the top so it isn't just a shhh.
shuffle: function (t) {
hiss(t, { freq: 700, sweepTo: 3400, q: 0.7, attack: 0.02, decay: 0.34, gain: 0.2 });
for (var i = 0; i < 9; i++) {
hiss(t + 0.03 + i * 0.032, { freq: 2400 + i * 130, q: 2.2, decay: 0.02, gain: 0.09 });
}
},
// A chip landing on a chip. Two detuned partials well above the staff, with a
// knock underneath — the knock is most of what makes it read as *clay* rather
// than as a bell.
chip: function (t, v) {
var f = 1750 + v * 55;
tone(t, f, { type: "sine", decay: 0.07, gain: 0.16 });
tone(t, f * 1.34, { type: "sine", decay: 0.05, gain: 0.1 });
hiss(t, { filter: "lowpass", freq: 900, decay: 0.03, gain: 0.3, q: 0.5 });
},
// Chips sliding away across felt.
sweep: function (t) {
hiss(t, { freq: 1600, sweepTo: 500, q: 0.6, attack: 0.015, decay: 0.26, gain: 0.16 });
},
// Four notes up. This is the only sound in the room allowed to be pleased.
win: function (t) {
[523.25, 659.25, 783.99, 1046.5].forEach(function (f, i) {
tone(t + i * 0.085, f, { type: "triangle", decay: 0.26, gain: 0.24 });
});
},
// Two notes down, and the second one is flat. Nobody needs telling twice.
lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
tone(t + 0.15, 233.08, { type: "triangle", decay: 0.42, gain: 0.22 });
},
// Nothing happened and nothing was lost.
push: function (t) {
tone(t, 392, { type: "sine", decay: 0.2, gain: 0.16 });
},
// A button. Short enough that you feel it rather than hear it.
blip: function (t) {
tone(t, 880, { type: "sine", decay: 0.055, gain: 0.12 });
},
// UNO, called. A bright stab plus a zip upwards: it is a shout, and it is the
// one thing on that table you want to feel good about pressing.
uno: function (t) {
[659.25, 830.61, 987.77].forEach(function (f) {
tone(t, f, { type: "triangle", decay: 0.3, gain: 0.2 });
});
tone(t, 500, { type: "sawtooth", to: 1600, glide: 0.16, decay: 0.18, gain: 0.09 });
},
// Got them. A rising snap with a thump on the end — the sound of a finger
// being pointed.
catch: function (t) {
tone(t, 300, { type: "square", to: 900, glide: 0.11, decay: 0.12, gain: 0.13 });
hiss(t + 0.1, { filter: "lowpass", freq: 1100, decay: 0.1, gain: 0.34, q: 0.7 });
tone(t + 0.1, 174.61, { type: "triangle", decay: 0.22, gain: 0.2 });
},
// Something bad landed on you: a stack, a +4, a catch you walked into.
bad: function (t) {
tone(t, 155.56, { type: "square", to: 98, glide: 0.18, decay: 0.22, gain: 0.14 });
hiss(t, { filter: "lowpass", freq: 700, decay: 0.14, gain: 0.3, q: 0.6 });
},
// A clock you can hear. Used sparingly, and never in a run of more than a few.
tick: function (t) {
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
},
};
// ---- the door --------------------------------------------------------------
// A browser will not make a noise until the user has touched the page, and a
// context built before that arrives suspended. This wakes it on the first real
// gesture — after which play() can schedule freely.
function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
}
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true });
});
// play makes a sound, or — if the room is muted — makes nothing at all, opens no
// context and builds no graph.
//
// `v` is the variation: pass the index of the card or chip in a run and the
// sound moves a little each time, which is the difference between a hand and a
// machine. `delay` schedules it ahead in seconds, so a caller that knows a card
// lands in 400ms can say so rather than sleeping.
function play(name, opts) {
if (muted) return;
var s = SOUNDS[name];
if (!s) return;
if (!boot()) return;
wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
} catch (e) {
/* a sound is never worth throwing over */
}
}
function setMuted(on) {
muted = !!on;
try {
if (muted) localStorage.setItem(KEY, "1");
else localStorage.removeItem(KEY);
} catch (e) {}
if (window.PetePrefs) window.PetePrefs.push();
listeners.forEach(function (fn) { fn(muted); });
if (!muted) play("blip"); // so you know what you just turned back on
}
window.PeteSFX = {
play: play,
muted: function () { return muted; },
toggle: function () { setMuted(!muted); return muted; },
set: setMuted,
// onChange lets the mute button re-label itself without owning the state.
onChange: function (fn) { listeners.push(fn); fn(muted); },
};
})();