// 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 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 busy = false; 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; // The seats along the top, and you underneath. seatsEl.innerHTML = ""; youEl.innerHTML = ""; seatEls = []; shown = []; v.seats.forEach(function (s, i) { var mine = i === 0; 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 !== 0); between.classList.toggle("hidden", !live || view.phase !== "handover"); if (!live) return; if (view.phase === "betting" && view.to_act === 0) { 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(); // No table to settle into — the session closed and storage has already cleared // it. There is nothing to animate onto, and render() would walk seats that // aren't there. if (!final) { render0(); return Promise.resolve(); } if (!events || !events.length) { render(final); return chain; } events.forEach(function (e) { chain = chain.then(function () { return beat(e, final); }); }); return chain.then(function () { render(final); verdict(events, final); }); } 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 === 0); 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 === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 }); 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 === 0 && s.cards) ? s.cards[round] : null; var card = Cards.el(face, { deal: true, tilt: i !== 0 }); 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 === 0) won += e.amount; if (e.kind === "show") showed = true; if (e.kind === "bust") busted = true; }); if (busted) { show("You're out of chips. Sit down again when you're ready.", "lose"); FX.sfx("lose"); return; } if (!events.some(function (e) { return e.kind === "end"; })) return; var me = final.seats[0]; 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 (me.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 === 0; })[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); return play(v.holdem_events, v.holdem || null).then(function () { if (!v.holdem) { render0(); return; } if (v.holdem.phase === "done") { var got = v.holdem.payout, put = v.holdem.bought_in; say(tableMsg, got > put ? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack." : got === 0 ? "Cleaned out. Better luck at the next table." : "You got up " + money(put - got) + " down. " + money(got) + " back on your stack."); setTimeout(render0, 2600); } }); }) .catch(function (err) { say(msgEl, err.message, "bad"); }) .then(function () { busy = false; lock(false); }); } // render0 is the table with nobody at it. function render0() { view = null; seatsEl.innerHTML = ""; youEl.innerHTML = ""; boardEl.innerHTML = ""; potStack.innerHTML = ""; potTotal.textContent = "0"; sideEl.classList.add("hidden"); panels(); syncSit(); } 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 () { send({ move: "leave" }, betweenMsg); }); 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); // 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; }); }); // ---- 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); else render0(); }); })();