// 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 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 }); 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 = ""; } 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 = ""; (data.chat || []).forEach(addChat); }) .catch(function () {}); } function addChat(line) { if (!chatLog || !line) return; 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-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--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-[color:var(--ink)]/50 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(); } }); })();