games: a blackjack table you can actually sit down at

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.
This commit is contained in:
prosolis
2026-07-13 23:20:42 -07:00
parent cb84e1d549
commit c69fbb63db
17 changed files with 1949 additions and 18 deletions

View File

@@ -527,4 +527,132 @@ html[data-phase="night"] {
font-size: 0.8rem;
font-weight: 700;
}
/* ---- the casino ---------------------------------------------------------
Cards are dealt, not swapped in: each one starts at the shoe in the corner,
flies to its place, and turns over. The whole point of the table is that you
watch it happen, so this is written as one animation per card with a delay,
and the JS just says which cards exist and in what order. */
.pete-felt {
background:
radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.10), transparent 60%),
linear-gradient(160deg, #2f7d5b 0%, #24614a 55%, #1c4d3c 100%);
}
/* The shoe: where every card comes from. */
.pete-shoe {
position: absolute;
top: 1.25rem;
right: 1.25rem;
height: 4.2rem;
width: 3rem;
border-radius: 0.55rem;
background: linear-gradient(150deg, #b4553f, #8d3f2f);
border: 2px solid rgba(0,0,0,0.18);
box-shadow: 0 4px 0 rgba(0,0,0,0.18), inset 0 0 0 3px rgba(255,255,255,0.12);
}
.pete-shoe::after {
content: "";
position: absolute;
inset: 0.45rem 0.4rem auto 0.4rem;
height: 0.5rem;
border-radius: 0.2rem;
background: rgba(0,0,0,0.22);
}
.pete-hand {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
min-height: 6.6rem;
}
/* One card. The wrapper does the flight, the inner face does the flip, so the
two never fight over the same transform. */
.pete-card {
perspective: 700px;
height: 6.4rem;
width: 4.5rem;
animation: pete-deal 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.pete-card-inner {
position: relative;
height: 100%;
width: 100%;
transform-style: preserve-3d;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Face-down is the resting state of a card that hasn't been turned over: the
hole card sits like this until the dealer plays. */
.pete-card[data-face="down"] .pete-card-inner { transform: rotateY(180deg); }
.pete-card-front,
.pete-card-back {
position: absolute;
inset: 0;
display: grid;
border-radius: 0.55rem;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
box-shadow: 0 3px 0 rgba(0,0,0,0.18), 0 6px 14px rgba(0,0,0,0.22);
}
.pete-card-front {
place-items: center;
background: #fdfaf2;
border: 2px solid rgba(30,20,10,0.12);
color: #2b2118;
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
line-height: 1;
}
.pete-card-front[data-red="1"] { color: #cc3d4a; }
.pete-card-back {
transform: rotateY(180deg);
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0 6px, transparent 6px 12px),
linear-gradient(150deg, #b4553f, #8d3f2f);
border: 2px solid rgba(0,0,0,0.15);
}
.pete-card-rank { font-size: 1.5rem; font-weight: 700; }
.pete-card-suit { font-size: 1.15rem; margin-top: 0.1rem; }
/* The flight itself: out of the shoe (up and to the right), scaled down and
spinning slightly, into place. */
@keyframes pete-deal {
from {
opacity: 0;
transform: translate(var(--deal-x, 14rem), var(--deal-y, -7rem)) scale(0.72) rotate(9deg);
}
to {
opacity: 1;
transform: translate(0, 0) scale(1) rotate(0);
}
}
/* A settled hand: the winning side gets a little lift, so a win reads at a
glance rather than only in the text. */
.pete-hand[data-won="1"] .pete-card { animation: pete-deal 0.42s cubic-bezier(0.22,1,0.36,1) backwards, pete-win 0.6s ease 0.1s; }
@keyframes pete-win {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-0.6rem); }
}
/* Chips: the denominations you bet with. */
.pete-chip {
background: radial-gradient(circle at 50% 35%, rgba(255,255,255,0.28), transparent 60%), var(--chip, #e07a5f);
border: 3px dashed rgba(255,255,255,0.55);
box-shadow: 0 3px 0 rgba(60,40,20,0.22), 0 6px 14px rgba(60,40,20,0.18);
}
.pete-chip[data-chip="5"] { --chip: #5aa9e6; }
.pete-chip[data-chip="25"] { --chip: #4caf7d; }
.pete-chip[data-chip="100"] { --chip: #2b2118; }
.pete-chip[data-chip="500"] { --chip: #b079d6; }
.pete-chip[aria-pressed="true"] { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.pete-card { animation: none; }
.pete-card-inner { transition: none; }
.pete-hand[data-won="1"] .pete-card { animation: none; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,302 @@
// The blackjack table.
//
// The browser holds no game. It sends intents — deal, hit, stand, double — and
// the server answers with the cards you're allowed to see plus the *script* of
// how they got there: one event per card off the shoe, in the order the shoe
// gave them up. This file's job is to play that script back at a human speed
// rather than snapping the finished hand into place.
//
// Which is also why the hole card works the way it does: the server sends a
// "dealer_hole" event with no card attached, because while you are still acting
// it hasn't told anyone what that card is. It arrives with the reveal.
(function () {
"use strict";
var root = document.querySelector("[data-blackjack]");
if (!root) return;
var dealerEl = root.querySelector("[data-dealer]");
var playerEl = root.querySelector("[data-player]");
var dTotalEl = root.querySelector("[data-dealer-total]");
var pTotalEl = root.querySelector("[data-player-total]");
var betChip = root.querySelector("[data-bet]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var actions = root.querySelector("[data-actions]");
var betAmount = root.querySelector("[data-bet-amount]");
var dealBtn = root.querySelector("[data-deal]");
var msgEl = root.querySelector("[data-table-msg]");
var bet = 25;
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the hand as the server last described it
var DEAL_MS = 380; // one card's flight, and the gap before the next
var FLIP_MS = 450;
var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone) {
if (!text) { msgEl.classList.add("hidden"); return; }
msgEl.textContent = text;
msgEl.classList.remove("hidden");
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- drawing --------------------------------------------------------------
// cardEl builds one card. face === null means face-down: the card is dealt,
// but this browser has not been told what it is.
function cardEl(face) {
var wrap = document.createElement("div");
wrap.className = "pete-card";
wrap.dataset.face = face ? "up" : "down";
// Every card flies out of the shoe, which sits in the top-right of the felt.
// The offset is per-card, so a card landing further left flies further.
wrap.style.setProperty("--deal-x", "14rem");
wrap.style.setProperty("--deal-y", "-6rem");
var inner = document.createElement("div");
inner.className = "pete-card-inner";
var front = document.createElement("div");
front.className = "pete-card-front";
var back = document.createElement("div");
back.className = "pete-card-back";
inner.appendChild(front);
inner.appendChild(back);
wrap.appendChild(inner);
if (face) paintFace(front, face);
return wrap;
}
function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0";
front.innerHTML = "";
var rank = document.createElement("span");
rank.className = "pete-card-rank";
rank.textContent = face.rank;
var suit = document.createElement("span");
suit.className = "pete-card-suit";
suit.textContent = face.suit;
front.appendChild(rank);
front.appendChild(suit);
front.setAttribute("aria-label", face.label);
}
// turnOver flips a face-down card up, now that we've been told what it is.
function turnOver(wrap, face) {
if (!wrap) return;
paintFace(wrap.querySelector(".pete-card-front"), face);
wrap.dataset.face = "up";
}
function totals(v) {
if (v.total) {
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : "");
pTotalEl.classList.remove("hidden");
} else {
pTotalEl.classList.add("hidden");
}
// While the hole card is down, the dealer's total is only what's showing —
// so say so, rather than printing a number that quietly means something else.
if (v.dealer && v.dealer.length) {
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
dTotalEl.classList.remove("hidden");
} else {
dTotalEl.classList.add("hidden");
}
}
// paint puts a hand on the felt with no animation. This is the resume path:
// you reloaded, or Pete restarted, and your cards are simply there.
function paint(v) {
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
if (!v) { setPhase(null); return; }
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
totals(v);
setPhase(v);
}
var VERDICTS = {
blackjack: "Blackjack! 🎉",
win: "You win!",
dealer_bust: "Dealer busts. You win!",
lose: "Dealer takes it.",
bust: "Bust.",
push: "Push — your bet comes back.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
playerEl.dataset.won = v.net > 0 ? "1" : "0";
}
// setPhase swaps the controls: bet between hands, act during one.
function setPhase(v) {
hand = v;
var live = !!v && v.phase === "player";
betting.classList.toggle("hidden", live);
actions.classList.toggle("hidden", !live);
if (v && v.bet) {
betChip.textContent = "Bet " + v.bet.toLocaleString();
betChip.classList.remove("hidden");
} else {
betChip.classList.add("hidden");
}
if (live) {
var dbl = actions.querySelector('[data-move="double"]');
if (dbl) dbl.disabled = !v.can_double;
}
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
}
// ---- the script -----------------------------------------------------------
// play walks the server's events, one card at a time. It is deliberately the
// only thing that renders during a hand: the final state is applied at the end,
// so what you watch and what the server says can't disagree halfway through.
function play(view) {
var events = view.events || [];
var final = view.hand;
var hole = null; // the face-down card element, once one has been dealt
var chain = Promise.resolve();
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "deal":
dealerEl.innerHTML = "";
playerEl.innerHTML = "";
playerEl.dataset.won = "0";
verdictEl.classList.add("hidden");
return;
case "player_card":
playerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
case "dealer_card":
dealerEl.appendChild(cardEl(e.card));
return wait(DEAL_MS);
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
return wait(DEAL_MS);
case "reveal":
// The hole card turns over. Its face is in the final hand — this is
// the first moment the server has been willing to say what it was.
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
if (hole && final && final.dealer && final.dealer[1]) {
turnOver(hole, final.dealer[1]);
}
return wait(FLIP_MS);
case "settle":
return;
}
});
});
return chain.then(function () {
if (final) {
totals(final);
setPhase(final);
if (final.phase === "done") verdict(final);
} else {
paint(null);
}
});
}
// ---- talking to the table -------------------------------------------------
function send(path, body) {
if (busy) return;
busy = true;
say("");
return window.PeteGames.post(path, body)
.then(function (view) {
window.PeteGames.apply(view);
return play(view);
})
.catch(function (err) {
say(err.message, "bad");
// Whatever we thought was on the felt, the server is the authority on it.
return window.PeteGames.refresh();
})
.then(function () { busy = false; });
}
// ---- betting --------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
root.querySelectorAll("[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
bet += parseInt(btn.dataset.chip, 10);
showBet();
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () { bet = 0; showBet(); });
}
if (dealBtn) {
dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/blackjack/deal", { bet: bet });
});
}
root.querySelectorAll("[data-move]").forEach(function (btn) {
btn.addEventListener("click", function () {
send("/api/games/blackjack/move", { move: btn.dataset.move });
});
});
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
if (!hand || hand.phase !== "player" || busy) return;
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()];
if (!move) return;
if (move === "double" && !hand.can_double) return;
e.preventDefault();
send("/api/games/blackjack/move", { move: move });
});
// The money bar owns the first fetch; the table picks up whatever it found,
// including a hand left sitting on the felt by a reload or a redeploy.
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hand) paint(v.hand);
if (v.hand && v.hand.phase === "done") verdict(v.hand);
}
showBet();
});
})();

View File

@@ -0,0 +1,160 @@
// 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();
})();