// 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;
}
// ---- the face ------------------------------------------------------------
//
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
// span — and at the size a card actually is, a suit character renders as a
// speck: the shape is whatever font happened to answer, it doesn't scale, and
// it can't be positioned to the half-row a real pip layout needs.
//
// So each face is one SVG on a 100×140 field (the proportions of a real card),
// with the suits as vector shapes. Everything below is coordinates on that
// field, which is why the pips land where a printed deck puts them instead of
// where a flexbox felt like putting them.
var SUIT_ART = {
"♠": '',
"♥": '',
"♦": '',
"♣": '' +
'',
};
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
// them, which is the whole reason this is a table of coordinates and not a
// grid. Anything below the middle is printed upside down, so it is.
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
var L = 30, C = 50, Rr = 70; // the three columns
var PIPS = {
"A": [[C, 70, 2.1]],
"2": [[C, R[1]], [C, R[7]]],
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
};
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
// below the middle of the card.
function pipAt(suit, x, y, scale) {
var s = (scale || 1) * 0.17;
var turn = y > 70 ? " rotate(180 50 50)" : "";
return '' +
SUIT_ART[suit] + "";
}
// The corner index: rank over suit. Printed in both corners, the second one
// upside down, which is what lets you read a card from a fanned hand.
function index(face) {
var g =
'' +
'' + face.rank + "" +
'' + SUIT_ART[face.suit] + "" +
"";
return g + '' + g + "";
}
// paintFace draws the card. 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";
var body = "";
if (COURT[face.rank]) {
// Court cards: a framed panel, the suit above the letter and again below it
// the other way up. A real court mirrors a *figure*; mirroring a letter just
// stacks two of them into a blob, which is exactly what the first attempt
// did. No portrait either — a drawn king would fight the room, and this
// reads instantly at the size a card actually is.
body =
'' +
pipAt(face.suit, 50, 38, 0.95) +
'' + face.rank + "" +
pipAt(face.suit, 50, 102, 0.95);
} else {
var spots = PIPS[face.rank] || [];
for (var i = 0; i < spots.length; i++) {
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
}
}
front.innerHTML =
'";
}
// "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();
});
})();