The engine, the escrow and the wire were all in place; nothing had a browser on the end of it. This is that end: a lobby, a table, and the five endpoints between them. The browser holds no game. It sends intents and gets back a view — the cards it is entitled to see, and the script of how they arrived, one event per card off the shoe. The dealer's hole card is not in the payload at all until the reveal, because a field the client is told to ignore is a field somebody reads in devtools. The shoe lives in game_live_hands, which also means a redeploy mid-hand no longer costs a player their stake: the hand is still there when they come back. The money is ordered so nothing can be spent twice. The stake leaves the stack in the same statement that checks it exists, before a card is dealt. Every new hand is seated with a plain INSERT, so a double-clicked Deal is decided by the primary key rather than by a read that raced — it loses, gets its chips back, and the hand in progress is untouched. A double takes its raise up front and hands it straight back if the engine refuses the move. Cards are dealt rather than swapped in — they fly out of the shoe and turn over, which was a requirement and not a flourish. The faces and the chips are still plain; that's next.
161 lines
5.3 KiB
JavaScript
161 lines
5.3 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) {
|
|
view = v;
|
|
if (chipsEl) chipsEl.textContent = money(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-hand: the stake is already on the table.
|
|
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.hand;
|
|
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();
|
|
})();
|