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
896 lines
34 KiB
JavaScript
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(); }
|
|
});
|
|
})();
|