Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
690 lines
26 KiB
JavaScript
690 lines
26 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 sitBtn = root.querySelector("[data-sit]");
|
|
var buySlider = root.querySelector("[data-buyin-slider]");
|
|
var buyLabel = root.querySelector("[data-buyin]");
|
|
var buyNote = root.querySelector("[data-buyin-note]");
|
|
var botsNote = root.querySelector("[data-bots-note]");
|
|
var gameMsg = root.querySelector("[data-game-msg]");
|
|
var betweenMsg = root.querySelector("[data-between-msg]");
|
|
var tableMsg = root.querySelector("[data-table-msg]");
|
|
|
|
var view = null; // the table, as the server last described it
|
|
var busy = false;
|
|
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
|
var shown = []; // what each seat's stack label currently reads
|
|
var pot = null; // the middle pile, a PeteFX.spot
|
|
|
|
var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]"));
|
|
var botBtns = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]"));
|
|
var tier = null;
|
|
var bots = 2;
|
|
var buyIn = 0;
|
|
|
|
function money(n) { return (n || 0).toLocaleString(); }
|
|
|
|
function say(el, text, tone) {
|
|
if (!el) return;
|
|
if (!text) { el.classList.add("hidden"); return; }
|
|
el.textContent = text;
|
|
el.classList.remove("hidden");
|
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
|
}
|
|
|
|
// ---- building the felt ----------------------------------------------------
|
|
|
|
// seat builds one player: their cards, their name and stack, and the spot their
|
|
// bet sits on. Bots go in the row along the top; you get your own, bigger.
|
|
function seat(s, i, mine) {
|
|
var wrap = document.createElement("div");
|
|
wrap.className = "pete-seat";
|
|
wrap.dataset.seat = i;
|
|
wrap.dataset.state = s.state;
|
|
|
|
var cards = document.createElement("div");
|
|
cards.className = "pete-seat-cards";
|
|
|
|
var plate = document.createElement("div");
|
|
plate.className = "pete-seat-plate";
|
|
var name = document.createElement("span");
|
|
name.className = "pete-seat-name";
|
|
name.textContent = s.name;
|
|
var stack = document.createElement("span");
|
|
stack.className = "pete-seat-stack";
|
|
stack.textContent = money(s.stack);
|
|
plate.appendChild(name);
|
|
plate.appendChild(stack);
|
|
|
|
// The button, the blinds. It hangs off the name plate rather than the seat,
|
|
// because the seat's corner is a different place for you than for a bot — your
|
|
// bet spot is above your cards and theirs is below — and a badge that floats
|
|
// over an empty betting circle reads as a bug.
|
|
if (s.pos) {
|
|
var pos = document.createElement("span");
|
|
pos.className = "pete-seat-pos";
|
|
pos.dataset.pos = s.pos;
|
|
pos.textContent = s.pos;
|
|
plate.appendChild(pos);
|
|
}
|
|
|
|
var spotEl = document.createElement("div");
|
|
spotEl.className = "pete-spot pete-seat-spot";
|
|
var pile = document.createElement("div");
|
|
pile.className = "pete-stack";
|
|
var total = document.createElement("span");
|
|
// The shared class, not one of our own: it hangs the number *below* the ring,
|
|
// which is what keeps the chips from landing on top of it.
|
|
total.className = "pete-spot-total";
|
|
spotEl.appendChild(pile);
|
|
spotEl.appendChild(total);
|
|
|
|
// Your bet sits between you and the board, so it goes above your cards; a
|
|
// bot's sits between them and the board, so it goes below theirs.
|
|
if (mine) {
|
|
wrap.appendChild(spotEl);
|
|
wrap.appendChild(cards);
|
|
wrap.appendChild(plate);
|
|
} else {
|
|
wrap.appendChild(cards);
|
|
wrap.appendChild(plate);
|
|
wrap.appendChild(spotEl);
|
|
}
|
|
|
|
var api = FX.spot({ spot: spotEl, stack: pile, total: total });
|
|
api.render(s.bet);
|
|
|
|
paintCards(cards, s, mine);
|
|
return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api };
|
|
}
|
|
|
|
// paintCards puts the two cards in front of a seat. A bot's are backs, unless
|
|
// the server has actually sent us faces — which it only ever does at a showdown.
|
|
function paintCards(el, s, mine) {
|
|
el.innerHTML = "";
|
|
if (s.state === "out") return;
|
|
var faces = s.cards || [];
|
|
var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2);
|
|
for (var i = 0; i < n; i++) {
|
|
el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine }));
|
|
}
|
|
}
|
|
|
|
function render(v) {
|
|
view = v;
|
|
|
|
// The seats along the top, and you underneath.
|
|
seatsEl.innerHTML = "";
|
|
youEl.innerHTML = "";
|
|
seatEls = [];
|
|
shown = [];
|
|
v.seats.forEach(function (s, i) {
|
|
var mine = i === 0;
|
|
var built = seat(s, i, mine);
|
|
seatEls[i] = built;
|
|
shown[i] = s.stack;
|
|
(mine ? youEl : seatsEl).appendChild(built.root);
|
|
built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0";
|
|
});
|
|
|
|
// The board.
|
|
boardEl.innerHTML = "";
|
|
(v.board || []).forEach(function (c) {
|
|
boardEl.appendChild(Cards.el(c, { deal: false, tilt: false }));
|
|
});
|
|
|
|
// The pot. Its chips live in a box of their own — see .pete-poker-pot-pile —
|
|
// so the number underneath stays readable.
|
|
pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null });
|
|
pot.render(v.pot);
|
|
potTotal.textContent = money(v.pot);
|
|
blindsEl.textContent = v.tier.sb + "/" + v.tier.bb;
|
|
tableName.textContent = v.tier.name;
|
|
if (v.side && v.side.length > 1) {
|
|
sideEl.textContent = v.side.map(function (n, i) {
|
|
return (i === 0 ? "main " : "side ") + money(n);
|
|
}).join(" · ");
|
|
sideEl.classList.remove("hidden");
|
|
} else {
|
|
sideEl.classList.add("hidden");
|
|
}
|
|
|
|
stackEl.textContent = money(v.stack);
|
|
boughtEl.textContent = money(v.bought_in);
|
|
rakeEl.textContent = money(v.rake);
|
|
|
|
panels();
|
|
}
|
|
|
|
// panels decides which of the three bars is showing: the one that acts, the one
|
|
// between hands, or the one you sit down from.
|
|
function panels() {
|
|
// A session you have got up from is not a live one: the felt still shows the
|
|
// last hand, but the table you sit down at is the one that's open to you.
|
|
var live = !!view && view.phase !== "done";
|
|
sitting.classList.toggle("hidden", live);
|
|
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== 0);
|
|
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
|
if (!live) return;
|
|
|
|
if (view.phase === "betting" && view.to_act === 0) {
|
|
checkBtn.classList.toggle("hidden", !view.can_check);
|
|
callBtn.classList.toggle("hidden", view.can_check);
|
|
callAmt.textContent = money(view.owed);
|
|
|
|
raiseRow.classList.toggle("hidden", !view.can_raise);
|
|
if (view.can_raise) {
|
|
slider.min = view.min_raise_to;
|
|
slider.max = view.max_raise_to;
|
|
slider.step = Math.max(1, view.tier.bb / 2);
|
|
slider.value = Math.min(view.max_raise_to, view.min_raise_to);
|
|
// "Bet" when nobody has, "Raise to" when somebody has. It is the same
|
|
// move and the same button, but calling a bet a raise is how you tell a
|
|
// player who has never played that this table is confused.
|
|
raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet";
|
|
showRaise();
|
|
}
|
|
}
|
|
if (view.phase === "handover") {
|
|
// The table has room for max_topup, but the button must not promise chips the
|
|
// wallet cannot cover — clicking it would only earn a refusal.
|
|
var wallet = window.PeteGames.view();
|
|
var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
|
|
topupBtn.disabled = can <= 0;
|
|
topupBtn.dataset.amount = can;
|
|
topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
|
|
}
|
|
}
|
|
|
|
function showRaise() {
|
|
var to = Number(slider.value);
|
|
raiseTo.textContent = money(to);
|
|
raiseLbl.textContent = money(to);
|
|
}
|
|
|
|
// ---- playing the script ---------------------------------------------------
|
|
|
|
var STREETS = { flop: 1, turn: 1, river: 1 };
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); });
|
|
}
|
|
|
|
// play walks the events the server sent, one beat at a time, and only then
|
|
// re-renders the table it ended on. Everything the player is meant to see
|
|
// happening happens here; render() is the state it settles into.
|
|
function play(events, final) {
|
|
var chain = Promise.resolve();
|
|
// No table to settle into — the session closed and storage has already cleared
|
|
// it. There is nothing to animate onto, and render() would walk seats that
|
|
// aren't there.
|
|
if (!final) { render0(); return Promise.resolve(); }
|
|
if (!events || !events.length) { render(final); return chain; }
|
|
|
|
events.forEach(function (e) {
|
|
chain = chain.then(function () { return beat(e, final); });
|
|
});
|
|
return chain.then(function () {
|
|
render(final);
|
|
verdict(events, final);
|
|
});
|
|
}
|
|
|
|
function beat(e, final) {
|
|
var s = seatEls[e.seat];
|
|
|
|
switch (e.kind) {
|
|
case "hand":
|
|
// A new deal: clear the felt before anything lands on it.
|
|
boardEl.innerHTML = "";
|
|
verdictEl.classList.add("hidden");
|
|
seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; });
|
|
pot.render(0);
|
|
potTotal.textContent = "0";
|
|
sideEl.classList.add("hidden");
|
|
FX.sfx("shuffle");
|
|
return wait(140);
|
|
|
|
case "rebuy":
|
|
if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); }
|
|
return wait(220);
|
|
|
|
case "blind":
|
|
if (!s) return;
|
|
moveStack(e.seat, -e.amount);
|
|
return s.spot.pour(s.plate, e.amount);
|
|
|
|
case "hole":
|
|
// Two cards to everybody, round the table, as they are actually dealt.
|
|
return dealHoles(final);
|
|
|
|
case "action":
|
|
return action(e, final);
|
|
|
|
case "flop":
|
|
case "turn":
|
|
case "river":
|
|
return street(e);
|
|
|
|
case "pot":
|
|
// The bets in front of the seats have been swept in. Nothing else in the
|
|
// script says so, and the pot is about to be paid out of.
|
|
return collect(e.amount);
|
|
|
|
case "uncalled":
|
|
if (!s) return;
|
|
return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); });
|
|
|
|
case "show":
|
|
if (!s) return;
|
|
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === 0);
|
|
flash(s.root);
|
|
return wait(420);
|
|
|
|
case "rake":
|
|
// The house takes its cut out of the pot, in front of you, so it is a
|
|
// thing that visibly happens rather than a number that quietly differs.
|
|
return pot.sweep(houseEl, e.amount).then(function () {
|
|
potTotal.textContent = money(pot.amount);
|
|
return wait(160);
|
|
});
|
|
|
|
case "win":
|
|
if (!s) return;
|
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
|
potTotal.textContent = money(pot.amount);
|
|
moveStack(e.seat, e.amount);
|
|
if (e.seat === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
|
return wait(260);
|
|
});
|
|
|
|
case "end":
|
|
return wait(280);
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// dealHoles puts two cards in front of everyone still in the hand. Yours land
|
|
// face up; theirs land as backs, because that is all that came over the wire.
|
|
function dealHoles(final) {
|
|
var chain = Promise.resolve();
|
|
for (var round = 0; round < 2; round++) {
|
|
(function (round) {
|
|
chain = chain.then(function () {
|
|
var beats = [];
|
|
final.seats.forEach(function (s, i) {
|
|
if (s.state === "out") return;
|
|
var built = seatEls[i];
|
|
if (!built) return;
|
|
var face = (i === 0 && s.cards) ? s.cards[round] : null;
|
|
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
|
|
built.cards.appendChild(card);
|
|
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
|
beats.push(wait(70 * i));
|
|
});
|
|
return Promise.all(beats).then(function () { return wait(180); });
|
|
});
|
|
})(round);
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
// action animates one seat doing one thing.
|
|
function action(e, final) {
|
|
var s = seatEls[e.seat];
|
|
if (!s) return Promise.resolve();
|
|
|
|
if (e.text === "fold") {
|
|
s.root.dataset.state = "folded";
|
|
s.cards.dataset.mucked = "1";
|
|
FX.sfx("card", { v: 2 }); // cards going into the muck
|
|
return wait(320);
|
|
}
|
|
if (e.text === "check") {
|
|
flash(s.root);
|
|
FX.sfx("blip"); // a knuckle on the table
|
|
return wait(320);
|
|
}
|
|
// call, raise, allin: chips leave their stack for their spot.
|
|
if (!e.amount) return wait(200);
|
|
moveStack(e.seat, -e.amount);
|
|
return s.spot.pour(s.plate, e.amount).then(function () {
|
|
if (e.text === "allin") flash(s.root);
|
|
return wait(180);
|
|
});
|
|
}
|
|
|
|
// collect sweeps every seat's bet into the middle. The total is worked out up
|
|
// front rather than accumulated as the chips land, because the sweeps run at the
|
|
// same time and would otherwise race each other into the pot's counter.
|
|
function collect(total) {
|
|
var moved = 0;
|
|
var sweeps = seatEls.map(function (s) {
|
|
if (!s || s.spot.amount <= 0) return Promise.resolve();
|
|
moved += s.spot.amount;
|
|
return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 });
|
|
});
|
|
if (!moved) {
|
|
if (total != null) { pot.render(total); potTotal.textContent = money(total); }
|
|
return Promise.resolve();
|
|
}
|
|
var to = total != null ? total : pot.amount + moved;
|
|
return Promise.all(sweeps).then(function () {
|
|
pot.render(to);
|
|
potTotal.textContent = money(to);
|
|
return wait(200);
|
|
});
|
|
}
|
|
|
|
// street sweeps the bets in, then turns the cards.
|
|
function street(e) {
|
|
return collect(e.amount).then(function () {
|
|
// The board turns one card at a time, even the flop. Three cards appearing
|
|
// at once is a screenshot; three cards appearing in a row is a flop.
|
|
var chain = Promise.resolve();
|
|
(e.cards || []).forEach(function (c, i) {
|
|
chain = chain.then(function () {
|
|
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
|
|
FX.sfx("card", { v: i });
|
|
return wait(240);
|
|
});
|
|
});
|
|
return chain;
|
|
}).then(function () {
|
|
return wait(200);
|
|
});
|
|
}
|
|
|
|
// moveStack keeps a seat's stack label honest *while the chips are moving*. The
|
|
// authoritative number is always the server's, and render() puts it back at the
|
|
// end of the script — but a stack that only updates then would sit unchanged
|
|
// through the whole hand and then jump, which reads as the table correcting
|
|
// itself rather than as chips being paid.
|
|
function moveStack(i, delta) {
|
|
var s = seatEls[i];
|
|
if (!s) return;
|
|
shown[i] = Math.max(0, (shown[i] || 0) + delta);
|
|
FX.count(s.stackEl, shown[i]);
|
|
}
|
|
|
|
function flash(el) {
|
|
el.animate(
|
|
[{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }],
|
|
{ duration: 320, easing: "ease-out" }
|
|
);
|
|
}
|
|
|
|
// verdict says what the hand did to you, once, in words.
|
|
function verdict(events, final) {
|
|
var won = 0, showed = false, busted = false;
|
|
events.forEach(function (e) {
|
|
if (e.kind === "win" && e.seat === 0) won += e.amount;
|
|
if (e.kind === "show") showed = true;
|
|
if (e.kind === "bust") busted = true;
|
|
});
|
|
if (busted) {
|
|
show("You're out of chips. Sit down again when you're ready.", "lose");
|
|
FX.sfx("lose");
|
|
return;
|
|
}
|
|
if (!events.some(function (e) { return e.kind === "end"; })) return;
|
|
|
|
var me = final.seats[0];
|
|
if (won > 0) {
|
|
// The pot coming your way already burst (and so already cheered) back in
|
|
// the "win" event. This is only the words.
|
|
show(showed
|
|
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
|
: "They folded. You take " + money(won) + ".", "win");
|
|
} else if (me.state === "folded") {
|
|
show("Folded.", "lose");
|
|
} else {
|
|
show("No good this time.", "lose");
|
|
FX.sfx("lose");
|
|
}
|
|
|
|
function show(text, tone) {
|
|
verdictEl.textContent = text;
|
|
verdictEl.dataset.tone = tone;
|
|
verdictEl.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function handName(events) {
|
|
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === 0; })[0];
|
|
return mine && mine.text ? mine.text : "the best hand";
|
|
}
|
|
|
|
// "You win 975 with straight" is not a sentence. Most hands take an article and
|
|
// the counted ones don't.
|
|
function article(desc) {
|
|
if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc;
|
|
return "a " + desc;
|
|
}
|
|
|
|
// ---- talking to the table --------------------------------------------------
|
|
|
|
// lock is busy, said out loud on the buttons.
|
|
//
|
|
// send() drops a click that arrives while a move is in flight, and it is right
|
|
// to: the board on screen during a script is a board the server has already
|
|
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
|
|
// enabled through the whole deal animation, so clicking Leave while the cards
|
|
// were still flying did nothing at all: no move, no message, no reason given.
|
|
// (The action buttons never had this problem; panels() hides the whole row when
|
|
// it isn't your turn.) A button that looks alive and does nothing has lied to
|
|
// you, so the lock lives on the buttons and not only in the variable.
|
|
//
|
|
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
|
|
// panels() owns that, so this only ever adds a reason to be disabled.
|
|
function lock(on) {
|
|
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
|
|
if (b) b.disabled = on;
|
|
});
|
|
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
|
|
}
|
|
|
|
function send(body, msgEl) {
|
|
if (busy) return Promise.resolve();
|
|
busy = true;
|
|
lock(true);
|
|
say(msgEl, "");
|
|
// Whatever the last hand said about itself stops being true the moment you do
|
|
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
|
// over a hand it had nothing to do with.
|
|
verdictEl.classList.add("hidden");
|
|
return window.PeteGames
|
|
.post("/api/games/holdem/move", body)
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
return play(v.holdem_events, v.holdem || null).then(function () {
|
|
if (!v.holdem) { render0(); return; }
|
|
if (v.holdem.phase === "done") {
|
|
var got = v.holdem.payout, put = v.holdem.bought_in;
|
|
say(tableMsg, got > put
|
|
? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack."
|
|
: got === 0
|
|
? "Cleaned out. Better luck at the next table."
|
|
: "You got up " + money(put - got) + " down. " + money(got) + " back on your stack.");
|
|
setTimeout(render0, 2600);
|
|
}
|
|
});
|
|
})
|
|
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
|
.then(function () { busy = false; lock(false); });
|
|
}
|
|
|
|
// render0 is the table with nobody at it.
|
|
function render0() {
|
|
view = null;
|
|
seatsEl.innerHTML = "";
|
|
youEl.innerHTML = "";
|
|
boardEl.innerHTML = "";
|
|
potStack.innerHTML = "";
|
|
potTotal.textContent = "0";
|
|
sideEl.classList.add("hidden");
|
|
panels();
|
|
syncSit();
|
|
}
|
|
|
|
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
|
if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); });
|
|
if (callBtn) callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); });
|
|
if (raiseBtn) raiseBtn.addEventListener("click", function () {
|
|
var to = Number(slider.value);
|
|
// Sliding all the way to the top is shoving, and the table would rather be
|
|
// told that than be told to raise to exactly everything you have.
|
|
send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg);
|
|
});
|
|
|
|
if (slider) slider.addEventListener("input", showRaise);
|
|
root.querySelectorAll("[data-raise-preset]").forEach(function (b) {
|
|
b.addEventListener("click", function () {
|
|
var which = b.dataset.raisePreset;
|
|
var to;
|
|
// A pot-sized raise is: call what's owed, then bet what the pot would then be.
|
|
// So the total is twice what you owe, plus the pot as it stands.
|
|
if (which === "max") to = view.max_raise_to;
|
|
else to = 2 * view.owed + view.pot * Number(which);
|
|
to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
|
|
slider.value = to;
|
|
showRaise();
|
|
});
|
|
});
|
|
|
|
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
|
if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); });
|
|
if (topupBtn) topupBtn.addEventListener("click", function () {
|
|
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
|
});
|
|
|
|
// ---- sitting down ----------------------------------------------------------
|
|
|
|
function pickTier(btn) {
|
|
tier = btn;
|
|
tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
|
var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb);
|
|
buySlider.min = min;
|
|
buySlider.max = max;
|
|
buySlider.step = bb;
|
|
buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks
|
|
syncSit();
|
|
}
|
|
|
|
function pickBots(btn) {
|
|
bots = Number(btn.dataset.botCount);
|
|
botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
|
syncSit();
|
|
}
|
|
|
|
function syncSit() {
|
|
if (!tier) return;
|
|
buyIn = Number(buySlider.value);
|
|
var bb = Number(tier.dataset.bb);
|
|
buyLabel.textContent = money(buyIn);
|
|
buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them.";
|
|
botsNote.textContent = bots === 1
|
|
? "Heads up. The bots know this game best when there's only one of them."
|
|
: bots + " bots. More opponents, and a hand has to be better to be worth playing.";
|
|
|
|
var chips = window.PeteGames.view();
|
|
sitBtn.disabled = !chips || chips.chips < buyIn;
|
|
say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : "");
|
|
}
|
|
|
|
tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); });
|
|
botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); });
|
|
if (buySlider) buySlider.addEventListener("input", syncSit);
|
|
|
|
if (sitBtn) sitBtn.addEventListener("click", function () {
|
|
if (busy || !tier) return;
|
|
busy = true;
|
|
say(tableMsg, "");
|
|
window.PeteGames
|
|
.post("/api/games/holdem/sit", {
|
|
tier: tier.dataset.tier,
|
|
bots: bots,
|
|
buyin: Number(buySlider.value),
|
|
})
|
|
.then(function (v) {
|
|
window.PeteGames.apply(v);
|
|
render(v.holdem);
|
|
// A table with nobody dealt in yet is a table waiting for you to say go.
|
|
say(betweenMsg, "You're in. Deal when you're ready.");
|
|
})
|
|
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
|
.then(function () { busy = false; });
|
|
});
|
|
|
|
// ---- boot ------------------------------------------------------------------
|
|
|
|
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
|
|
|
pickTier(tierBtns[0]);
|
|
pickBots(botBtns[1]);
|
|
|
|
window.PeteGames.refresh().then(function (v) {
|
|
if (v && v.holdem) render(v.holdem);
|
|
else render0();
|
|
});
|
|
})();
|