Files
Pete/internal/web/static/js/blackjack.js
prosolis 8ec13eab5b games: the casino moves out, and gets a clock of its own
The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.

So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.

It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.

And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
2026-07-13 23:40:33 -07:00

389 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". Each entry is [column, row] on a 3×7 grid: three columns, seven rows,
// the same skeleton every printed deck has used for about four hundred years.
// A pip below the halfway line is printed upside down, so it does that here.
//
// Sevens and tens have a pip that sits *between* two rows, so those span a pair
// of rows and centre themselves across the pair.
var PIPS = {
"A": [[2, 4]],
"2": [[2, 1], [2, 7]],
"3": [[2, 1], [2, 4], [2, 7]],
"4": [[1, 1], [3, 1], [1, 7], [3, 7]],
"5": [[1, 1], [3, 1], [2, 4], [1, 7], [3, 7]],
"6": [[1, 1], [3, 1], [1, 4], [3, 4], [1, 7], [3, 7]],
"7": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [1, 7], [3, 7]],
"8": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [2, "4/6"], [1, 7], [3, 7]],
"9": [[1, 1], [3, 1], [1, 3], [3, 3], [2, 4], [1, 5], [3, 5], [1, 7], [3, 7]],
"10": [[1, 1], [3, 1], [2, "2/4"], [1, 3], [3, 3], [1, 5], [3, 5], [2, "4/6"], [1, 7], [3, 7]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
function corner(face, where) {
var el = document.createElement("span");
el.className = "pete-card-corner pete-card-corner-" + where;
var r = document.createElement("span");
r.className = "pete-card-corner-rank";
r.textContent = face.rank;
var s = document.createElement("span");
s.className = "pete-card-corner-suit";
s.textContent = face.suit;
el.appendChild(r);
el.appendChild(s);
return el;
}
function pip(face, col, row) {
var el = document.createElement("span");
el.className = "pete-card-pip";
el.textContent = face.suit;
el.style.gridColumn = String(col);
// A row given as "2/4" spans that pair and centres between them.
el.style.gridRow = String(row);
// The bottom half of a real card is printed the other way up.
var mid = typeof row === "number" ? row > 4 : row === "4/6";
if (mid) el.dataset.flip = "1";
return el;
}
// paintFace draws an actual playing card: index in two opposite corners, and
// either the pips or a court figure in the middle. The dealer's cards and
// yours use the same face, because they came out of the same shoe.
function paintFace(front, face) {
front.dataset.red = face.red ? "1" : "0";
front.innerHTML = "";
front.appendChild(corner(face, "tl"));
var body = document.createElement("span");
if (COURT[face.rank]) {
// Court cards: the letter, big, with the suit sitting over each shoulder.
// No portrait — a drawn face would fight the room, and this reads instantly.
body.className = "pete-card-court";
var top = document.createElement("span");
top.className = "pete-card-court-suit";
top.textContent = face.suit;
var letter = document.createElement("span");
letter.className = "pete-card-court-letter";
letter.textContent = face.rank;
var bot = document.createElement("span");
bot.className = "pete-card-court-suit";
bot.dataset.flip = "1";
bot.textContent = face.suit;
body.appendChild(top);
body.appendChild(letter);
body.appendChild(bot);
} else {
body.className = "pete-card-pips";
// An ace gets one big pip in the middle, the way an ace does.
if (face.rank === "A") body.dataset.ace = "1";
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body.appendChild(pip(face, spots[i][0], spots[i][1]));
}
}
front.appendChild(body);
front.appendChild(corner(face, "br"));
front.setAttribute("aria-label", ariaFor(face));
}
// "A♠" is not something a screen reader should be asked to pronounce.
function ariaFor(face) {
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
var suit = SUITS[face.suit];
return suit ? name + " of " + suit : 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();
});
})();