Files
Pete/internal/web/static/js/casino-fx.js
prosolis 79c857023f games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots,
and going out first pays the table: 2.2x heads up, 3.6x against a full house.
Anybody else going out first takes the stake. The table size is the tier,
because it is the only dial UNO has.

The bots move inside ApplyMove. A game with opponents is normally where you
reach for a socket, and the plan says solo UNO must not — so one request plays
your move and every bot turn behind it, and hands back the whole lap as a
script the felt plays in order.

The RNG is in the state rather than an argument to it: the bots choose and a
spent deck reshuffles, so the engine needs randomness mid-game, and there is no
generator alive across requests to pass in. The seed rides in the state and each
step derives its own. The game still replays exactly as it fell.

The zero value of Color is Wild, and that is the whole point of it: a wild
played with the colour field missing from the JSON must be refused, not
quietly played as a red one. It was red for an hour.

The browser never sees a bot's card — not the deck, not a hand, not the face of
a card a bot drew, which is most of the deck. Seats cross the wire as a name and
a count.

The multiples are measured, not guessed: playing the first legal card you hold
wins 43/32/27% of the time against these bots, so the tiers price that to lose
about 8% a game and leave good play worth roughly the house's edge.

PeteFX.flyNode is the throw with the chip taken out of it, so a card can be
thrown across the felt the same way. fly() is now that with a chip in it.

Not yet driven in a browser, which in this room means not yet finished.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:07:17 -07:00

336 lines
12 KiB
JavaScript

