Files
Pete/internal/web/static/js/holdem.js
prosolis dbde827f75 games: the chat line that shows up once, not twice
Your own message came back twice — once from the POST that sent it, once
echoed over your own SSE stream — so the felt printed it on the rail twice.
Drop any chat id already seen; reset the seen-set when the log is cleared
(unseated, and on a full chat reload).

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 19:32:08 -07:00

896 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 });
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-[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(); }
});
})();