Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
T
jheapsandClaude Opus 5 e36e172670 Let the undo pill finish its own sentence
The bar's undo is a fixed-width pill because its text changes on every tap and
a control that resizes under a thumb reads as the interface twitching. On a
phone that leaves the label 78px, so the label is three elements and
fitUndoLabel drops the actor whole rather than letting an ellipsis land
wherever it lands. That machinery was right. The labels fed to it were not.

Two rules follow from "the actor is the half that goes", and six of the
fifteen labels broke one.

The act has to name the action on its own, because it is what survives. Four
labels put a verb in the actor's slot — "dealing" + "the hand", "dealing" +
"everybody in", "calling" + "the straddle off" — so dropping the lead left the
pill reading "↶ the hand" after every deal of the night, and "↶ the straddle
off". Neither names anything. Those are re-split: "the deal", "everybody dealt
in", "the straddle called off".

The act has to fit, because whatever is left is ellipsised. Measured at
390x844 and 360x640 with a thumb: "hand killed on the clock" wanted 125px of
78, "check on the clock" 99, "waiting for the blind" 103, "posting to come in"
97 — all four came out cut mid-phrase, "↶ waiting for th…". The qualifier goes
into a new optional `long`, which is the whole sentence, and the verb stays on
the pill.

Every short form is a substring of its long form, which is what Label in Name
asks of a control that has shortened its own name — and it is what lets `long`
be the accessible name rather than only a title a phone has no way to show.
Before this the name was whatever was visible: "↶ hand killed U", arrow and
keycap included. Both are aria-hidden now.

Measured after, on both phone sizes: nothing ellipsised, and every visible
form contained in the name. One exception, stated in the code: a five-figure
raise. "raise to $12,500" is 86px of 78 and the number is the whole of what it
says, so there is no qualifier to move — the leading digits survive, the
amount is on that player's seat puck, and the whole of it is on the name.

Three comments carrying the old 73px figure are re-measured while I was here.

1353 tests pass, 11 of them new — 3 fail against the old labels. The sweep is
clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 05:33:27 -06:00

