Every text-[color:var(--ink)]/50, border-[color:var(--ink)]/10 and
bg-[color:var(--ink)]/5 in the templates compiled to nothing. Tailwind v3
cannot put an opacity on an arbitrary var() colour, so all 538 of them were
dropped from output.css without a word. Meta text rendered at full ink, card
borders fell back to Tailwind's default grey (bright white rings at night),
and the washes, backdrops and hover pills simply weren't there, on the news
site, the casino and the adventure pages alike.
The palette is now written once per phase and casino room as bare rgb
channels (--ink-rgb and friends), the plain vars are built from them, and
tailwind.config.js exposes ink, muted, accent, warn and card as real colours.
The classes are text-ink/80, border-ink/10, bg-accent/20 now, and they compile.
Restoring the intended opacities as-is would have traded flat for illegible:
ink at 50% measures 2.7 to 2.9:1 on the lit phases. So secondary text gets
its own colour, --muted, per phase and per casino room, taken as faint as it
can go while still clearing 4.5:1 on both the card and the page. Everything
that was ink at 35 to 70% is text-muted. The 20-30% ones are separators and
empty-cell dashes and stay faint.
The bg-theme-* chips carry white text everywhere they appear, and eight of
them measured 2.5 to 3.2:1. Same hues, taken just dark enough to pass (4.6 to
4.7:1). eu, lego and adventure already passed and are unchanged, and the
underline and border colours keep the old pastels since they carry no text.
Static files came out of the embedded FS with no Last-Modified, no ETag and
no Cache-Control, so every page view downloaded the stylesheet and every
script in full, and the service worker's "cheap 304" never happened. Each
file is hashed once now. Templates link through {{asset}}, which appends
?v=<hash>; a request naming the current hash is served immutable for a year,
and everything else gets the hash as its ETag and revalidates to a 304. The
service worker serves versioned URLs cache-first, drops older versions of the
same file as new ones land, and moves to cache v6.
901 lines
34 KiB
JavaScript
901 lines
34 KiB
JavaScript
// The hold'em table.
|
|
//
|
|
// Same bargain as every other table in the room: the browser holds no game. It
|
|
// sends one move, and what comes back is that move *and every bot action behind
|
|
// it* — plus whatever streets that finished and whatever the pot did — as a
|
|
// script of events. So a round trip here can be a whole hand: shove all-in and
|
|
// the flop, turn, river, showdown and payout all arrive in one response, and this
|
|
// file's job is to play them back slowly enough that you can watch it happen to
|
|
// you.
|
|
//
|
|
// The one thing here that no other table has is a *second seat with money on it*.
|
|
// Everywhere else the spot is a singleton, because there was only ever you and
|
|
// the house. Here every seat has its own, chips move from a seat to its spot and
|
|
// from every spot into the pot, and out of the pot to whoever won it. PeteFX.spot
|
|
// still owns the rule that the number under a pile is a readout of that pile, so
|
|
// none of that arithmetic lives in here.
|
|
//
|
|
// And the browser never learns a bot's hand. Their cards are backs until a
|
|
// showdown turns them over, because a showdown is the only time a player at a
|
|
// real table would be looking at them.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var root = document.querySelector("[data-holdem]");
|
|
if (!root) return;
|
|
|
|
var FX = window.PeteFX;
|
|
var Cards = window.PeteCards;
|
|
|
|
var seatsEl = root.querySelector("[data-seats]");
|
|
var youEl = root.querySelector("[data-you]");
|
|
var boardEl = root.querySelector("[data-board]");
|
|
var potStack = root.querySelector("[data-pot-stack]");
|
|
var potTotal = root.querySelector("[data-pot-total]");
|
|
var sideEl = root.querySelector("[data-side]");
|
|
var blindsEl = root.querySelector("[data-blinds]");
|
|
var tableName = root.querySelector("[data-table-name]");
|
|
var verdictEl = root.querySelector("[data-verdict]");
|
|
var houseEl = root.querySelector("[data-house]");
|
|
|
|
var acting = root.querySelector("[data-acting]");
|
|
var between = root.querySelector("[data-between]");
|
|
var sitting = root.querySelector("[data-sitting]");
|
|
|
|
var foldBtn = root.querySelector('[data-move="fold"]');
|
|
var checkBtn = root.querySelector('[data-move="check"]');
|
|
var callBtn = root.querySelector('[data-move="call"]');
|
|
var raiseBtn = root.querySelector('[data-move="raise"]');
|
|
var callAmt = root.querySelector("[data-call-amount]");
|
|
var raiseRow = root.querySelector("[data-raise-row]");
|
|
var slider = root.querySelector("[data-raise-slider]");
|
|
var raiseTo = root.querySelector("[data-raise-to]");
|
|
var raiseLbl = root.querySelector("[data-raise-label]");
|
|
var raiseVerb = root.querySelector("[data-raise-verb]");
|
|
|
|
var dealBtn = root.querySelector("[data-deal]");
|
|
var topupBtn = root.querySelector("[data-topup]");
|
|
var leaveBtn = root.querySelector("[data-leave]");
|
|
var stackEl = root.querySelector("[data-table-stack]");
|
|
var boughtEl = root.querySelector("[data-bought-in]");
|
|
var rakeEl = root.querySelector("[data-session-rake]");
|
|
|
|
var chatSection = root.querySelector("[data-chat]");
|
|
var chatLog = root.querySelector("[data-chat-log]");
|
|
var chatSeen = {}; // chat line ids already on the rail, to drop echoed duplicates
|
|
var chatForm = root.querySelector("[data-chat-form]");
|
|
var chatInput = root.querySelector("[data-chat-input]");
|
|
var lobbyWrap = root.querySelector("[data-lobby-wrap]");
|
|
var lobbyEl = root.querySelector("[data-lobby]");
|
|
|
|
var sitBtn = root.querySelector("[data-sit]");
|
|
var buySlider = root.querySelector("[data-buyin-slider]");
|
|
var buyLabel = root.querySelector("[data-buyin]");
|
|
var buyNote = root.querySelector("[data-buyin-note]");
|
|
var botsNote = root.querySelector("[data-bots-note]");
|
|
var gameMsg = root.querySelector("[data-game-msg]");
|
|
var betweenMsg = root.querySelector("[data-between-msg]");
|
|
var tableMsg = root.querySelector("[data-table-msg]");
|
|
|
|
var view = null; // the table, as the server last described it
|
|
var me = 0; // which seat is yours — no longer always zero, at a shared table
|
|
var busy = false;
|
|
var pendingSync = false; // a pushed frame arrived mid-animation; re-render when free
|
|
var stream = null; // the open EventSource, when seated
|
|
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
|
var shown = []; // what each seat's stack label currently reads
|
|
var pot = null; // the middle pile, a PeteFX.spot
|
|
|
|
var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]"));
|
|
var botBtns = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]"));
|
|
var tier = null;
|
|
var bots = 2;
|
|
var buyIn = 0;
|
|
|
|
function money(n) { return (n || 0).toLocaleString(); }
|
|
|
|
function say(el, text, tone) {
|
|
if (!el) return;
|
|
if (!text) { el.classList.add("hidden"); return; }
|
|
el.textContent = text;
|
|
el.classList.remove("hidden");
|
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
|
}
|
|
|
|
// ---- building the felt ----------------------------------------------------
|
|
|
|
// seat builds one player: their cards, their name and stack, and the spot their
|
|
// bet sits on. Bots go in the row along the top; you get your own, bigger.
|
|
function seat(s, i, mine) {
|
|
var wrap = document.createElement("div");
|
|
wrap.className = "pete-seat";
|
|
wrap.dataset.seat = i;
|
|
wrap.dataset.state = s.state;
|
|
|
|
var cards = document.createElement("div");
|
|
cards.className = "pete-seat-cards";
|
|
|
|
var plate = document.createElement("div");
|
|
plate.className = "pete-seat-plate";
|
|
var name = document.createElement("span");
|
|
name.className = "pete-seat-name";
|
|
name.textContent = s.name;
|
|
var stack = document.createElement("span");
|
|
stack.className = "pete-seat-stack";
|
|
stack.textContent = money(s.stack);
|
|
plate.appendChild(name);
|
|
plate.appendChild(stack);
|
|
|
|
// The button, the blinds. It hangs off the name plate rather than the seat,
|
|
// because the seat's corner is a different place for you than for a bot — your
|
|
// bet spot is above your cards and theirs is below — and a badge that floats
|
|
// over an empty betting circle reads as a bug.
|
|
if (s.pos) {
|
|
var pos = document.createElement("span");
|
|
pos.className = "pete-seat-pos";
|
|
pos.dataset.pos = s.pos;
|
|
pos.textContent = s.pos;
|
|
plate.appendChild(pos);
|
|
}
|
|
|
|
var spotEl = document.createElement("div");
|
|
spotEl.className = "pete-spot pete-seat-spot";
|
|
var pile = document.createElement("div");
|
|
pile.className = "pete-stack";
|
|
var total = document.createElement("span");
|
|
// The shared class, not one of our own: it hangs the number *below* the ring,
|
|
// which is what keeps the chips from landing on top of it.
|
|
total.className = "pete-spot-total";
|
|
spotEl.appendChild(pile);
|
|
spotEl.appendChild(total);
|
|
|
|
// Your bet sits between you and the board, so it goes above your cards; a
|
|
// bot's sits between them and the board, so it goes below theirs.
|
|
if (mine) {
|
|
wrap.appendChild(spotEl);
|
|
wrap.appendChild(cards);
|
|
wrap.appendChild(plate);
|
|
} else {
|
|
wrap.appendChild(cards);
|
|
wrap.appendChild(plate);
|
|
wrap.appendChild(spotEl);
|
|
}
|
|
|
|
var api = FX.spot({ spot: spotEl, stack: pile, total: total });
|
|
api.render(s.bet);
|
|
|
|
paintCards(cards, s, mine);
|
|
return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api };
|
|
}
|
|
|
|
// paintCards puts the two cards in front of a seat. A bot's are backs, unless
|
|
// the server has actually sent us faces — which it only ever does at a showdown.
|
|
function paintCards(el, s, mine) {
|
|
el.innerHTML = "";
|
|
if (s.state === "out") return;
|
|
var faces = s.cards || [];
|
|
var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2);
|
|
for (var i = 0; i < n; i++) {
|
|
el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine }));
|
|
}
|
|
}
|
|
|
|
function render(v) {
|
|
view = v;
|
|
me = v.your_seat || 0;
|
|
|
|
// The seats along the top, and you underneath.
|
|
seatsEl.innerHTML = "";
|
|
youEl.innerHTML = "";
|
|
seatEls = [];
|
|
shown = [];
|
|
v.seats.forEach(function (s, i) {
|
|
var mine = i === me;
|
|
var built = seat(s, i, mine);
|
|
seatEls[i] = built;
|
|
shown[i] = s.stack;
|
|
(mine ? youEl : seatsEl).appendChild(built.root);
|
|
built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0";
|
|
});
|
|
|
|
// The board.
|
|
boardEl.innerHTML = "";
|
|
(v.board || []).forEach(function (c) {
|
|
boardEl.appendChild(Cards.el(c, { deal: false, tilt: false }));
|
|
});
|
|
|
|
// The pot. Its chips live in a box of their own — see .pete-poker-pot-pile —
|
|
// so the number underneath stays readable.
|
|
pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null });
|
|
pot.render(v.pot);
|
|
potTotal.textContent = money(v.pot);
|
|
blindsEl.textContent = v.tier.sb + "/" + v.tier.bb;
|
|
tableName.textContent = v.tier.name;
|
|
if (v.side && v.side.length > 1) {
|
|
sideEl.textContent = v.side.map(function (n, i) {
|
|
return (i === 0 ? "main " : "side ") + money(n);
|
|
}).join(" · ");
|
|
sideEl.classList.remove("hidden");
|
|
} else {
|
|
sideEl.classList.add("hidden");
|
|
}
|
|
|
|
stackEl.textContent = money(v.stack);
|
|
boughtEl.textContent = money(v.bought_in);
|
|
rakeEl.textContent = money(v.rake);
|
|
|
|
panels();
|
|
}
|
|
|
|
// panels decides which of the three bars is showing: the one that acts, the one
|
|
// between hands, or the one you sit down from.
|
|
function panels() {
|
|
// A session you have got up from is not a live one: the felt still shows the
|
|
// last hand, but the table you sit down at is the one that's open to you.
|
|
var live = !!view && view.phase !== "done";
|
|
sitting.classList.toggle("hidden", live);
|
|
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== me);
|
|
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
|
if (chatSection) chatSection.classList.toggle("hidden", !live);
|
|
if (!live) return;
|
|
|
|
if (view.phase === "betting" && view.to_act === me) {
|
|
checkBtn.classList.toggle("hidden", !view.can_check);
|
|
callBtn.classList.toggle("hidden", view.can_check);
|
|
callAmt.textContent = money(view.owed);
|
|
|
|
raiseRow.classList.toggle("hidden", !view.can_raise);
|
|
if (view.can_raise) {
|
|
slider.min = view.min_raise_to;
|
|
slider.max = view.max_raise_to;
|
|
slider.step = Math.max(1, view.tier.bb / 2);
|
|
slider.value = Math.min(view.max_raise_to, view.min_raise_to);
|
|
// "Bet" when nobody has, "Raise to" when somebody has. It is the same
|
|
// move and the same button, but calling a bet a raise is how you tell a
|
|
// player who has never played that this table is confused.
|
|
raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet";
|
|
showRaise();
|
|
}
|
|
}
|
|
if (view.phase === "handover") {
|
|
// The table has room for max_topup, but the button must not promise chips the
|
|
// wallet cannot cover — clicking it would only earn a refusal.
|
|
var wallet = window.PeteGames.view();
|
|
var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
|
|
topupBtn.disabled = can <= 0;
|
|
topupBtn.dataset.amount = can;
|
|
topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
|
|
}
|
|
}
|
|
|
|
function showRaise() {
|
|
var to = Number(slider.value);
|
|
raiseTo.textContent = money(to);
|
|
raiseLbl.textContent = money(to);
|
|
}
|
|
|
|
// ---- playing the script ---------------------------------------------------
|
|
|
|
var STREETS = { flop: 1, turn: 1, river: 1 };
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); });
|
|
}
|
|
|
|
// play walks the events the server sent, one beat at a time, and only then
|
|
// re-renders the table it ended on. Everything the player is meant to see
|
|
// happening happens here; render() is the state it settles into.
|
|
function play(events, final) {
|
|
var chain = Promise.resolve();
|
|
if (!events || !events.length) {
|
|
// Nothing to animate. Either settle into the table we ended on, or — if the
|
|
// session closed and storage cleared it — clear the felt.
|
|
if (final) render(final); else render0();
|
|
return chain;
|
|
}
|
|
|
|
events.forEach(function (e) {
|
|
chain = chain.then(function () { return beat(e, final); });
|
|
});
|
|
return chain.then(function () {
|
|
// A hand can be the last one — a bust closes the table, so there is no state
|
|
// to settle into. Play it out and say what it did anyway (the seats are still
|
|
// on the felt from the previous render), then clear.
|
|
verdict(events, final);
|
|
if (final) render(final); else render0();
|
|
});
|
|
}
|
|
|
|
function beat(e, final) {
|
|
var s = seatEls[e.seat];
|
|
|
|
switch (e.kind) {
|
|
case "hand":
|
|
// A new deal: clear the felt before anything lands on it.
|
|
boardEl.innerHTML = "";
|
|
verdictEl.classList.add("hidden");
|
|
seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; });
|
|
pot.render(0);
|
|
potTotal.textContent = "0";
|
|
sideEl.classList.add("hidden");
|
|
FX.sfx("shuffle");
|
|
return wait(140);
|
|
|
|
case "rebuy":
|
|
if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); }
|
|
return wait(220);
|
|
|
|
case "blind":
|
|
if (!s) return;
|
|
moveStack(e.seat, -e.amount);
|
|
return s.spot.pour(s.plate, e.amount);
|
|
|
|
case "hole":
|
|
// Two cards to everybody, round the table, as they are actually dealt.
|
|
return dealHoles(final);
|
|
|
|
case "action":
|
|
return action(e, final);
|
|
|
|
case "flop":
|
|
case "turn":
|
|
case "river":
|
|
return street(e);
|
|
|
|
case "pot":
|
|
// The bets in front of the seats have been swept in. Nothing else in the
|
|
// script says so, and the pot is about to be paid out of.
|
|
return collect(e.amount);
|
|
|
|
case "uncalled":
|
|
if (!s) return;
|
|
return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); });
|
|
|
|
case "show":
|
|
if (!s) return;
|
|
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === me);
|
|
flash(s.root);
|
|
return wait(420);
|
|
|
|
case "rake":
|
|
// The house takes its cut out of the pot, in front of you, so it is a
|
|
// thing that visibly happens rather than a number that quietly differs.
|
|
return pot.sweep(houseEl, e.amount).then(function () {
|
|
potTotal.textContent = money(pot.amount);
|
|
return wait(160);
|
|
});
|
|
|
|
case "win":
|
|
if (!s) return;
|
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
|
potTotal.textContent = money(pot.amount);
|
|
moveStack(e.seat, e.amount);
|
|
if (e.seat === me && e.amount > 0) {
|
|
FX.burst(s.plate, { count: 18 });
|
|
// A real haul makes it rain; a blind-steal doesn't, or poker would be
|
|
// raining money every thirty seconds. Twenty big blinds is a pot.
|
|
if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
|
|
}
|
|
return wait(260);
|
|
});
|
|
|
|
case "end":
|
|
return wait(280);
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// dealHoles puts two cards in front of everyone still in the hand. Yours land
|
|
// face up; theirs land as backs, because that is all that came over the wire.
|
|
function dealHoles(final) {
|
|
var chain = Promise.resolve();
|
|
for (var round = 0; round < 2; round++) {
|
|
(function (round) {
|
|
chain = chain.then(function () {
|
|
var beats = [];
|
|
final.seats.forEach(function (s, i) {
|
|
if (s.state === "out") return;
|
|
var built = seatEls[i];
|
|
if (!built) return;
|
|
var face = (i === me && s.cards) ? s.cards[round] : null;
|
|
var card = Cards.el(face, { deal: true, tilt: i !== me });
|
|
built.cards.appendChild(card);
|
|
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
|
beats.push(wait(70 * i));
|
|
});
|
|
return Promise.all(beats).then(function () { return wait(180); });
|
|
});
|
|
})(round);
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
// action animates one seat doing one thing.
|
|
function action(e, final) {
|
|
var s = seatEls[e.seat];
|
|
if (!s) return Promise.resolve();
|
|
|
|
if (e.text === "fold") {
|
|
s.root.dataset.state = "folded";
|
|
s.cards.dataset.mucked = "1";
|
|
FX.sfx("card", { v: 2 }); // cards going into the muck
|
|
return wait(320);
|
|
}
|
|
if (e.text === "check") {
|
|
flash(s.root);
|
|
FX.sfx("blip"); // a knuckle on the table
|
|
return wait(320);
|
|
}
|
|
// call, raise, allin: chips leave their stack for their spot.
|
|
if (!e.amount) return wait(200);
|
|
moveStack(e.seat, -e.amount);
|
|
return s.spot.pour(s.plate, e.amount).then(function () {
|
|
if (e.text === "allin") flash(s.root);
|
|
return wait(180);
|
|
});
|
|
}
|
|
|
|
// collect sweeps every seat's bet into the middle. The total is worked out up
|
|
// front rather than accumulated as the chips land, because the sweeps run at the
|
|
// same time and would otherwise race each other into the pot's counter.
|
|
function collect(total) {
|
|
var moved = 0;
|
|
var sweeps = seatEls.map(function (s) {
|
|
if (!s || s.spot.amount <= 0) return Promise.resolve();
|
|
moved += s.spot.amount;
|
|
return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 });
|
|
});
|
|
if (!moved) {
|
|
if (total != null) { pot.render(total); potTotal.textContent = money(total); }
|
|
return Promise.resolve();
|
|
}
|
|
var to = total != null ? total : pot.amount + moved;
|
|
return Promise.all(sweeps).then(function () {
|
|
pot.render(to);
|
|
potTotal.textContent = money(to);
|
|
return wait(200);
|
|
});
|
|
}
|
|
|
|
// street sweeps the bets in, then turns the cards.
|
|
function street(e) {
|
|
return collect(e.amount).then(function () {
|
|
// The board turns one card at a time, even the flop. Three cards appearing
|
|
// at once is a screenshot; three cards appearing in a row is a flop.
|
|
var chain = Promise.resolve();
|
|
(e.cards || []).forEach(function (c, i) {
|
|
chain = chain.then(function () {
|
|
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
|
|
FX.sfx("card", { v: i });
|
|
return wait(240);
|
|
});
|
|
});
|
|
return chain;
|
|
}).then(function () {
|
|
return wait(200);
|
|
});
|
|
}
|
|
|
|
// moveStack keeps a seat's stack label honest *while the chips are moving*. The
|
|
// authoritative number is always the server's, and render() puts it back at the
|
|
// end of the script — but a stack that only updates then would sit unchanged
|
|
// through the whole hand and then jump, which reads as the table correcting
|
|
// itself rather than as chips being paid.
|
|
function moveStack(i, delta) {
|
|
var s = seatEls[i];
|
|
if (!s) return;
|
|
shown[i] = Math.max(0, (shown[i] || 0) + delta);
|
|
FX.count(s.stackEl, shown[i]);
|
|
}
|
|
|
|
function flash(el) {
|
|
el.animate(
|
|
[{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }],
|
|
{ duration: 320, easing: "ease-out" }
|
|
);
|
|
}
|
|
|
|
// verdict says what the hand did to you, once, in words.
|
|
function verdict(events, final) {
|
|
var won = 0, showed = false, busted = false;
|
|
events.forEach(function (e) {
|
|
if (e.kind === "win" && e.seat === me) won += e.amount;
|
|
if (e.kind === "show") showed = true;
|
|
if (e.kind === "bust" && e.seat === me) busted = true;
|
|
});
|
|
if (busted) {
|
|
show("You're out of chips. Sit down again when you're ready.", "lose");
|
|
FX.sfx("lose");
|
|
return;
|
|
}
|
|
if (!final || !events.some(function (e) { return e.kind === "end"; })) return;
|
|
|
|
var mine = final.seats[me];
|
|
if (won > 0) {
|
|
// The pot coming your way already burst (and so already cheered) back in
|
|
// the "win" event. This is only the words.
|
|
show(showed
|
|
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
|
: "They folded. You take " + money(won) + ".", "win");
|
|
} else if (mine.state === "folded") {
|
|
show("Folded.", "lose");
|
|
} else {
|
|
show("No good this time.", "lose");
|
|
FX.sfx("lose");
|
|
}
|
|
|
|
function show(text, tone) {
|
|
verdictEl.textContent = text;
|
|
verdictEl.dataset.tone = tone;
|
|
verdictEl.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function handName(events) {
|
|
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === me; })[0];
|
|
return mine && mine.text ? mine.text : "the best hand";
|
|
}
|
|
|
|
// "You win 975 with straight" is not a sentence. Most hands take an article and
|
|
// the counted ones don't.
|
|
function article(desc) {
|
|
if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc;
|
|
return "a " + desc;
|
|
}
|
|
|
|
// ---- talking to the table --------------------------------------------------
|
|
|
|
// lock is busy, said out loud on the buttons.
|
|
//
|
|
// send() drops a click that arrives while a move is in flight, and it is right
|
|
// to: the board on screen during a script is a board the server has already
|
|
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
|
|
// enabled through the whole deal animation, so clicking Leave while the cards
|
|
// were still flying did nothing at all: no move, no message, no reason given.
|
|
// (The action buttons never had this problem; panels() hides the whole row when
|
|
// it isn't your turn.) A button that looks alive and does nothing has lied to
|
|
// you, so the lock lives on the buttons and not only in the variable.
|
|
//
|
|
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
|
|
// panels() owns that, so this only ever adds a reason to be disabled.
|
|
function lock(on) {
|
|
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
|
|
if (b) b.disabled = on;
|
|
});
|
|
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
|
|
}
|
|
|
|
function send(body, msgEl) {
|
|
if (busy) return Promise.resolve();
|
|
busy = true;
|
|
lock(true);
|
|
say(msgEl, "");
|
|
// Whatever the last hand said about itself stops being true the moment you do
|
|
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
|
// over a hand it had nothing to do with.
|
|
verdictEl.classList.add("hidden");
|
|
return window.PeteGames
|
|
.post("/api/games/holdem/move", body)
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
// No table came back: the session ended inside this move — a bust closes a
|
|
// solo table. play() animates the last hand (its "bust" beat says the words)
|
|
// and clears the felt.
|
|
return play(v.holdem_events, v.holdem || null);
|
|
})
|
|
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
|
.then(function () { busy = false; lock(false); flushSync(); });
|
|
}
|
|
|
|
// flushSync applies a table frame that arrived while a hand was animating. The
|
|
// felt is now settled, so the held re-render will not clobber a script.
|
|
function flushSync() {
|
|
if (pendingSync) { pendingSync = false; sync(); }
|
|
}
|
|
|
|
// leave gets you up from the table. It is its own endpoint, not a move: the
|
|
// chips cross back and the felt may close behind you, neither of which is a play
|
|
// in a hand. The felt clears and the stack you took is reported from what was in
|
|
// front of you a moment ago.
|
|
function leave() {
|
|
if (busy) return;
|
|
busy = true;
|
|
lock(true);
|
|
var stack = view ? view.stack : 0;
|
|
verdictEl.classList.add("hidden");
|
|
window.PeteGames
|
|
.post("/api/games/holdem/leave", {})
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
render0();
|
|
say(tableMsg, stack > 0
|
|
? money(stack) + " back on your stack. Sit down again when you're ready."
|
|
: "");
|
|
})
|
|
.catch(function (err) { say(betweenMsg, err.message, "bad"); })
|
|
.then(function () { busy = false; lock(false); flushSync(); });
|
|
}
|
|
|
|
// render0 is the table with nobody at it.
|
|
function render0() {
|
|
view = null;
|
|
unseated();
|
|
seatsEl.innerHTML = "";
|
|
youEl.innerHTML = "";
|
|
boardEl.innerHTML = "";
|
|
potStack.innerHTML = "";
|
|
potTotal.textContent = "0";
|
|
sideEl.classList.add("hidden");
|
|
panels();
|
|
syncSit();
|
|
loadLobby();
|
|
}
|
|
|
|
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
|
if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); });
|
|
if (callBtn) callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); });
|
|
if (raiseBtn) raiseBtn.addEventListener("click", function () {
|
|
var to = Number(slider.value);
|
|
// Sliding all the way to the top is shoving, and the table would rather be
|
|
// told that than be told to raise to exactly everything you have.
|
|
send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg);
|
|
});
|
|
|
|
if (slider) slider.addEventListener("input", showRaise);
|
|
root.querySelectorAll("[data-raise-preset]").forEach(function (b) {
|
|
b.addEventListener("click", function () {
|
|
var which = b.dataset.raisePreset;
|
|
var to;
|
|
// A pot-sized raise is: call what's owed, then bet what the pot would then be.
|
|
// So the total is twice what you owe, plus the pot as it stands.
|
|
if (which === "max") to = view.max_raise_to;
|
|
else to = 2 * view.owed + view.pot * Number(which);
|
|
to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
|
|
slider.value = to;
|
|
showRaise();
|
|
});
|
|
});
|
|
|
|
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
|
if (leaveBtn) leaveBtn.addEventListener("click", function () { leave(); });
|
|
if (topupBtn) topupBtn.addEventListener("click", function () {
|
|
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
|
});
|
|
|
|
// ---- sitting down ----------------------------------------------------------
|
|
|
|
function pickTier(btn) {
|
|
tier = btn;
|
|
tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
|
var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb);
|
|
buySlider.min = min;
|
|
buySlider.max = max;
|
|
buySlider.step = bb;
|
|
buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks
|
|
syncSit();
|
|
}
|
|
|
|
function pickBots(btn) {
|
|
bots = Number(btn.dataset.botCount);
|
|
botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
|
syncSit();
|
|
}
|
|
|
|
function syncSit() {
|
|
if (!tier) return;
|
|
buyIn = Number(buySlider.value);
|
|
var bb = Number(tier.dataset.bb);
|
|
buyLabel.textContent = money(buyIn);
|
|
buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them.";
|
|
botsNote.textContent = bots === 1
|
|
? "Heads up. The bots know this game best when there's only one of them."
|
|
: bots + " bots. More opponents, and a hand has to be better to be worth playing.";
|
|
|
|
var chips = window.PeteGames.view();
|
|
sitBtn.disabled = !chips || chips.chips < buyIn;
|
|
say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : "");
|
|
}
|
|
|
|
tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); });
|
|
botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); });
|
|
if (buySlider) buySlider.addEventListener("input", syncSit);
|
|
|
|
if (sitBtn) sitBtn.addEventListener("click", function () {
|
|
if (busy || !tier) return;
|
|
busy = true;
|
|
say(tableMsg, "");
|
|
window.PeteGames
|
|
.post("/api/games/holdem/sit", {
|
|
tier: tier.dataset.tier,
|
|
bots: bots,
|
|
buyin: Number(buySlider.value),
|
|
})
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
render(v.holdem);
|
|
seated();
|
|
// A table with nobody dealt in yet is a table waiting for you to say go.
|
|
say(betweenMsg, "You're in. Deal when you're ready.");
|
|
})
|
|
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
|
.then(function () { busy = false; });
|
|
});
|
|
|
|
// ---- the live table --------------------------------------------------------
|
|
//
|
|
// Poker is where the room stops being just you and the house: other people are
|
|
// at the felt, and what they do has to reach you without your asking. That is
|
|
// one EventSource. The server pushes a nudge when the table changes and a line
|
|
// when somebody speaks; the nudge is not the state (a hole card must never ride
|
|
// a frame that fans to the whole table), so on a nudge we refetch our own
|
|
// seat's view, which is authoritative and already redacted.
|
|
|
|
// seated opens the stream and loads the rail. Called the moment you sit down.
|
|
function seated() {
|
|
loadChat();
|
|
connectLive();
|
|
}
|
|
|
|
// unseated tears it down. Called when the felt clears — you got up, or busted.
|
|
function unseated() {
|
|
disconnectLive();
|
|
if (chatLog) chatLog.innerHTML = "";
|
|
chatSeen = {};
|
|
}
|
|
|
|
function connectLive() {
|
|
if (stream || !window.EventSource) return;
|
|
stream = new EventSource("/api/games/holdem/stream");
|
|
stream.onmessage = function (ev) {
|
|
var msg;
|
|
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
|
if (msg.type === "chat") addChat(msg.line);
|
|
else if (msg.type === "table") sync();
|
|
};
|
|
// The opening nudge (event: sync) just says "come and look".
|
|
stream.addEventListener("sync", function () { sync(); });
|
|
// EventSource reconnects itself on a dropped connection. The one case we don't
|
|
// want it retrying is a table that has closed (the stream 409s) — but by then
|
|
// we have called unseated() and closed it ourselves.
|
|
}
|
|
|
|
function disconnectLive() {
|
|
if (stream) { stream.close(); stream = null; }
|
|
}
|
|
|
|
// sync re-renders the felt from the authoritative table, but never mid-animation
|
|
// — a frame that lands while a hand is playing is held and applied once the
|
|
// script finishes, or it would repaint the table out from under it.
|
|
function sync() {
|
|
if (busy) { pendingSync = true; return; }
|
|
window.PeteGames.refresh().then(function (v) {
|
|
if (!v) return;
|
|
if (v.holdem) render(v.holdem); else { render0(); }
|
|
});
|
|
}
|
|
|
|
// ---- the rail --------------------------------------------------------------
|
|
|
|
function loadChat() {
|
|
fetch("/api/games/holdem/chat", { headers: { "Accept": "application/json" } })
|
|
.then(function (res) { return res.ok ? res.json() : null; })
|
|
.then(function (data) {
|
|
if (!data || !chatLog) return;
|
|
chatLog.innerHTML = "";
|
|
chatSeen = {};
|
|
(data.chat || []).forEach(addChat);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function addChat(line) {
|
|
if (!chatLog || !line) return;
|
|
// Your own line comes back twice — once from the POST that sent it, once
|
|
// echoed over your own stream — so drop any id already on the rail.
|
|
if (line.id) {
|
|
if (chatSeen[line.id]) return;
|
|
chatSeen[line.id] = true;
|
|
}
|
|
var row = document.createElement("div");
|
|
row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
|
|
var who = document.createElement("span");
|
|
who.className = "pete-chat-who";
|
|
who.textContent = line.name;
|
|
var body = document.createElement("span");
|
|
body.className = "pete-chat-body";
|
|
body.textContent = line.body;
|
|
row.appendChild(who);
|
|
row.appendChild(body);
|
|
chatLog.appendChild(row);
|
|
chatLog.scrollTop = chatLog.scrollHeight;
|
|
}
|
|
|
|
if (chatForm) chatForm.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
var body = (chatInput.value || "").trim();
|
|
if (!body) return;
|
|
chatInput.value = "";
|
|
window.PeteGames
|
|
.post("/api/games/holdem/say", { body: body })
|
|
.then(function (line) { addChat(line); })
|
|
.catch(function () { chatInput.value = body; });
|
|
});
|
|
|
|
// ---- the lobby -------------------------------------------------------------
|
|
//
|
|
// The tables other people have open, each with a seat going spare. Joining
|
|
// takes the buy-in the slider is set to, clamped to what that table allows.
|
|
|
|
function loadLobby() {
|
|
if (!lobbyEl) return;
|
|
fetch("/api/games/holdem/tables", { headers: { "Accept": "application/json" } })
|
|
.then(function (res) { return res.ok ? res.json() : null; })
|
|
.then(function (data) {
|
|
var tables = (data && data.tables) || [];
|
|
renderLobby(tables);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function renderLobby(tables) {
|
|
lobbyEl.innerHTML = "";
|
|
lobbyWrap.classList.toggle("hidden", tables.length === 0);
|
|
tables.forEach(function (t) {
|
|
var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
|
|
var btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-ink/10 p-3 text-left hover:bg-ink/5 transition";
|
|
var label = document.createElement("span");
|
|
label.className = "font-display font-bold";
|
|
label.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
|
|
var seats = document.createElement("span");
|
|
seats.className = "text-xs font-semibold text-muted tabular-nums";
|
|
seats.textContent = t.humans + "/" + t.seats + " seated";
|
|
btn.appendChild(label);
|
|
btn.appendChild(seats);
|
|
btn.addEventListener("click", function () { join(t, stakes); });
|
|
lobbyEl.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function join(t, stakes) {
|
|
if (busy) return;
|
|
// Buy in for what the slider says, but only what this table allows.
|
|
var buyIn = buyIn0(stakes);
|
|
busy = true;
|
|
say(tableMsg, "");
|
|
window.PeteGames
|
|
.post("/api/games/holdem/sit", { table: t.id, buyin: buyIn })
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
render(v.holdem);
|
|
seated();
|
|
say(betweenMsg, "You're in. The next hand deals when the table is ready.");
|
|
})
|
|
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
|
.then(function () { busy = false; });
|
|
}
|
|
|
|
// buyIn0 is a legal buy-in for a table you are joining: fifty big blinds, or as
|
|
// close as the table's range allows.
|
|
function buyIn0(stakes) {
|
|
if (!stakes) return Number(buySlider.value);
|
|
var min = Number(stakes.dataset.min), max = Number(stakes.dataset.max), bb = Number(stakes.dataset.bb);
|
|
var want = Number(buySlider.value);
|
|
if (stakes === tier) return Math.min(max, Math.max(min, want)); // same table: honour the slider
|
|
return Math.min(max, Math.max(min, 50 * bb));
|
|
}
|
|
|
|
// ---- boot ------------------------------------------------------------------
|
|
|
|
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
|
|
|
pickTier(tierBtns[0]);
|
|
pickBots(botBtns[1]);
|
|
|
|
window.PeteGames.refresh().then(function (v) {
|
|
if (v && v.holdem) { render(v.holdem); seated(); }
|
|
else { render0(); loadLobby(); }
|
|
});
|
|
})();
|