Files
Pete/internal/web/static/js/games.js
prosolis fe2195e85f games: a gallows you can bet on
Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.

The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.

State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.

Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.

commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
2026-07-14 01:19:05 -07:00

171 lines
5.9 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;
});
});
}
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();
})();