1580 lines
81 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 rail = this.isRail(host);
// On a phone held sideways the bar is a rail down the right-hand side, so it
// takes no height at all — and `--actionBarHeight` is what four other rules
// and the felt measurement all mean by "how much of the bottom is spoken
// for". Publishing the rail's height there would reserve the whole window.
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
// What is under the felt is the bar in one layout and the two links in the
// other. Either way it is the same question — how much of the bottom is
// already spoken for — so it is the same argument.
//
// Except that the felt is asked a slightly different question from everything
// else that reads --actionBarHeight. The toast and the scroll padding want to
// know what is covering the bottom of the window *right now*; the table wants
// to know how big to be, and being a different size for the few seconds a pad
// is open over it is not an answer, it is a jolt. See restingBarHeight.
this.publishFeltRoom(rail ? this.roomBelowFelt() : this.restingBarHeight(height));
};
// Kept so anything that moves the table without touching the bar can ask for
// the felt to be measured again — the board strip appearing above it does
// exactly that, and the felt's height is worked out from where the table starts.
this.publishHeight = publish;
if (typeof ResizeObserver === "function") {
this.heightWatcher = new ResizeObserver(publish);
this.heightWatcher.observe(host);
}
window.addEventListener("resize", publish);
window.addEventListener("orientationchange", publish);
publish();
},
// How tall the bar is when it is only a bar.
//
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
// 390x844 felt 309x463 -> 309x451, worst seat moved 11px
//
// The whole table jumped, re-ran the ten-candidate width search, redrew at a
// different size — and then undid all of it when the dealer pressed Raise to. Every
// raise, all night, at the one moment they are reading the felt to decide what to
// type. A phone held sideways already did the right thing here and scored 0px: the
// rail is a column down the side, so its height never was the table's business.
//
// A pad over the bar is a transient surface, and the thing a transient surface must
// not do is take the context away from underneath itself. So the felt is sized
// against the bar the thumb is coming back to, and the pad grows up over the bottom
// of the felt for as long as it is open.
//
// Only the felt's question is answered this way. --actionBarHeight stays what it has
// always been — what is actually covering the bottom of the window — because that is
// what the toast has to clear and what the page has to scroll past.
//
// The remembered height goes stale if the window changes size while the pad is open,
// which on a phone means turning it over mid-raise. The next publish after the pad
// closes is a measurement, so it puts itself right; a stale felt for the seconds in
// between is the same trade being made everywhere else here.
restingBarHeight(measured) {
if (!this.raiseOpen) {
this.barHeightAtRest = measured;
return measured;
}
return this.barHeightAtRest === undefined ? measured : this.barHeightAtRest;
},
// Is the bar a rail down the side rather than a bar across the bottom?
//
// Asked of the stylesheet rather than answered again here. The layout is chosen by
// a media query — orientation, height and width together — and a second copy of
// that query written in JavaScript is a copy that drifts: the first thing anyone
// changes is the breakpoint, in one of the two places. So the CSS declares which
// layout it picked in a custom property and this reads the answer back.
isRail(host) {
if (!host || typeof getComputedStyle !== "function") return false;
return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
},
// What sits between the bottom of the felt and the bottom of the window when the
// bar is off to one side: the table hint and the row of links under it. Measured,
// because both come and go — the hint is empty mid-hand and the links wrap on the
// narrowest column.
// Taken as the distance from the bottom of the felt to the bottom of the section it
// sits in, which is a difference rather than a height — so it doesn't move when the
// felt's own height changes, and this can't chase itself around the screen.
roomBelowFelt() {
const wrap = document.getElementById("pokerTableWrap");
const section = wrap && wrap.parentElement;
if (!section) return 0;
const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
return Math.max(0, Math.round(gap));
},
// 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;
const root = document.documentElement;
const was = root.style.getPropertyValue("--feltRoomPx");
const now = `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`;
root.style.setProperty("--feltRoomPx", now);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render === "undefined" || !Render.state) return;
if (Render.updateSeatDensity) Render.updateSeatDensity();
// And when it really did change size, the seats are laid out again rather than
// only re-measured.
//
// The seats follow a felt on their own — they are placed in percentages of it —
// but a bet chip anchored inside a seat is placed in pixels off that seat's
// centre, worked out against the felt as it was. Nothing re-ran that. Measured
// at 640x360 with five players: the chips were positioned for a felt 83px tall,
// painted on one of 60, and seat 3's $5 came to rest on seat 4's puck. The bar
// growing mid-hand does this too — a side pot appearing is 55px off the felt —
// so it is not only the board strip that could reach it.
if (now !== was && Render.renderSeats) Render.renderSeats();
},
// 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 = "";
// A table with nobody at it used to hide this bar altogether, which meant the
// one moment a dealer has never used the tool before is the one moment it says
// nothing at all. What they got instead was a felt printing EMPTY ten times and
// a green Resolve Pot button that would resolve nothing.
//
// An empty table is a state with an obvious next step, so it says what it is.
if (!this.isRunning()) {
host.hidden = false;
document.body.classList.add("hasActionBar");
host.appendChild(this.renderEmptyTable());
this.stopTankTimer();
this.tankSeatIndex = null;
this.announce();
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();
// After the tank timer, because whether the stopwatch and the clock button are
// on the acting line is what decides how much room the name has left.
this.fitActingLine();
this.fitUndoLabel();
this.showRailEnd(host);
this.announce();
},
// A phone held sideways puts this bar down the right-hand edge as a rail, anchored
// low: the buttons sit at the bottom, where a thumb rests rather than where a hand
// has to reach up. The rail scrolls when what it holds outgrows it, which the raise
// pad does on a 360px-tall screen the moment the dealer has their text turned up —
// measured at 640x360 with 20px of root text, 407px of pad in a 358px rail.
//
// And a scroll container starts at its top. So the overflow came off the bottom,
// which is exactly where the two buttons that end a raise are: Cancel and Raise to
// were half under the edge of the screen with the hand number and the pot — the two
// things a dealer had already read — holding the room above them. Anchored low, the
// rail should open at the end for the same reason it is anchored low at all.
showRailEnd(host) {
if (!this.isRail(host)) return;
if (host.scrollHeight <= host.clientHeight) return;
host.scrollTop = host.scrollHeight;
},
// The empty table. One sentence and one button, in the place every other prompt in
// this tool appears, so a dealer who has never opened it before is looking at the
// next step rather than at ten chairs labelled EMPTY.
renderEmptyTable() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
const line = document.createElement("p");
line.className = "dealPromptLine";
line.textContent = "Nobody's at the table yet.";
wrap.appendChild(line);
const buttons = document.createElement("div");
buttons.className = "dealPromptButtons";
const seat = document.createElement("button");
seat.type = "button";
seat.className = "actionBtn actionDeal";
seat.textContent = "Seat a player";
seat.addEventListener("click", () => this.hooks.seatFirstPlayer());
buttons.appendChild(seat);
wrap.appendChild(buttons);
return wrap;
},
// One sentence, for anyone running this by ear.
//
// The bar used to carry aria-live itself, and because render() rebuilds the whole
// thing every action, a screen reader read the lot out after every tap — street,
// pot, player count, who's short, the clock, the undo label and all three buttons.
// Nothing in that is wrong; it is just unusable at the speed a hand is dealt.
//
// What a dealer actually needs from the tool between taps is who is on the clock
// and what it costs them. That is what this says, into a small element that exists
// from page load and only ever has its text replaced.
//
// Guarded on the sentence itself rather than on the state, because render() is
// called for a great many reasons that don't change whose turn it is — resizing the
// window, opening a panel, editing a note — and a live region that repeats itself
// is read out again every time.
announce() {
const target = document.getElementById("actionAnnouncer");
if (!target) return;
const said = this.announcement();
if (said === this.lastAnnouncement) return;
this.lastAnnouncement = said;
target.textContent = said;
},
announcement() {
const state = this.state;
if (!this.isRunning()) return "Nobody's at the table yet.";
if (!state.bettingOpen) {
const preview = Betting.blindPreview(state);
return preview ? "Ready to deal." : "";
}
if (Betting.isHandOver(state)) return "Hand over. Resolve the pot.";
const index = state.actingSeatIndex;
if (index === null || index === undefined) {
return `${Betting.streetLabel(state.street)} betting is done.`;
}
const player = state.seats[index];
const name = HoldemState.displayName(player, index);
const owed = Betting.toCall(state, player);
const cost = owed > 0 ? `${Money.format(owed)} to call` : "checked to them";
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
},
// Pure, so the wording can be checked without a page to hang it on — the same reason
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
// zero and not hand undefined, and that fallback is the whole of what there is to
// get wrong here.
handLabel(state) {
return `#${(state && state.handNumber) || 1}`;
},
renderStatus() {
const row = document.createElement("div");
row.className = "actionStatus";
// Which hand this is. It used to be a gold pill beside the page title, and it
// was the wrong fact in the wrong place: a counter that changes once a hand,
// read after the fact, holding a line of a 390px phone's header all night. It
// belongs with the street, the pot and the player count — the other three
// things that say what hand you are in — and here it costs nothing, because
// this row was already drawn.
const hand = document.createElement("span");
hand.className = "actionHand";
hand.textContent = this.handLabel(this.state);
row.appendChild(hand);
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";
// It used to end "(12 players)", which is the number the chip immediately to
// its left already says, in the same row, at the same moment. Saying it
// twice in one breath reads like two different figures being quoted, and on
// a 360px phone it cost the sentence a second line — which the felt paid
// for, on the one table shape that has twelve seats to fit.
runout.textContent = "All-in — running out the board";
row.appendChild(runout);
}
// The second line of this block, held as a line rather than left to whatever the
// wrapping happens to produce.
//
// The row wraps, and flex decides its line breaks before it decides what to
// shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line. That is 22px, and the
// bar is fixed to the bottom, so it came off the felt: measured at 360x640
// mid-street, "Dee · $600 behind" left the table 283x329 and "Christopher ·
// $1,200 behind" made it 283x310, with every seat moving 24px between one player
// and the next. Whose turn it was decided how big the table was.
//
// On its own line the three of them are a row that can shrink instead of a row
// that can wrap, and the block is two lines whoever is acting.
const acting = document.createElement("div");
acting.className = "actionActing";
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
// Two spans rather than one sentence, because only one half of it is
// allowed to give way. This row wraps, and a name long enough to push the
// clock and the undo pill onto a third line takes 22px off the felt — so
// whose turn it is decided how big the table was. Measured at 360x640:
// "Dee · $600 behind" left the bar 151px tall and "Christopher · $1,200
// behind" made it 173, and the felt resized between one player and the next.
//
// So the name shrinks and the number does not. A name cut to "Christophe…"
// is still the player the dealer is looking at; a stack cut to "$1,2…" is a
// figure they would have to go and find somewhere else.
const who = document.createElement("span");
who.className = "actionWho";
const whoName = document.createElement("span");
whoName.className = "actionWhoName";
whoName.textContent = HoldemState.displayName(player, index);
// The whole of it, for the one name on the narrowest phone that still has to
// give up a few characters. Same arrangement the seat pucks have.
whoName.title = whoName.textContent;
who.appendChild(whoName);
const whoStack = document.createElement("span");
whoStack.className = "actionWhoStack";
// No leading space: this is a flex item and its own would be collapsed away
// at the edge. .actionWho holds the two of them apart instead.
whoStack.textContent = `· ${Money.format(Betting.stackOf(this.state, player))} behind`;
who.appendChild(whoStack);
acting.appendChild(who);
// Built every render and shown by the tick, the same way the clock button
// beside it is: a stopwatch reading "0:02" is 50px of the narrowest line in
// the tool spent on a number nobody acts on. Starts hidden so a decision
// that has only just landed on somebody never flashes one up for a frame.
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
tank.hidden = true;
acting.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
// dealer reaches for it — and never occupies the bar in the ordinary case
// where nobody is holding anybody up.
const clock = document.createElement("button");
clock.type = "button";
clock.id = "actionClockBtn";
clock.className = "actionClockBtn";
// Once the count is over there is nothing to stop: the ruling has been
// made, and the dealer either carries it out or takes an ordinary action,
// both of which end the clock themselves. Leaving the button there also
// cost real room — at 640x360 its 94px pushed the undo control onto a
// second line, and the taller bar covered the two bottom seats.
const expiredNow = this.clockRunning() && this.clockLeft() === 0;
clock.hidden = !this.clockRunning() || expiredNow;
if (this.clockRunning()) {
clock.textContent = "Stop the clock";
clock.title = "Put the decision back in the player's own time";
clock.addEventListener("click", () => { this.stopClock(); this.render(); });
} else {
clock.textContent = "Call the clock";
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
acting.appendChild(clock);
}
// 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}`;
// And said out loud, not only hung off a hover. The visible label is the
// short form and every short form is a substring of this one, which is what
// Label in Name asks: a speech user saying what they can see still hits it,
// and a screen reader gets the qualifier the pill had no room for. Without
// this the name was the visible text — on a phone, "↶ hand killed U".
undo.setAttribute("aria-label", `Undo ${undoLabel}`);
// The label goes in its own element rather than straight onto the button.
// The pill is a fixed width — it names the last action, so its text changes
// on every tap, and a control that resizes under the dealer's thumb reads
// as the interface twitching. Fixed width means long labels have to give
// something up, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault.
//
// Three elements, because what it gives up matters. Measured on a phone,
// the label has 78px and every label with an actor on it wants more:
// "↶ Mike from work's fold" 128, "↶ Christopher's fold" 109. Widening it is
// not available — the room it would take is the room the acting name beside
// it needs, and that is what fitActingLine is already spending. So the actor
// comes off, whole, and the act stays; see fitUndoLabel, and undoParts for
// what that requires of an act.
const label = document.createElement("span");
label.className = "actionUndoLabel";
const glyph = document.createElement("span");
glyph.className = "actionUndoGlyph";
glyph.textContent = "↶";
// Decoration, and hidden so it stays out of the name above — "left arrow
// over hand killed" is not what this control is called.
glyph.setAttribute("aria-hidden", "true");
label.appendChild(glyph);
const parts = this.undoStep();
if (parts.lead) {
const lead = document.createElement("span");
lead.className = "actionUndoLead";
lead.textContent = parts.lead;
label.appendChild(lead);
}
const act = document.createElement("span");
act.className = "actionUndoAct";
act.textContent = parts.act;
label.appendChild(act);
undo.appendChild(label);
// 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;
key.setAttribute("aria-hidden", "true");
undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
acting.appendChild(undo);
}
// Nothing to act on and nothing to take back — no second line at all.
if (acting.childElementCount) row.appendChild(acting);
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,
// ---- Calling the clock ----
//
// The stopwatch above says how long somebody has been thinking. This is what a
// dealer does about it, and it is a procedure with a shape: a player asks for a
// clock, the dealer grants it, the player gets a set time and then a countdown,
// and if they still haven't acted the hand is decided for them — dead if they were
// facing a bet, checked if they weren't.
//
// The tool knew none of that. A dealer running it had to count in their head while
// the table argued about whether it had been thirty seconds, which is the moment
// the count needs to be somewhere everybody can see.
//
// Thirty seconds and then a ten-second countdown is the common form of the rule and
// what the TDA writes down. The countdown is the part the table hears out loud, so
// it is marked separately rather than being ten more seconds of the same.
CLOCK_THINK_S: 30,
CLOCK_COUNTDOWN_S: 10,
clockSeatIndex: null,
clockStartedAt: null,
clockRunning() {
return this.clockSeatIndex !== null &&
this.clockSeatIndex === this.state.actingSeatIndex &&
this.clockStartedAt !== null;
},
// Seconds left before the hand is decided, or null when no clock is running.
// Negative is impossible: it stops at zero and stays there until the dealer acts.
clockLeft(now = Date.now()) {
if (!this.clockRunning()) return null;
const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
},
// What happens when it reaches zero, in the words the dealer says out loud.
// Facing a bet, the hand is dead. Facing none, it is checked — a player who could
// have checked for free is not folded for being slow.
clockRuling() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return null;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const who = HoldemState.displayName(player, index);
return owed > 0
? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
: { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
},
callClock() {
const index = this.state.actingSeatIndex;
if (index === null || index === undefined) return;
this.clockSeatIndex = index;
this.clockStartedAt = Date.now();
this.clockExpiredShown = false;
this.render();
},
stopClock() {
this.clockSeatIndex = null;
this.clockStartedAt = null;
this.clockExpiredShown = false;
},
// Whether the bar has already been rebuilt into its "time is up" shape, so the
// one-second tick doesn't rebuild it again every second afterwards.
clockExpiredShown: false,
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();
// A clock belongs to one player's decision. The moment the action moves —
// because they acted, or because the dealer took something back — it is
// over, and leaving it running would put the next player on a countdown
// nobody called on them.
this.stopClock();
}
if (!this.tankInterval) {
this.tankInterval = setInterval(() => this.paintTank(), 1000);
}
this.paintTank();
},
stopTankTimer() {
if (this.tankInterval) {
clearInterval(this.tankInterval);
this.tankInterval = null;
}
},
// Whether this decision has been going on long enough to be worth saying anything
// about — which is the one question behind both the stopwatch and the button beside
// it, so they ask it once and answer it together.
//
// The stopwatch used to be there from the first second of every decision, and for
// the first half-minute it reads "0:07" in grey: a number nobody acts on, holding
// 50px of a line that on a 390px phone has 349 to spend between a name, a stack, a
// clock button and the undo pill. It is not a readout the dealer is watching; it is
// a prompt, and a prompt that is always there is not one. See fitActingLine for
// what the room it gives back is spent on.
tankWorthShowing() {
if (this.clockRunning()) return true;
if (this.tankStartedAt === null) return false;
// Nothing to time from — the action was already here when the page loaded. The
// dash and its explanation are the answer to "why is there no clock on this
// one", so it has to be visible to be asked.
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) return true;
return this.tankSeconds() >= this.NUDGE_AFTER_S;
},
tankSeconds() {
return Math.floor((Date.now() - this.tankStartedAt) / 1000);
},
// 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;
const nudge = document.getElementById("actionClockBtn");
const wasTank = el.hidden;
const wasNudge = nudge ? nudge.hidden : null;
const worth = this.tankWorthShowing();
el.hidden = !worth;
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking. Before that it is a control nobody
// needs, and this bar is docked over the seats. While a clock is already running
// the button says "Stop the clock" and renderStatus decides whether it is there
// at all — once the count has expired there is nothing left to stop.
if (nudge && !this.clockRunning()) nudge.hidden = !worth;
if (!this.paintTankText(el)) return;
// Two things just left the line or joined it, so what is left has a different
// amount of room than it did a second ago.
if (el.hidden !== wasTank || (nudge && nudge.hidden !== wasNudge)) this.fitActingLine();
},
// The readout itself. Returns false when it has asked for a full render instead, so
// that nothing downstream measures a bar which is about to be rebuilt.
paintTankText(el) {
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
// gets a full render, once, rather than being painted in by the tick.
const left = this.clockLeft();
if (left !== null) {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return false;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return true;
}
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 true;
}
const seconds = this.tankSeconds();
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);
return true;
},
// Which half of "Christopher · $1,200 behind" survives when the line cannot hold
// both of them.
//
// The name was set up to be the half that gives way, on the rule the seat pucks
// keep: a number read wrong is a worse failure than a name read short. That rule is
// right and it stays. What was missing under it was a floor. Flex shrinks the only
// item that *can* shrink until the line fits, and with the clock button on the line
// there is no fitting to be had — measured at 360x640 and again at 390x844, both
// "Christopher" and "Alexandra" were drawn 0px wide, so the bar read
//
// · $1,200 behind
//
// a dangling separator and a figure, at the exact moment the dealer is looking at
// the bar to say whose turn it is out loud. The ellipsis never appeared: there were
// no characters left to put one after.
//
// So the stack comes off the line before the name gives up a single character. It is
// not lost — it is on that player's own seat puck, a couple of inches up the same
// screen, which is where a dealer counting somebody down is looking anyway. The name
// is the thing the bar alone is saying.
// The last action in the two halves the pill draws it in. The history is main.js's,
// because undo pops it — remembering the halves here would name the step that was
// just taken back rather than the one now on top. A caller that has not been given
// the halves gets the label whole, with nothing to drop, which is what it had before.
undoStep() {
const label = this.hooks.lastActionLabel();
const parts = this.hooks.lastActionParts && this.hooks.lastActionParts();
return parts || { lead: "", act: label };
},
// Which half of "Christopher's fold" the pill can hold.
//
// The actor comes off whole rather than being cut down: "Christo…'s fold" is a worse
// sentence than "fold", and the question a hand later is what came back rather than
// whose it was — the dealer has the seat in front of them. The whole of it stays on
// the button's title and aria-label, which is what the guidance asks of a control
// that has had to shorten its own name.
fitUndoLabel() {
const label = document.querySelector("#actionBar .actionUndoLabel");
const lead = label && label.querySelector(".actionUndoLead");
const act = label && label.querySelector(".actionUndoAct");
if (!lead || !act) return;
// The act is what gets asked, not the label. The label cannot overflow — the act
// inside it is the item with `overflow: hidden`, so it swallows the excess and
// the row it sits in measures as though everything fitted. Asking the label was
// the first way this was written and it read "↶ dealing the hand" with "the
// hand" quietly cut off inside it, on the one label short enough that the actor
// alone still fitted.
//
// Measured with the actor back on, so the answer is a property of the pill
// rather than of whatever the last render left behind.
lead.hidden = false;
if (act.scrollWidth > act.clientWidth + 1) lead.hidden = true;
},
fitActingLine() {
const name = document.querySelector("#actionBar .actionWhoName");
const stack = document.querySelector("#actionBar .actionWhoStack");
if (!name || !stack) return;
// Always measured with the stack back on, so the answer is a property of the
// line rather than of whatever the last render happened to leave behind.
stack.hidden = false;
// What the name wants against what flex gave it. A cut name reads as an
// overflowing one even when the overflow is the whole word.
if (name.scrollWidth > name.clientWidth + 1) stack.hidden = true;
},
// "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)])
);
// Four is what the bar can hold without taking the felt with it — see
// buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check
// for the ceiling this is keeping the bar under.
//
// Two, once the run-out has started, for the reason spelled out over there: the
// list is decision support, and during a run-out there is no decision left to
// support. The felt gets the difference, on the one table shape that has the
// most seats to fit into it.
return Render.buildPotBreakdown(
PotCalculator.layers(contributions),
PotCalculator.uncalledReturns(contributions),
id => nameById.get(id),
null,
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
);
},
// 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) {
// A toast is news about the last thing that happened, and this is the next
// thing: whatever it says is now out of date. Two reasons it goes rather than
// waiting out its eight seconds. It lies — "Next hand — button on seat 2.
// [Undo]" reads as an offer to move the button back, but the snapshot behind
// that Undo predates the hand you have since dealt, so taking it would throw
// the hand away without saying so. And it is in the way: measured across the
// sweep, a toast left up over a dealt table covered seats, the clock button
// or the bar's own Undo on thirty of the small screens it was checked at,
// because there is nowhere on a 360x640 phone for a 560px strip to go. The
// bar's own Undo control takes over from here, and it names the step it
// would actually take back.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
const step = this.undoParts(label);
this.hooks.beginAction(step.long, step);
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
},
// An action's name in the parts the undo pill needs it in.
//
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching.
// Measured on a phone, that leaves the label 78px, and given the halves it can drop
// one. The actor is the half that goes, because the dealer has the seat in front of
// them and the question a hand later is *what* came back, not who it belonged to;
// and because "Christo…'s fold" is a worse sentence than "fold".
//
// Two rules follow, and both were being broken:
//
// `act` has to name the action on its own, because it is what survives. Four
// labels put a verb in the lead instead of an actor — "dealing" + "the hand",
// "calling" + "the straddle off" — so dropping the lead left the pill reading
// "↶ the hand" after every deal of the night, and "↶ the straddle off", neither
// of which names anything. Those are re-split; the ones with no actor at all
// hand over a single string and have nothing to drop.
//
// `act` has to fit, because the box ellipsises whatever is left. Measured at
// 390x844 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on
// the clock" 99, "waiting for the blind" 103, "posting to come in" 97 — all four
// came out cut mid-phrase. The qualifier moves into `long` and the verb stays on
// the pill.
//
// `long` is the whole sentence, for the title and the accessible name and for the
// toast the bar's own Undo raises. Every short form is a substring of it — that is
// what WCAG's Label in Name asks of a control that has shortened its own name, and
// it is why `long` can be the accessible name rather than only a title.
undoParts(label) {
const lead = typeof label === "string" ? "" : (label.lead || "");
const act = typeof label === "string" ? label : label.act;
const spelt = typeof label === "string" ? "" : (label.long || "");
return { lead, act, long: spelt || (lead ? `${lead} ${act}` : act) };
},
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);
// Time is up. The ruling goes on the bar in the words the dealer says out loud,
// with the one action that carries it out — and the ordinary buttons stay
// underneath, because a player who speaks up as the count ends is still acting
// and the dealer is the one who decides whether it was in time.
const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
const wrap = document.createElement("div");
if (expired) {
const line = document.createElement("p");
line.className = "clockExpired";
// The ruling in one sentence. `label` is the same call in three words for
// the button beside it; saying both left the line doubling back on itself
// — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
line.textContent = `Time — ${expired.say}`;
wrap.appendChild(line);
const enforce = document.createElement("div");
enforce.className = "actionButtons";
enforce.appendChild(this.button(
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
// The ruling is what the sentence is for; the pill keeps the verb.
// "hand killed on the clock" wants 125px of the 78 the pill has and
// came out "hand killed on the cl…"; "check on the clock" wanted 99.
{ lead: `${expired.who}'s`,
act: expired.verb === "fold" ? "hand killed" : "check",
long: `${expired.who}'s ${expired.verb === "fold"
? "hand killed on the clock" : "check on the clock"}` },
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
else Betting.check(this.state, index);
})));
wrap.appendChild(enforce);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit({ lead: `${who}'s`, act: "fold" }, () => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit({ lead: `${who}'s`, act: "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({ lead: `${who}'s`, act: "all-in" }, () => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit({ lead: `${who}'s`, act: "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();
}));
}
if (!expired) return row;
wrap.appendChild(row);
return wrap;
},
// 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);
// The amount stays in the half that survives: "did I raise, and to what"
// is one question. It is the only act long enough to still need an ellipsis
// on the narrowest phone, and only past four figures — "raise to $60" is
// 55px of the 59 the label has once the arrow is drawn, "raise to $1,200" is
// 69. The whole of it is on the button's own title and aria-label either way.
const label = { lead: `${who}'s`,
act: raising ? `raise to ${Money.format(target)}` : `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({ lead: "the", act: on === 0 ? "straddle" : "re-straddle" },
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit({ lead: "the straddle", act: "called 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;
Render.bringIntoView(field, { block: "center" });
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({ lead: "everybody", act: "dealt 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(
// Both of these wanted around 100px of 78 and came out mid-phrase.
{ lead: who,
act: posting ? "waiting" : "posting",
long: `${who} ${posting ? "waiting for the blind" : "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", () => {
// The first hand is the moment this stops being a setup screen and starts
// being a night somebody could lose. It is also a tap, which is what every
// browser wants the "keep my data" request to come from — see
// requestDurable() in state.js. Harmless and instant after the first time.
if (this.hooks.keepStorage) this.hooks.keepStorage();
// No actor to drop, so no half of this can go missing: "dealing" was the
// lead, and dropping a lead that is a verb left the pill reading "↶ the
// hand" after every deal of the night.
this.commit({ act: "the deal" }, () => 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;
},
// The cards come over before anybody is paid.
//
// Same shape as the street button above: when the dealer has asked to be asked, the
// moment they press this is the moment they are holding the hands, so the sheet
// opens here rather than leaving them to find the seat editor for each player with
// the pot already on the table. Off by default, and unchanged when it's off.
toShowdown() {
if (this.state.trackBoard && typeof Render !== "undefined") {
Render.openShowdownPicker(() => this.hooks.resolvePot());
return;
}
this.hooks.resolvePot();
},
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.toShowdown();
return;
}
const street = Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase();
this.commit({ act: `the ${street}` }, () => Betting.closeStreet(this.state));
// The cards that just came off the deck. Asked for here rather than left
// for the dealer to remember, because the moment they are holding them is
// the only moment they know what they are without turning the board round.
if (this.state.trackBoard && typeof Render !== "undefined") Render.openBoardPicker();
}));
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.toShowdown()
));
wrap.appendChild(row);
return wrap;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = ActionBar;
}