Files
Pete/internal/web/static/js/blackjack.js
prosolis 8504c4f47a games: burial send-offs, money rain on wins, and honest stack targeting
Three front-end changes to the casino games:

- uno: a draw-card stack now names the seat it's actually pointed at
  ("+N on Beep") instead of always reading "+N on you". The bill reads
  v.turn, which the engine advances to the target when a draw card lands.

- uno: the No Mercy rule buries a seat with some ceremony now. bury()
  rolls one of four send-offs over the seat (or your hand, if it's you) —
  a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
  burst — each with its own synth sound, and a static headstone under
  reduced motion. The "Buried on N" badge still rides alongside.

- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
  and coins with a jackpot coin-cascade sound, gated by significance so it
  reads as a win and not as noise: hold'em keeps its light per-pot confetti
  and only rains on a real haul (>= 20bb), while the confetti stays the
  rare cherry (natural 21, full board clear, phrase solved outright).
2026-07-15 18:37:19 -07:00

593 lines
23 KiB
JavaScript

// The blackjack table.
//
// The browser holds no game. It sends intents — deal, hit, stand, double, split
// — 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.
//
// The money is animated on the same principle. Every number the server sends is
// also a movement: a stake is chips leaving your pile and landing on the spot, a
// payout is chips coming out of the house's rack, a loss is your stack being
// taken away. Nothing about the money changes on this table without something
// crossing the felt to make it change — including the count in the chip bar,
// which is deliberately not updated until the chips that justify it have landed.
//
// Since split, "the spot" is not one place. A hand has a spot, and a split makes
// a second hand with a second bet on a second spot — two bets that win and lose
// separately, which is the whole point of splitting and has to look like it.
(function () {
"use strict";
var root = document.querySelector("[data-blackjack]");
if (!root) return;
var FX = window.PeteFX;
var dealerEl = root.querySelector("[data-dealer]");
var dTotalEl = root.querySelector("[data-dealer-total]");
var dLabelEl = root.querySelector("[data-dealer-label]");
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]");
// Where the chips live: your pile (in the bar above), a spot in front of each
// hand, and the house's rack on the felt.
var purseEl = document.querySelector("[data-chips]");
var houseEl = root.querySelector("[data-house]");
var handsEl = root.querySelector("[data-hands]");
var handTpl = root.querySelector("[data-hand-template]");
var bet = 0; // what you're building between hands
var base = 0; // what one hand of the last deal cost, for standing it back up
var busy = false; // a request is in flight, or cards are still landing
var hand = null; // the deal 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 BEAT_MS = 600; // the dealer thinking before they draw out
var SPLIT_MS = 340; // the card lifting out of the hand it's leaving
var reduced = FX.reduced;
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 --------------------------------------------------------------
//
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
// every other table in the room. Here a card is always dealt out of the shoe
// and always lands with a degree or two of tilt on it, which are this table's
// two opinions about a card and the only ones it has.
var CARDS = window.PeteCards;
function cardEl(face) { return CARDS.el(face); }
var turnOver = CARDS.turnOver;
// ---- the hands -------------------------------------------------------------
//
// A box per hand: its chips, its cards, its total, its own verdict. There is
// always at least one, and before a deal that one is where you build your bet —
// so the chips you stack are already sitting on the hand they're about to buy.
var hands = [];
function makeHand(at) {
var el = handTpl.content.firstElementChild.cloneNode(true);
var spotEl = el.querySelector("[data-spot]");
var box = {
el: el,
spotEl: spotEl, // where a chip flies to, as opposed to what counts it
cards: el.querySelector("[data-cards]"),
total: el.querySelector("[data-total]"),
verdict: el.querySelector("[data-hand-outcome]"),
ranks: [], // what's in it, for a running total while the cards are landing
spot: FX.spot({
spot: spotEl,
stack: el.querySelector("[data-stack]"),
total: el.querySelector("[data-spot-total]"),
}),
};
if (at === undefined || at >= hands.length) {
handsEl.appendChild(el);
hands.push(box);
} else {
handsEl.insertBefore(el, hands[at].el);
hands.splice(at, 0, box);
}
handsEl.dataset.count = hands.length;
return box;
}
// reset tears the row down to n empty hands. It does not preserve chips: a
// caller that has some to keep (a deal, whose stake is already on the spot)
// reads the amount first and puts it back.
function reset(n) {
handsEl.innerHTML = "";
hands = [];
for (var i = 0; i < (n || 1); i++) makeHand();
}
function ensure(n) { while (hands.length < n) makeHand(); }
// The bet you build between hands is hand zero's.
function betSpot() { return hands[0].spot; }
// live marks the hand you're being asked about, and dims the ones you aren't.
//
// -1 means nobody is acting — the dealer is drawing, or the deal is over — and
// that is *not* the same as every hand being inactive: a settled hand is the
// thing you are reading, so it goes back to full brightness rather than sitting
// there greyed out. Hence the empty string: it matches neither CSS rule.
function live(i) {
hands.forEach(function (h, k) {
h.el.dataset.live = i < 0 ? "" : k === i ? "1" : "0";
});
}
// The running total under a hand, worked out from the cards you can see. The
// server sends totals too, but only with the finished state — and a hand you're
// playing needs to know what it's holding *now*, not once it's over. It's a
// readout, never a decision: every ruling is the server's.
var TEN = { K: 10, Q: 10, J: 10 };
function totalOf(ranks) {
var total = 0, aces = 0;
ranks.forEach(function (r) {
if (r === "A") { aces++; total += 11; }
else total += TEN[r] || parseInt(r, 10) || 0;
});
while (total > 21 && aces > 0) { total -= 10; aces--; }
return { total: total, soft: aces > 0 };
}
function showTotal(box) {
if (!box.ranks.length) { box.total.classList.add("hidden"); return; }
var v = totalOf(box.ranks);
box.total.textContent = v.total + (v.soft ? " (soft)" : "");
box.total.classList.remove("hidden");
}
var SHORT = {
blackjack: "Blackjack", win: "Won", dealer_bust: "Won",
lose: "Lost", bust: "Bust", push: "Push",
};
// A hand's own result, on the hand. With one hand this says the same thing as
// the pill above it and is a bit redundant; with four it is the only way to
// read what happened, because "you win" is not true of all of them.
function showHandVerdict(box, h) {
var text = h && SHORT[h.outcome];
if (!text) { box.verdict.classList.add("hidden"); return; }
box.verdict.textContent = text;
box.verdict.classList.remove("hidden");
}
// ---- the money on the felt -------------------------------------------------
// settleChips is what the felt does about the outcome, after the cards have
// finished telling you what it is. Each hand is paid on its own, in order, left
// to right — because each hand *is* its own bet, and a split that lost one and
// won the other has to be watchable as exactly that.
function settleChips(final) {
var chain = Promise.resolve();
(final.hands || []).forEach(function (h, i) {
chain = chain.then(function () {
var box = hands[i];
if (!box) return;
var payout = h.payout || 0;
if (payout <= 0) {
// The house takes it. The stack goes to the rack and doesn't come back.
return box.spot.sweep(houseEl, h.bet, { gap: 45, lift: 0.6, fade: true });
}
// The house pays first, into the spot beside the stake, so you watch the
// winnings arrive on top of the bet that earned them.
var back = payout - h.bet;
return box.spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 340 : 160); })
.then(function () { return box.spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
});
});
return chain;
}
// 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.
function dealerTotal(v) {
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 deal on the felt with no animation. This is the resume path: you
// reloaded, or Pete restarted, and your cards are simply there — including the
// stakes, which are still on their spots because the server still has them.
function paint(v) {
dealerEl.innerHTML = "";
if (!v) { reset(1); setPhase(null); return; }
reset(v.hands.length || 1);
v.hands.forEach(function (h, i) {
var box = hands[i];
(h.cards || []).forEach(function (c) {
box.cards.appendChild(cardEl(c));
box.ranks.push(c.rank);
});
box.spot.render(v.phase === "done" ? 0 : h.bet);
showTotal(box);
showHandVerdict(box, v.phase === "done" ? h : null);
});
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
if (v.hole) dealerEl.appendChild(cardEl(null));
dealerTotal(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] || "";
// Across several hands "you win" is a claim about the money, not about the
// cards: you can win one, lose one, and still be up. The pill reports the
// deal; the badge on each hand reports the hand.
if ((v.hands || []).length > 1) {
text = v.net > 0 ? "You're up on the deal." : v.net < 0 ? "Down on the deal." : "All square.";
}
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");
// Every win gets it to rain, because a win should feel like one. A natural is
// rarer and pays 3:2, so it keeps the confetti on top of the money as well.
if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// setPhase swaps the controls: bet between hands, act during one.
function setPhase(v) {
hand = v;
var acting = !!v && v.phase === "player";
betting.classList.toggle("hidden", acting);
actions.classList.toggle("hidden", !acting);
if (acting) {
var dbl = actions.querySelector('[data-move="double"]');
var spl = actions.querySelector('[data-move="split"]');
if (dbl) dbl.disabled = !v.can_double;
if (spl) spl.disabled = !v.can_split;
live(v.active || 0);
} else {
live(-1);
}
if (!acting) 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.
//
// `money` is the one exception, and it's a deliberate one. On a hand that is
// still running, the chip bar is right immediately — your stake left your pile
// when you pressed Deal, and it's sitting on the spot where you can see it. On
// a hand that *settles*, the bar is left alone until the chips have physically
// come home, because a counter that pays you before the dealer has turned over
// is a counter that has told you the ending.
function play(view, money) {
var events = view.events || [];
var final = view.hand;
var settles = !!final && final.phase === "done";
var hole = null; // the face-down card element, once one has been dealt
var chain = Promise.resolve();
var drew = false; // has the dealer drawn since the reveal?
if (!settles) money();
// A deal whose bet was typed rather than stacked (you kept last hand's number
// and just pressed Deal) has chips to put down before the first card does.
// Everything else that costs money — a double, a split — announces itself as
// an event, and pays for itself there.
if (final && final.hands && final.hands.length && events.length && events[0].kind === "deal") {
var want = final.hands[0].bet;
var have = betSpot().amount;
if (want > have) {
chain = chain.then(function () { return betSpot().pour(purseEl, want - have); });
}
}
events.forEach(function (e) {
chain = chain.then(function () {
var box;
switch (e.kind) {
case "deal":
// Clear the felt, but not the stake: those chips are yours, they are
// already on the spot, and they are what this deal is riding on.
var staked = betSpot().amount;
dealerEl.innerHTML = "";
reset(1);
betSpot().render(staked);
verdictEl.classList.add("hidden");
FX.sfx("shuffle");
return;
case "player_card":
ensure(e.hand + 1);
box = hands[e.hand];
box.cards.appendChild(cardEl(e.card));
box.ranks.push(e.card.rank);
showTotal(box);
live(e.hand);
FX.sfx("deal");
return wait(DEAL_MS);
case "split":
// The second card lifts out of the hand it was in and becomes a hand
// of its own, and the bet that follows it is a second bet: the same
// chips again, out of your pile, onto a spot that didn't exist a
// moment ago.
var src = hands[e.hand];
var moved = src.cards.lastElementChild;
var stake = src.spot.amount;
var fresh = makeHand(e.hand + 1);
if (moved) moved.classList.add("bj-splitting");
FX.sfx("deal", { v: 1 });
return wait(SPLIT_MS).then(function () {
if (moved) {
moved.classList.remove("bj-splitting");
fresh.cards.appendChild(moved);
}
fresh.ranks.push(src.ranks.pop());
showTotal(src);
showTotal(fresh);
return fresh.spot.pour(purseEl, stake);
});
case "double":
// The stake goes down again on that hand, and only that hand.
box = hands[e.hand];
return box.spot.pour(purseEl, box.spot.amount);
case "dealer_card":
// The dealer takes a moment before the first card they draw out.
// Card, card, card with no breath in between is a machine dealing;
// the pause is the only thing on this table that plays as suspense.
var beat = drew ? Promise.resolve() : think();
drew = true;
live(-1);
return beat.then(function () {
dealerEl.appendChild(cardEl(e.card));
FX.sfx("deal", { v: 1 });
return wait(DEAL_MS);
});
case "dealer_hole":
hole = cardEl(null);
dealerEl.appendChild(hole);
FX.sfx("deal", { v: 2 });
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.
live(-1);
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) { paint(null); money(); return; }
dealerTotal(final);
if (!settles) { setPhase(final); return; }
// The deal is over: nothing is on offer while the money is moving. The
// actions go now, and Deal comes back at the far end.
actions.classList.add("hidden");
live(-1);
(final.hands || []).forEach(function (h, i) {
if (hands[i]) showHandVerdict(hands[i], h);
});
verdict(final);
// The chips move, and the bar catches up with them when they arrive. The
// betting controls come back last, once the felt is clear: offering Deal
// over a table that is still being paid out invites a click the table then
// has to refuse.
return settleChips(final)
.then(money)
.then(function () { return standing(base || unitBet(final.hands[0])); })
.then(function () { setPhase(final); });
});
}
// standing leaves your bet up for the next hand, the way you would at a table:
// one hand's stake goes straight back on the spot. It costs nothing — chips on
// the spot are a proposal until you press Deal — and it's what keeps the number
// in the panel honest, because otherwise a settled hand leaves "your bet: 300"
// printed over an empty spot.
//
// It's one hand's worth, not the whole deal's: a split cost you four hundred,
// and standing four hundred back up on one spot would be betting a stranger's
// money on your behalf.
// unitBet is what one hand of a deal actually cost, read back off a settled
// hand. It exists for the reload: `base` only knows what you pressed Deal with,
// and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice
// the stake it was dealt with, so standing its bet back up would quietly put you
// on double the number you thought you were playing.
function unitBet(h) {
if (!h) return 0;
return h.doubled ? h.bet / 2 : h.bet;
}
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return betSpot().pour(purseEl, amount);
}
// think is the dealer's beat: a pause with something to look at, so it reads as
// deliberation rather than as the page having hung.
function think() {
if (reduced || !dLabelEl) return wait(0);
dLabelEl.classList.add("pete-dealer-think");
return wait(BEAT_MS).then(function () {
dLabelEl.classList.remove("pete-dealer-think");
});
}
// ---- talking to the table -------------------------------------------------
function send(path, body) {
if (busy) return;
busy = true;
say("");
return window.PeteGames.post(path, body)
.then(function (view) {
// play() decides *when* the money lands; see the note on it.
return play(view, function () { window.PeteGames.apply(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 (v) {
if (v && !v.hand) { reset(1); setPhase(null); }
else if (v && v.hand) paint(v.hand);
});
})
.then(function () { busy = false; });
}
// ---- betting --------------------------------------------------------------
//
// A bet is built by putting chips on the spot, one at a time, and it is those
// chips the deal then rides on — the number under the pile is a readout of the
// pile, not the other way round.
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
// rack, and the house is not betting.
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
// The chip you clicked is the chip that flies: same colour, same size, off
// the button and onto the felt. The pile only grows once it gets there —
// but the spot's total moves now, so a Deal pressed mid-flight still knows
// the chip is on its way and doesn't put a second one down.
var target = bet;
var spot = betSpot();
spot.amount = bet;
FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !betSpot().amount) { bet = 0; showBet(); return; }
betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
}
if (dealBtn) {
dealBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
base = bet; // one hand's worth, which is what a split doubles and a stand puts back
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", p: "split" }[e.key.toLowerCase()];
if (!move) return;
if (move === "double" && !hand.can_double) return;
if (move === "split" && !hand.can_split) 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 deal left sitting on the felt by a reload or a redeploy.
reset(1);
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();
});
})();