// The moving parts of the room.
//
// A casino is mostly one gesture repeated: something of value is picked up here
// and put down there, in front of you, slowly enough that you can object. So the
// only real idea in this file is fly() — take an element's place on screen, take
// another's, and move a chip between them on an arc that a hand would make.
//
// Everything flies in a fixed overlay pinned to the viewport, not inside the
// felt, because a chip's journey starts on a button *outside* the felt and any
// container in between would clip it halfway. Coordinates come from
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
// and nothing has to be positioned relative to anything else.
//
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var layer = null;
function stage() {
if (!layer) {
layer = document.createElement("div");
layer.className = "pete-fly-layer";
layer.setAttribute("aria-hidden", "true");
document.body.appendChild(layer);
}
return layer;
}
// Where a thing is, in viewport coordinates: the middle of it.
function centre(target) {
if (!target) return { x: 0, y: 0 };
if (typeof target.x === "number") return target;
var r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
// DENOMS, biggest first — the order you break an amount down in.
var DENOMS = [500, 100, 25, 5];
// chipsFor turns an amount into the chips a dealer would actually push across:
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
// just gets shorter than the number under it, which is what a real tall stack
// looks like from across a table anyway.
function chipsFor(amount, cap) {
var out = [];
var left = Math.max(0, Math.round(amount || 0));
for (var i = 0; i < DENOMS.length && left > 0; i++) {
while (left >= DENOMS[i]) {
out.push(DENOMS[i]);
left -= DENOMS[i];
}
}
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
var max = cap || 12;
return out.length > max ? out.slice(0, max) : out;
}
function disc(denom) {
var el = document.createElement("div");
el.className = "pete-disc";
el.dataset.chip = String(denom);
return el;
}
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
// reshuffle itself every time the page repaints.
function jitter(i, spread) {
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
}
// flyNode moves *anything* from one place to another and resolves when it
// lands: a chip, a playing card, a card face down.
//
// The arc is the point. A straight line between two rects reads as a UI
// transition; something that rises, travels and drops reads as a throw. WAAPI
// does this in one keyframe list because it can interpolate a midpoint — a CSS
// transition can't, which is why this isn't a class toggle.
//
// The node is yours: build it, hand it over, and it is gone when the promise
// resolves. Nothing in here knows or cares what it was.
function flyNode(node, from, to, opts) {
opts = opts || {};
var a = centre(from);
var b = centre(to);
node.classList.add("pete-fly");
stage().appendChild(node);
var dur = opts.duration || 420;
if (reduced) dur = 1;
// How high it goes: further throws arc higher, and a lob can be forced.
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
var midX = (a.x + b.x) / 2;
var midY = (a.y + b.y) / 2 - lift;
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
var s1 = opts.toScale == null ? 1 : opts.toScale;
var anim = node.animate(
[
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, (s0 + s1) / 2 * 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
);
return anim.finished
.catch(function () {})
.then(function () {
node.remove();
});
}
// fly throws one chip. It is flyNode with a chip in it, which is what it always
// was — UNO wanted the same throw with a card in it, so the throw moved out.
function fly(from, to, opts) {
opts = opts || {};
return flyNode(disc(opts.denom || 25), from, to, opts);
}
function t(x, y, scale, rot) {
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
}
// flyMany throws a whole bet across, one chip after another rather than all at
// once, because a pile of chips arriving as a single object is a progress bar.
// Resolves when the last one lands.
function flyMany(from, to, denoms, opts) {
opts = opts || {};
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
var each = denoms.map(function (d, i) {
return fly(from, to, {
denom: d,
index: i,
delay: (opts.delay || 0) + i * gap,
duration: opts.duration,
lift: opts.lift,
fade: opts.fade,
spin: opts.spin,
});
});
if (opts.onLand && !reduced) {
denoms.forEach(function (d, i) {
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
});
} else if (opts.onLand) {
denoms.forEach(function (d, i) { opts.onLand(d, i); });
}
return Promise.all(each);
}
// A bet spot: the pile of chips sitting on it, and the number printed under
// the pile.
//
// The rule the whole room is built on lives in here, which is why it's one
// object and not two variables on a table: **the number is a readout of the
// pile, never the other way round.** There is no way to change one without the
// other, so a settled game can't leave "your bet: 300" printed over an empty
// circle, and a payout can't be counted before the chips that justify it have
// landed.
//
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
// holds what you've banked, which grows a card at a time. Same object.
function spot(els) {
var api = {
// What the pile is holding. Written by render, and readable by a table that
// needs to know whether a chip is already on its way down.
amount: 0,
render: function (n) {
api.amount = n || 0;
els.stack.innerHTML = "";
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
if (!api.amount) {
if (els.total) els.total.classList.add("hidden");
return;
}
chipsFor(api.amount).forEach(function (d, i) {
var c = disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
els.stack.appendChild(c);
});
if (els.total) {
els.total.textContent = api.amount.toLocaleString();
els.total.classList.remove("hidden");
}
},
// pour throws a run of chips onto the spot and grows the pile as each one
// lands — by the value of the chip that landed, so the total under the pile
// counts up the way the chips do. The last chip carries the remainder,
// because chipsFor caps how many chips it will make you watch and the pile
// still has to end on the real number.
pour: function (from, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = api.amount;
var chips = chipsFor(amount, 8);
var run = 0;
return flyMany(from, els.spot, chips, Object.assign({
onLand: function (d, i) {
run += d;
api.render(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
},
// sweep sends chips off the spot to somewhere else — your pile, or the
// house's rack. The spot is emptied *now* rather than when they land, so
// nothing that is already in the air can be bet a second time.
sweep: function (to, amount, opts) {
var n = amount == null ? api.amount : amount;
var left = api.amount - n;
if (n <= 0) return Promise.resolve();
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
api.render(left > 0 ? left : 0);
return chain;
},
};
return api;
}
// burst: confetti out of a point. Saved for the things worth celebrating.
function burst(target, opts) {
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
var n = opts.count || 26;
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-spark";
bit.style.background = colours[i % colours.length];
stage().appendChild(bit);
// Fired into a cone that mostly goes up, then let gravity have it.
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
var vx = Math.cos(angle) * speed;
var vy = Math.sin(angle) * speed;
var dropTo = c.y + 220 + jitter(i + 3, 60);
done.push(
bit
.animate(
[
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
{
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
opacity: 1,
offset: 0.4,
},
// Held at full colour most of the way down: a piece of confetti that
// starts fading the moment it's thrown reads as smoke.
{
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
opacity: 1,
offset: 0.75,
},
{
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
opacity: 0,
offset: 1,
},
],
{
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
fill: "both",
}
)
.finished.catch(function () {})
.then(
(function (b) {
return function () { b.remove(); };
})(bit)
)
);
}
return Promise.all(done);
}
// count rolls a number to a new value instead of swapping it. A chip count that
// jumps is a variable; one that climbs is a payout.
function count(el, to, opts) {
if (!el) return;
opts = opts || {};
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
if (isNaN(from) || reduced || from === to) {
el.textContent = (to || 0).toLocaleString();
return;
}
var dur = opts.duration || 520;
var t0 = null;
if (el._petePop) el._petePop.cancel();
el._petePop = el.animate(
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
{ duration: dur, easing: "ease-out" }
);
function step(ts) {
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
if (p < 1) requestAnimationFrame(step);
else el.textContent = (to || 0).toLocaleString();
}
requestAnimationFrame(step);
}
window.PeteFX = {
reduced: reduced,
chipsFor: chipsFor,
disc: disc,
jitter: jitter,
fly: fly,
flyNode: flyNode,
flyMany: flyMany,
spot: spot,
burst: burst,
count: count,
centre: centre,
};
})();