A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.
There are three at once:
- the amount doubles as it goes left, and re-straddles carry on doubling;
- first to act is left of the *straddler*, not left of the big blind, and the
straddler keeps the option when it comes back round even if nobody raised;
- the minimum raise is twice the straddle. Measured off the big blind
underneath it, a $2/$4 game prices the next raise at $6, and every player at
the table will argue — correctly.
Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.
The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.
20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
899 lines
43 KiB
JavaScript
899 lines
43 KiB
JavaScript
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
|
|
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
|
|
// so it's reachable without looking, and re-renders after every action because the
|
|
// next player's numbers are different.
|
|
const ActionBar = {
|
|
state: null,
|
|
hooks: null,
|
|
raiseOpen: false,
|
|
raiseDraft: null,
|
|
|
|
mount(state, hooks) {
|
|
this.state = state;
|
|
this.hooks = hooks;
|
|
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
|
|
this.tankUnknownFor = state.bettingOpen &&
|
|
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
|
|
? state.actingSeatIndex
|
|
: null;
|
|
this.watchHeight();
|
|
this.render();
|
|
},
|
|
|
|
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
|
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
|
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
|
// constant either: opening the raise pad grows it. Publishing the measured height
|
|
// as a custom property lets the layout and the toast both stay clear of it without
|
|
// anyone guessing a number.
|
|
watchHeight() {
|
|
const host = document.getElementById("actionBar");
|
|
if (!host || this.heightWatcher) return;
|
|
|
|
const publish = () => {
|
|
const visible = !host.hidden;
|
|
const height = visible ? host.getBoundingClientRect().height : 0;
|
|
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
|
this.publishFeltRoom(height);
|
|
};
|
|
|
|
if (typeof ResizeObserver === "function") {
|
|
this.heightWatcher = new ResizeObserver(publish);
|
|
this.heightWatcher.observe(host);
|
|
}
|
|
window.addEventListener("resize", publish);
|
|
window.addEventListener("orientationchange", publish);
|
|
publish();
|
|
},
|
|
|
|
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
|
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
|
// table-sized — so the overhang is a constant rather than something read back off
|
|
// the layout this is about to change. Measuring it would mean shrinking the felt,
|
|
// moving the seats, and getting a different answer on the next pass.
|
|
SEAT_OVERHANG_PX: 37,
|
|
FELT_GAP_PX: 8,
|
|
// The floor is a guard against a measurement that has gone wrong — not a size the
|
|
// window has agreed to hand over. It used to be 220px, which a phone on its side
|
|
// hasn't got: at 844x390 there are 99px between the header and the bar, and holding
|
|
// out for 220 is how the bottom row of seats came to sit 26px inside the Fold
|
|
// button. A floor that puts seats behind the bar defeats the one thing the floor is
|
|
// for, so it drops to the smallest felt still worth drawing and the measurement
|
|
// wins above it.
|
|
FELT_FLOOR_PX: 96,
|
|
|
|
// How tall the felt can be and still leave the whole ring of seats above the bar.
|
|
//
|
|
// Publishing the bar's height was only half the job. The wide layout never used it:
|
|
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
|
|
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
|
|
// 117px to 172px the moment there's a side pot to show, which is exactly when the
|
|
// dealer most needs to see who is still in. Measured with two side pots, the bottom
|
|
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
|
|
//
|
|
// The wrap's offset is taken in document space rather than viewport space, so
|
|
// scrolling the page doesn't resize the table under the dealer's hands.
|
|
feltRoomFor(windowHeight, barHeight, topInDocument) {
|
|
const room = windowHeight - barHeight - topInDocument -
|
|
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
|
return Math.max(this.FELT_FLOOR_PX, Math.round(room));
|
|
},
|
|
|
|
publishFeltRoom(barHeight) {
|
|
const wrap = document.getElementById("pokerTableWrap");
|
|
if (!wrap) return;
|
|
// Taking the top before the height is set matters: the wrap sits below the
|
|
// header in normal flow, so its own height never moves its top and this can't
|
|
// chase itself around the screen.
|
|
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
|
document.documentElement.style.setProperty(
|
|
"--feltRoomPx",
|
|
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
|
},
|
|
|
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
|
// still holds during setup, because a seated player is a live seat whether or not
|
|
// the betting has been opened. An empty table stays uncluttered.
|
|
//
|
|
// A *named* table is not an empty one, though, and that was the hole: name six
|
|
// people without dealing any of them in and the bar hid itself, leaving a screen
|
|
// with six players on it, no way forward and nothing saying why. Anyone with a
|
|
// name on the felt is somebody the dealer has to be told about.
|
|
isRunning() {
|
|
return Betting.liveSeats(this.state).length > 0 ||
|
|
this.state.seats.some(p => (p.name || "").trim());
|
|
},
|
|
|
|
render() {
|
|
const host = document.getElementById("actionBar");
|
|
if (!host) return;
|
|
host.innerHTML = "";
|
|
|
|
if (!this.isRunning()) {
|
|
host.hidden = true;
|
|
document.body.classList.remove("hasActionBar");
|
|
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
|
this.stopTankTimer();
|
|
this.tankSeatIndex = null;
|
|
return;
|
|
}
|
|
host.hidden = false;
|
|
document.body.classList.add("hasActionBar");
|
|
|
|
host.appendChild(this.renderStatus());
|
|
|
|
const breakdown = this.renderPotBreakdown();
|
|
if (breakdown) host.appendChild(breakdown);
|
|
|
|
if (!this.state.bettingOpen) {
|
|
host.appendChild(this.renderDealPrompt());
|
|
} else if (Betting.isHandOver(this.state)) {
|
|
host.appendChild(this.renderHandOver());
|
|
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
|
host.appendChild(this.renderStreetDone());
|
|
} else if (this.raiseOpen) {
|
|
host.appendChild(this.renderRaisePad());
|
|
} else {
|
|
host.appendChild(this.renderActions());
|
|
}
|
|
|
|
this.syncTankTimer();
|
|
},
|
|
|
|
renderStatus() {
|
|
const row = document.createElement("div");
|
|
row.className = "actionStatus";
|
|
|
|
const street = document.createElement("span");
|
|
street.className = "actionStreet";
|
|
street.textContent = Betting.streetLabel(this.state.street);
|
|
row.appendChild(street);
|
|
|
|
const pot = document.createElement("span");
|
|
pot.className = "actionPot";
|
|
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
|
row.appendChild(pot);
|
|
|
|
// How many are still in the hand — part of the standard per-street
|
|
// announcement, and the number a dealer is asked for most often. Before the
|
|
// deal it counts the table as it will be dealt, so a busted player waiting on a
|
|
// rebuy isn't announced as one of the players. Read-only: the sitting-out
|
|
// itself happens when the hand is dealt, not while the bar is being drawn.
|
|
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
|
|
const remaining = Betting.liveSeats(counted).length;
|
|
const players = document.createElement("span");
|
|
players.className = "actionPlayers";
|
|
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
|
row.appendChild(players);
|
|
|
|
// Nobody is being asked to act because nobody can — say so, or the bar just
|
|
// looks like it's waiting for something.
|
|
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
|
const runout = document.createElement("span");
|
|
runout.className = "actionRunout";
|
|
const stillIn = Betting.liveSeats(this.state).length;
|
|
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
|
row.appendChild(runout);
|
|
}
|
|
|
|
const index = this.state.actingSeatIndex;
|
|
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
|
const player = this.state.seats[index];
|
|
const who = document.createElement("span");
|
|
who.className = "actionWho";
|
|
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
|
row.appendChild(who);
|
|
|
|
const tank = document.createElement("span");
|
|
tank.className = "actionTank";
|
|
tank.id = "actionTank";
|
|
tank.title = "How long this player has been thinking";
|
|
tank.textContent = "0:00";
|
|
row.appendChild(tank);
|
|
}
|
|
|
|
// A standing control rather than a toast per action: a dealer taps this bar
|
|
// constantly, and a toast after every fold would be noise they'd learn to
|
|
// ignore — which is exactly when they'd miss the one that mattered.
|
|
const undoLabel = this.hooks.lastActionLabel();
|
|
if (undoLabel) {
|
|
const undo = document.createElement("button");
|
|
undo.type = "button";
|
|
undo.className = "actionUndo";
|
|
undo.title = `Undo ${undoLabel}`;
|
|
undo.textContent = `↶ ${undoLabel}`;
|
|
// U undoes, and nothing said so — this control was the only place a dealer
|
|
// could have learned it, and it was the one action bar control without its
|
|
// key printed on it.
|
|
const key = document.createElement("kbd");
|
|
// Its own class, not .actionKey: that one pins itself to the top-right
|
|
// corner of a big action button, and this control is a 30px pill with
|
|
// overflow hidden and no positioning context — the cap escaped it and
|
|
// floated above the bar. Here the key belongs inline, after the label.
|
|
key.className = "actionUndoKey";
|
|
key.textContent = this.KEY_HINTS.actionUndo;
|
|
undo.appendChild(key);
|
|
undo.addEventListener("click", () => this.hooks.undoLast());
|
|
row.appendChild(undo);
|
|
}
|
|
|
|
return row;
|
|
},
|
|
|
|
// ---- Think timer ----
|
|
//
|
|
// A player who hasn't noticed the action is on them costs more time than anything
|
|
// else a dealer does, so the bar shows how long the current player has been in the
|
|
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
|
|
// tells the dealer when it's worth a nudge.
|
|
NUDGE_AFTER_S: 30,
|
|
SLOW_AFTER_S: 60,
|
|
|
|
tankSeatIndex: null,
|
|
tankStartedAt: null,
|
|
tankInterval: null,
|
|
|
|
// The seat that was already being asked to act when this page loaded, if there was
|
|
// one. Its clock is unknowable: the action moved to them before this session
|
|
// started — maybe two minutes ago, maybe last night — and starting from zero would
|
|
// have the bar say they had just begun thinking. Which it did, every time a dealer
|
|
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
|
|
// clock is measuring something it actually saw.
|
|
tankUnknownFor: null,
|
|
|
|
// Restarts only when the action actually moves — a re-render for any other reason
|
|
// (a chip edit, a name change) must not put a thinking player back to zero.
|
|
syncTankTimer() {
|
|
const index = this.state.actingSeatIndex;
|
|
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
|
|
|
|
if (!running) {
|
|
this.stopTankTimer();
|
|
this.tankSeatIndex = null;
|
|
return;
|
|
}
|
|
|
|
if (this.tankSeatIndex !== index) {
|
|
// Once the action has moved under our own eyes, nothing is unknown any more.
|
|
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
|
this.tankSeatIndex = index;
|
|
this.tankStartedAt = Date.now();
|
|
}
|
|
|
|
if (!this.tankInterval) {
|
|
this.tankInterval = setInterval(() => this.paintTank(), 1000);
|
|
}
|
|
this.paintTank();
|
|
},
|
|
|
|
stopTankTimer() {
|
|
if (this.tankInterval) {
|
|
clearInterval(this.tankInterval);
|
|
this.tankInterval = null;
|
|
}
|
|
},
|
|
|
|
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
|
|
// buttons under the dealer's thumb.
|
|
paintTank() {
|
|
const el = document.getElementById("actionTank");
|
|
if (!el || this.tankStartedAt === null) return;
|
|
|
|
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
|
|
el.textContent = "—";
|
|
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
|
|
el.classList.remove("tankNudge", "tankSlow");
|
|
return;
|
|
}
|
|
|
|
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
|
el.title = "How long this player has been thinking";
|
|
el.textContent = this.formatTank(seconds);
|
|
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
|
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
|
},
|
|
|
|
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
|
|
// open on a hand does reach that, and sixty-five minutes written as though it were
|
|
// a minutes-and-seconds reading is a number the eye gets wrong.
|
|
formatTank(seconds) {
|
|
const pad = value => String(value).padStart(2, "0");
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const rest = seconds % 60;
|
|
return hours > 0
|
|
? `${hours}:${pad(minutes)}:${pad(rest)}`
|
|
: `${minutes}:${pad(rest)}`;
|
|
},
|
|
|
|
// Shown only once someone is all-in and the money has actually split — a single
|
|
// undivided pot needs no announcement, and a line that's always there stops being
|
|
// read.
|
|
renderPotBreakdown() {
|
|
// Only worth showing once someone is actually all-in. Mid-street the amounts in
|
|
// front of players are unequal all the time — the blinds alone are unequal —
|
|
// and layering that would announce a side pot on every hand before anyone had
|
|
// even acted.
|
|
const someoneAllIn = this.state.seats.some(
|
|
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
|
|
);
|
|
if (!someoneAllIn) return null;
|
|
|
|
const contributions = this.state.seats.map((player, index) => ({
|
|
id: player.id,
|
|
index,
|
|
contribution: Betting.handTotal(player),
|
|
folded: player.folded,
|
|
}));
|
|
|
|
const nameById = new Map(
|
|
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
|
);
|
|
|
|
return Render.buildPotBreakdown(
|
|
PotCalculator.layers(contributions),
|
|
PotCalculator.uncalledReturns(contributions),
|
|
id => nameById.get(id)
|
|
);
|
|
},
|
|
|
|
// Which key presses each button. Shown on the button itself and hidden on touch
|
|
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
|
// on a phone is just clutter.
|
|
KEY_HINTS: {
|
|
actionFold: "F",
|
|
actionCheck: "C",
|
|
actionCall: "C",
|
|
actionRaise: "R",
|
|
actionAdvance: "↵",
|
|
raiseConfirm: "↵",
|
|
raiseCancel: "esc",
|
|
raiseAllIn: "A",
|
|
actionUndo: "U",
|
|
},
|
|
|
|
button(label, className, onClick, sublabel) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `actionBtn ${className}`;
|
|
|
|
const main = document.createElement("span");
|
|
main.className = "actionBtnLabel";
|
|
main.textContent = label;
|
|
btn.appendChild(main);
|
|
|
|
// Looked up by any of the button's classes, so a button can carry a shared
|
|
// class for its looks and a specific one for its key — the raise pad's four
|
|
// quick amounts are all raiseQuickBtn, but only All-in has a key.
|
|
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
|
|
if (hint) {
|
|
const kbd = document.createElement("kbd");
|
|
kbd.className = "actionKey";
|
|
kbd.textContent = hint;
|
|
btn.appendChild(kbd);
|
|
}
|
|
|
|
if (sublabel) {
|
|
const sub = document.createElement("span");
|
|
sub.className = "actionBtnSub";
|
|
sub.textContent = sublabel;
|
|
btn.appendChild(sub);
|
|
}
|
|
|
|
btn.addEventListener("click", onClick);
|
|
return btn;
|
|
},
|
|
|
|
// Every action taken from the bar goes through here, which is also where it gets
|
|
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
|
// tool a dealer can't talk their way out of.
|
|
commit(label, mutate) {
|
|
this.hooks.beginAction(label);
|
|
mutate();
|
|
this.hooks.save(this.state);
|
|
this.hooks.rerender();
|
|
},
|
|
|
|
renderActions() {
|
|
const index = this.state.actingSeatIndex;
|
|
const player = this.state.seats[index];
|
|
const owed = Betting.toCall(this.state, player);
|
|
const stack = Betting.stackOf(this.state, player);
|
|
const who = HoldemState.displayName(player, index);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
|
|
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
|
|
|
|
if (owed <= 0) {
|
|
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
|
|
} else if (owed >= stack) {
|
|
// Calling would put them all-in, so say that rather than quoting a number
|
|
// they can't cover.
|
|
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
|
} else {
|
|
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
|
|
}
|
|
|
|
if (stack > owed) {
|
|
// Not "does this player owe anything" — the big blind owes nothing and is
|
|
// still raising. It's whether there is a live bet to raise.
|
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
|
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
|
|
this.raiseOpen = true;
|
|
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
|
this.render();
|
|
}));
|
|
}
|
|
|
|
return row;
|
|
},
|
|
|
|
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
|
// button, and only reaches for digits when the table does something odd.
|
|
renderRaisePad() {
|
|
// A toast is transient news about the action just taken; the raise pad is the
|
|
// dealer deciding the next one. On a short screen they compete for the same
|
|
// strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
|
|
// which floats just above the bar — gets pushed up over the table, covering
|
|
// seats outright. Rather than squeeze both in, the older one gets out of the
|
|
// way. Nothing is lost: the toast's Undo is also the bar's own undo control.
|
|
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
|
|
|
const index = this.state.actingSeatIndex;
|
|
const player = this.state.seats[index];
|
|
const stack = Betting.stackOf(this.state, player);
|
|
const maxTo = Betting.round(Betting.committed(player) + stack);
|
|
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
|
// what this player can actually put in, which is what the Min button should
|
|
// offer. `legalMin` is what the rules require — for a short stack the two are
|
|
// different, and it's the rules one the warnings have to be measured against,
|
|
// or a player whose whole stack is under a full raise never gets told that.
|
|
const legalMin = Betting.minRaiseTo(this.state);
|
|
const minTo = Math.min(legalMin, maxTo);
|
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "raisePad";
|
|
|
|
const quick = document.createElement("div");
|
|
quick.className = "raiseQuick";
|
|
|
|
// The pot amounts come from Betting, which knows that a pot raise is measured
|
|
// off the pot as it stands *after* the caller's own call goes in. The pad used
|
|
// to work it out here as "the bet plus the pot", which left the call out and
|
|
// made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
|
|
// raise is $7, and a half-pot that clamped up to the minimum and sat next to
|
|
// the Min button showing the same $4 twice.
|
|
const options = [
|
|
{ label: "Min", value: minTo },
|
|
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
|
|
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
|
|
{ label: "All-in", value: maxTo },
|
|
];
|
|
options.forEach(option => {
|
|
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
|
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
|
|
this.raiseDraft = value;
|
|
this.render();
|
|
}, Money.format(value));
|
|
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
|
|
quick.appendChild(btn);
|
|
});
|
|
wrap.appendChild(quick);
|
|
|
|
const entry = document.createElement("div");
|
|
entry.className = "raiseEntry";
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.inputMode = "decimal";
|
|
input.className = "raiseInput";
|
|
input.value = this.raiseDraft ?? minTo;
|
|
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
|
|
input.addEventListener("input", () => {
|
|
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
|
this.updateRaiseNote(wrap, legalMin, maxTo);
|
|
});
|
|
entry.appendChild(input);
|
|
|
|
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
|
|
this.raiseOpen = false;
|
|
this.render();
|
|
}));
|
|
|
|
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
|
|
// under it to raise past.
|
|
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
|
|
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
|
this.raiseOpen = false;
|
|
const who = HoldemState.displayName(player, index);
|
|
const label = raising
|
|
? `${who}'s raise to ${Money.format(target)}`
|
|
: `${who}'s bet of ${Money.format(target)}`;
|
|
this.commit(label, () => Betting.raiseTo(this.state, index, target));
|
|
}));
|
|
|
|
wrap.appendChild(entry);
|
|
|
|
const note = document.createElement("p");
|
|
note.className = "raiseNote";
|
|
note.hidden = true;
|
|
wrap.appendChild(note);
|
|
this.updateRaiseNote(wrap, legalMin, maxTo);
|
|
|
|
return wrap;
|
|
},
|
|
|
|
// Says something when the amount typed isn't a legal raise. It never blocks —
|
|
// a dealer overruling the arithmetic is right more often than the arithmetic is,
|
|
// and the whole tool is built on tapping what the humans actually did.
|
|
//
|
|
// But an under-minimum raise from a player with chips behind them is the single
|
|
// most common thing that stops a home game dead, and until now the bar took it
|
|
// without a word: type 13 when the minimum is 18 and it was simply accepted. The
|
|
// engine then treats it as a short raise and correctly doesn't reopen the betting,
|
|
// which is right for an all-in and wrong for somebody who just misheard the bet.
|
|
updateRaiseNote(wrap, minTo, maxTo) {
|
|
const note = wrap.querySelector(".raiseNote");
|
|
if (!note) return;
|
|
|
|
const typed = Number(this.raiseDraft);
|
|
const current = this.state.currentBet || 0;
|
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
|
|
|
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
|
|
|
// Going all-in for less than a full raise is perfectly legal, and worth saying
|
|
// out loud because of what follows from it.
|
|
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
|
|
|
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
|
// The way out of an under-minimum differs: with a bet in front of you the
|
|
// alternative is calling it, and with an empty pot there's nothing to call.
|
|
note.textContent = raising
|
|
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
|
|
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
|
|
note.className = "raiseNote raiseNoteWarn";
|
|
note.hidden = false;
|
|
return;
|
|
}
|
|
|
|
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
|
note.textContent = raising
|
|
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
|
|
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
|
|
note.className = "raiseNote raiseNoteInfo";
|
|
note.hidden = false;
|
|
return;
|
|
}
|
|
|
|
if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; }
|
|
|
|
if (typed > maxTo + Betting.EPSILON_CHIPS) {
|
|
note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`;
|
|
note.className = "raiseNote raiseNoteInfo";
|
|
note.hidden = false;
|
|
return;
|
|
}
|
|
|
|
if (typed <= current) {
|
|
note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`;
|
|
note.className = "raiseNote raiseNoteWarn";
|
|
note.hidden = false;
|
|
return;
|
|
}
|
|
|
|
note.hidden = true;
|
|
},
|
|
|
|
// The table is set but the cards aren't out. This is the step that used to happen
|
|
// by itself the instant the previous hand ended, which meant blinds were committed
|
|
// while people were still sitting down — and the blind badges would drift away from
|
|
// the money already posted as seats changed. Now the dealer says when.
|
|
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
|
|
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
|
|
// name doesn't need the verb again, and the bar has no room for it.
|
|
straddleClause(preview) {
|
|
const straddles = preview.straddles || [];
|
|
if (straddles.length === 0) return "";
|
|
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
|
const [first, ...rest] = straddles;
|
|
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
|
|
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
|
|
},
|
|
|
|
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
|
|
// the big blind to straddle from, so nothing is offered rather than a button that
|
|
// would do nothing.
|
|
straddleButtons(preview) {
|
|
const on = (preview.straddles || []).length;
|
|
const max = preview.maxStraddles || 0;
|
|
if (max <= 0) return [];
|
|
|
|
const buttons = [];
|
|
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
|
|
if (on < max) {
|
|
buttons.push(this.button(
|
|
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
|
|
"actionStraddle",
|
|
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
|
|
() => { this.state.straddleCount = on + 1; })));
|
|
}
|
|
if (on > 0) {
|
|
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
|
|
() => this.commit("calling the straddle off",
|
|
() => { this.state.straddleCount = 0; })));
|
|
}
|
|
return buttons;
|
|
},
|
|
|
|
renderDealPrompt() {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "dealPrompt";
|
|
|
|
const preview = Betting.blindPreview(this.state);
|
|
const seated = Betting.liveSeats(this.state).length;
|
|
const broke = Betting.brokeSeats(this.state);
|
|
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
|
const listNames = indexes => indexes.length === 1
|
|
? name(indexes[0])
|
|
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
|
|
|
|
const line = document.createElement("p");
|
|
line.className = "dealPromptLine";
|
|
|
|
if (!preview) {
|
|
// Two players is the least you can deal. Say what's missing rather than
|
|
// offering a button that would quietly do nothing — and say the *right*
|
|
// thing: "only one player is dealt in" is wrong when four are sitting there
|
|
// and three of them are just out of chips.
|
|
const withChips = seated - broke.length;
|
|
// "Nobody has chips left" is the wrong story for a table that never had
|
|
// any. A dealer who has just named six people and not yet said what a
|
|
// buy-in is gets told their players are busted, which sends them looking
|
|
// for a rebuy button instead of the one field that fixes it.
|
|
const nobodyHasEverBoughtIn = this.state.seats.every(p =>
|
|
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
|
|
|
|
if (broke.length > 0 && withChips < 2) {
|
|
line.textContent = withChips === 1
|
|
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
|
|
: nobodyHasEverBoughtIn
|
|
? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
|
|
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
|
} else {
|
|
line.textContent = seated === 0
|
|
? "Nobody is dealt in yet. Tap a seat to add a player."
|
|
: "Only one player is dealt in — a hand needs two.";
|
|
}
|
|
line.classList.add("dealPromptBlocked");
|
|
wrap.appendChild(line);
|
|
|
|
// A blocked prompt with nothing to press is a dead end, and this was the
|
|
// one a first-time dealer hits: six players named, a sentence saying they
|
|
// have no chips, and no button anywhere on the bar. The starting stack is a
|
|
// single field in the sidebar and the message never pointed at it.
|
|
if (nobodyHasEverBoughtIn && seated > 0) {
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
row.appendChild(this.button("Set starting stack", "actionDeal", () => {
|
|
const field = document.getElementById("defaultStackInput");
|
|
if (!field) return;
|
|
const panel = field.closest("details");
|
|
if (panel) panel.open = true;
|
|
field.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
field.focus();
|
|
field.select();
|
|
}));
|
|
wrap.appendChild(row);
|
|
return wrap;
|
|
}
|
|
|
|
// Named seats that aren't in the hand. Naming a chair now seats whoever is
|
|
// in it, so reaching this takes something else — a table where everyone was
|
|
// sat out, or a save from before that was true — but the way out of it
|
|
// should still be one tap rather than a hunt through six seat editors.
|
|
const benched = this.state.seats
|
|
.map((player, index) => ({ player, index }))
|
|
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
|
.map(({ index }) => index);
|
|
|
|
if (benched.length > 0) {
|
|
const aside = document.createElement("p");
|
|
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
|
const text = document.createElement("span");
|
|
text.textContent = benched.length === 1
|
|
? `${listNames(benched)} is sitting out.`
|
|
: `${listNames(benched)} are sitting out.`;
|
|
aside.appendChild(text);
|
|
aside.appendChild(this.button(
|
|
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
|
|
"dealPromptDebtBtn",
|
|
() => this.commit("dealing everybody in", () => {
|
|
benched.forEach(index => {
|
|
this.state.seats[index].inHand = true;
|
|
this.state.seats[index].folded = false;
|
|
});
|
|
})
|
|
));
|
|
wrap.appendChild(aside);
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
|
|
// the small blind" out loud is the whole point — a dealer who reads a preview
|
|
// with a blind missing and isn't told why will assume the tool has lost track.
|
|
const blinds = document.createElement("span");
|
|
blinds.textContent = (preview.deadSmallBlind
|
|
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
|
|
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
|
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
|
|
this.straddleClause(preview) +
|
|
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
|
line.appendChild(blinds);
|
|
wrap.appendChild(line);
|
|
|
|
// Why a position is missing. Both of these are the dead button rule doing its
|
|
// job, and both look like a bug until somebody says otherwise.
|
|
if (preview.deadButton || preview.deadSmallBlind) {
|
|
const dead = document.createElement("p");
|
|
dead.className = "dealPromptLine dealPromptAside";
|
|
dead.textContent = preview.deadButton
|
|
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
|
|
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
|
|
wrap.appendChild(dead);
|
|
}
|
|
|
|
// Dealing relocates a button sitting on an empty chair. That has to be said
|
|
// before it happens rather than discovered afterwards — a fresh table starts
|
|
// the button on seat 1 whether or not anyone is sitting there.
|
|
if (preview.buttonMoves) {
|
|
const moved = document.createElement("p");
|
|
moved.className = "dealPromptLine dealPromptAside";
|
|
moved.textContent =
|
|
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
|
|
wrap.appendChild(moved);
|
|
}
|
|
|
|
// Dealing sits out anyone with nothing behind. Said before it happens, for the
|
|
// same reason the button move is: a dealer shouldn't find out from the felt.
|
|
if (preview.satOut.length > 0) {
|
|
const out = document.createElement("p");
|
|
out.className = "dealPromptLine dealPromptAside";
|
|
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
|
|
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
|
|
wrap.appendChild(out);
|
|
}
|
|
|
|
// Somebody who has just sat down between the button and the big blind has had
|
|
// the blinds go past them, and dealing them in there is a free hand in late
|
|
// position. At a table the answer is that they post the big blind to come in
|
|
// now, or wait for it to reach them. The tool used to do neither and say
|
|
// nothing: it dealt them in for free, and the dealer had no way to know it had
|
|
// happened. Both answers are offered here; neither is forced, because a home
|
|
// game that doesn't care shouldn't have to argue with its own scoreboard.
|
|
// One line for however many have walked up, not one each. The bar is docked
|
|
// over the felt and the felt has already shrunk as far as it will go on a
|
|
// phone, so every line here is taken out of the ring of seats: measured at
|
|
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
|
|
// bottom seat. They post together or they wait together, which is what happens
|
|
// at a table anyway when two people sit down at once.
|
|
if (preview.debtors.length > 0) {
|
|
const ids = preview.debtors.map(i => this.state.seats[i].id);
|
|
const posting = ids.every(id => preview.posting.includes(id));
|
|
const who = listNames(preview.debtors);
|
|
const each = preview.debtors.length > 1 ? " each" : "";
|
|
|
|
const line = document.createElement("p");
|
|
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
|
|
|
const text = document.createElement("span");
|
|
text.textContent = posting
|
|
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
|
|
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
|
|
line.appendChild(text);
|
|
|
|
line.appendChild(this.button(
|
|
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
|
|
"dealPromptDebtBtn",
|
|
() => this.commit(
|
|
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
|
|
() => { this.state.postingIn = posting ? [] : ids; })
|
|
));
|
|
wrap.appendChild(line);
|
|
}
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons dealPromptButtons";
|
|
// Beside the deal button rather than on a line of its own. Measured at 390x844:
|
|
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
|
|
// and this row is already there and already 44px tall. It reads in the order it
|
|
// happens, too — say the straddle, then deal.
|
|
this.straddleButtons(preview).forEach(button => row.appendChild(button));
|
|
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
|
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
|
}));
|
|
wrap.appendChild(row);
|
|
|
|
return wrap;
|
|
},
|
|
|
|
// "Dee shows first — last to raise." The call the dealer has to make at every
|
|
// showdown and that nobody at the table volunteers for, said out loud with the
|
|
// reason attached so it can be repeated to whoever argues with it.
|
|
showdownCall() {
|
|
const call = Betting.showdownOrder(this.state);
|
|
if (!call) return null;
|
|
|
|
const who = HoldemState.displayName(this.state.seats[call.index], call.index);
|
|
const because = {
|
|
aggressor: "last to bet or raise",
|
|
// Semicolon, not a second dash: the sentence already spends its dash on
|
|
// "shows first —", and two of them in one line reads as a stutter.
|
|
allInAggressor: "last to put money in; nobody could bet the river",
|
|
leftOfButton: "checked round, first left of the button",
|
|
}[call.reason];
|
|
|
|
const line = document.createElement("p");
|
|
line.className = "showdownCall";
|
|
line.textContent = `${who} shows first — ${because}.`;
|
|
return line;
|
|
},
|
|
|
|
renderStreetDone() {
|
|
const wrap = document.createElement("div");
|
|
|
|
const isRiver = this.state.street === "river";
|
|
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
|
|
|
if (isRiver) {
|
|
const call = this.showdownCall();
|
|
if (call) wrap.appendChild(call);
|
|
}
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
row.appendChild(this.button(label, "actionAdvance", () => {
|
|
if (isRiver) {
|
|
this.hooks.resolvePot();
|
|
return;
|
|
}
|
|
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
|
}));
|
|
wrap.appendChild(row);
|
|
|
|
return wrap;
|
|
},
|
|
|
|
renderHandOver() {
|
|
const wrap = document.createElement("div");
|
|
const live = Betting.liveSeats(this.state);
|
|
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
|
|
|
// Two or more still in with the betting finished is a showdown however it was
|
|
// arrived at — including everyone all-in on the flop, where the cards go on
|
|
// their backs and the order still decides who turns over first.
|
|
if (!winner) {
|
|
const call = this.showdownCall();
|
|
if (call) wrap.appendChild(call);
|
|
}
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
row.appendChild(this.button(
|
|
winner ? `Award pot to ${winner}` : "Resolve pot",
|
|
"actionAdvance",
|
|
() => this.hooks.resolvePot()
|
|
));
|
|
wrap.appendChild(row);
|
|
return wrap;
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = ActionBar;
|
|
}
|