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
190 lines
6.8 KiB
JavaScript
190 lines
6.8 KiB
JavaScript
// The money bar: chips, wallet, buying in, cashing out.
|
|
//
|
|
// Buying chips is not instant and cannot be. gogobee owns the euros and has no
|
|
// inbound API, so it polls Pete for the crossing, moves the money on its side,
|
|
// and pushes the verdict back — a few seconds, end to end. So the button does
|
|
// not lie about it: the chips show as "buying" until they are real, and this
|
|
// file polls until they are. Nothing spendable appears until gogobee has said
|
|
// it took the euros.
|
|
//
|
|
// Exposed as window.PeteGames so the table (blackjack.js) shares one view of
|
|
// the money rather than keeping a second copy that drifts from this one.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var bar = document.querySelector("[data-chipbar]");
|
|
if (!bar) return;
|
|
|
|
var chipsEl = bar.querySelector("[data-chips]");
|
|
var pendingEl = bar.querySelector("[data-pending]");
|
|
var eurosEl = bar.querySelector("[data-euros]");
|
|
var amountEl = bar.querySelector("[data-buyin-amount]");
|
|
var buyBtn = bar.querySelector("[data-buyin]");
|
|
var cashBtn = bar.querySelector("[data-cashout]");
|
|
var msgEl = bar.querySelector("[data-chipbar-msg]");
|
|
|
|
var listeners = [];
|
|
var view = null;
|
|
var pollTimer = null;
|
|
var pollUntil = 0;
|
|
|
|
function money(n) {
|
|
return (n || 0).toLocaleString();
|
|
}
|
|
|
|
function say(text, tone) {
|
|
if (!msgEl) return;
|
|
if (!text) { msgEl.classList.add("hidden"); return; }
|
|
msgEl.textContent = text;
|
|
msgEl.classList.remove("hidden");
|
|
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
|
|
}
|
|
|
|
function paint(v) {
|
|
var first = view === null;
|
|
view = v;
|
|
// The chip count rolls to its new value rather than jumping to it — the table
|
|
// times this to land with the chips that caused it, so a payout reads as the
|
|
// number catching up with the felt. On the first paint there's nothing to
|
|
// catch up with, so it just sets.
|
|
if (chipsEl) {
|
|
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
|
|
else window.PeteFX.count(chipsEl, v.chips);
|
|
}
|
|
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
|
|
|
|
if (pendingEl) {
|
|
if (v.pending > 0) {
|
|
pendingEl.textContent = "+" + money(v.pending) + " buying…";
|
|
pendingEl.classList.remove("hidden");
|
|
} else {
|
|
pendingEl.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
// You cannot cash out mid-game: the stake is already on the table. `game` is
|
|
// set by whichever game you're in, so this holds for any of them — checking
|
|
// for a blackjack hand specifically would let you walk out on a hangman.
|
|
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game;
|
|
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
|
|
|
|
listeners.forEach(function (fn) { fn(v); });
|
|
}
|
|
|
|
// pollPending keeps asking while a buy-in is in flight, and stops the moment
|
|
// it lands — or is refused, which looks the same from here (pending drops to
|
|
// zero) and is told apart by whether the chips arrived.
|
|
function pollPending() {
|
|
clearTimeout(pollTimer);
|
|
if (Date.now() > pollUntil) {
|
|
say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload.");
|
|
return;
|
|
}
|
|
pollTimer = setTimeout(function () {
|
|
get().then(function (v) {
|
|
if (!v) return;
|
|
if (v.pending > 0) { pollPending(); return; }
|
|
if (v.chips > (pollPending.was || 0)) {
|
|
say("Chips are yours. Good luck!");
|
|
} else {
|
|
say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad");
|
|
}
|
|
});
|
|
}, 1200);
|
|
}
|
|
|
|
function request(path, body) {
|
|
return fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body || {}),
|
|
}).then(function (res) {
|
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
if (!res.ok) throw new Error(data.error || "that didn't work");
|
|
return data;
|
|
});
|
|
});
|
|
}
|
|
|
|
function get() {
|
|
return fetch("/api/games/table", { headers: { "Accept": "application/json" } })
|
|
.then(function (res) { return res.ok ? res.json() : null; })
|
|
.then(function (v) { if (v) paint(v); return v; })
|
|
.catch(function () { return null; });
|
|
}
|
|
|
|
if (buyBtn) {
|
|
buyBtn.addEventListener("click", function () {
|
|
var amount = parseInt(amountEl && amountEl.value, 10);
|
|
if (!(amount > 0)) { say("How many chips?", "bad"); return; }
|
|
|
|
buyBtn.disabled = true;
|
|
say("Asking gogobee for " + money(amount) + " euros…");
|
|
pollPending.was = view ? view.chips : 0;
|
|
|
|
request("/api/games/buyin", { amount: amount })
|
|
.then(function (v) {
|
|
paint(v);
|
|
pollUntil = Date.now() + 60000;
|
|
pollPending();
|
|
})
|
|
.catch(function (err) {
|
|
say(err.message, "bad");
|
|
buyBtn.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (cashBtn) {
|
|
cashBtn.addEventListener("click", function () {
|
|
cashBtn.disabled = true;
|
|
say("Cashing you out…");
|
|
request("/api/games/cashout", { amount: 0 })
|
|
.then(function (v) {
|
|
paint(v);
|
|
say("Chips are on their way back to euros. They'll show in your wallet shortly.");
|
|
// The euro balance Pete shows is whatever gogobee last told it, so it
|
|
// only moves once the credit has actually gone through over there.
|
|
setTimeout(get, 4000);
|
|
})
|
|
.catch(function (err) {
|
|
say(err.message, "bad");
|
|
cashBtn.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
// The room's volume. The button lives in the chip bar, which is the one thing
|
|
// every table has — the sound belongs to the room, not to any one game — and
|
|
// the state itself belongs to PeteSFX, which persists it. This only draws it.
|
|
var sfxBtn = document.querySelector("[data-sfx-toggle]");
|
|
if (sfxBtn && window.PeteSFX) {
|
|
var onIcon = sfxBtn.querySelector("[data-sfx-on]");
|
|
var offIcon = sfxBtn.querySelector("[data-sfx-off]");
|
|
var sfxLbl = sfxBtn.querySelector("[data-sfx-label]");
|
|
|
|
window.PeteSFX.onChange(function (muted) {
|
|
sfxBtn.setAttribute("aria-pressed", muted ? "true" : "false");
|
|
sfxBtn.title = muted ? "Sound is off" : "Sound is on";
|
|
if (onIcon) onIcon.classList.toggle("hidden", muted);
|
|
if (offIcon) offIcon.classList.toggle("hidden", !muted);
|
|
if (sfxLbl) sfxLbl.textContent = muted ? "Turn the sound on" : "Mute the room";
|
|
});
|
|
sfxBtn.addEventListener("click", function () { window.PeteSFX.toggle(); });
|
|
}
|
|
|
|
window.PeteGames = {
|
|
// onUpdate registers a listener called on every fresh view of the money.
|
|
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
|
|
// apply pushes a view the table already fetched (a deal answers with one),
|
|
// so playing a hand doesn't need a second round-trip to refresh the chips.
|
|
apply: paint,
|
|
refresh: get,
|
|
post: request,
|
|
say: say,
|
|
view: function () { return view; },
|
|
};
|
|
|
|
get();
|
|
})();
|