The bar has had a stopwatch on the acting seat for a while: how long this player has been thinking, so the dealer knows when it's worth a nudge. What it never had was the thing a dealer actually does about it. Calling the clock is a procedure — a player asks, the dealer grants it, the player gets a set time and then a countdown said out loud, and if they still haven't acted the hand is decided for them. Running that from this tool meant counting in your head while the table argued about whether it had been thirty seconds yet. Thirty and then ten, which is the common form and what the TDA writes down. The countdown is the part the table hears, so it is marked apart rather than being ten more seconds of the same: the readout switches to a bare number and turns red. At zero the bar says the ruling in the dealer's own words and offers it as one button. The ruling depends on what they were facing, and getting that backwards is the part worth being careful about. Facing a bet, the hand is dead. Facing nothing, it is checked — nobody gets folded for being slow when checking was free. Three things about where it sits. The button appears only once somebody has been thinking long enough for the table to start asking, which is the same threshold the stopwatch already marks, so an ordinary hand costs no room at all: measured at 390x844, the bar is 152px with or without it. It is 44px like everything else a thumb has to find — it was drawn at 36 and the audit called it at every touch viewport the moment the check ran, which is the rule doing its job on a control that kills a hand. And once the count is over the Stop button goes, because there is nothing left to stop: at 640x360 its 94px pushed the undo control onto a second line and the taller bar covered the two bottom seats. The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up as the count ends is still acting, and whether it was in time is the dealer's call, not the tool's. 11 new tests, and both halves of the ruling were inverted on purpose to check they catch it. Watched end to end in the browser at shortened intervals: the button appears, the count runs down, the ruling lands, and the hand is killed — or checked — with the undo control naming what happened. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1064 lines
51 KiB
JavaScript
1064 lines
51 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`);
|
|
// 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.updateSeatDensity) Render.updateSeatDensity();
|
|
},
|
|
|
|
// 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);
|
|
|
|
// 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());
|
|
}
|
|
row.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}`;
|
|
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,
|
|
|
|
// ---- 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;
|
|
}
|
|
},
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// The button that starts a clock is offered once somebody has been thinking
|
|
// long enough for the table to start asking — which is the same threshold the
|
|
// stopwatch already marks. Before that it is a control nobody needs, and this
|
|
// bar is docked over the seats.
|
|
const nudge = document.getElementById("actionClockBtn");
|
|
if (nudge && !this.clockRunning()) {
|
|
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
|
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
|
|
? false
|
|
: waited < this.NUDGE_AFTER_S;
|
|
}
|
|
|
|
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);
|
|
|
|
// 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(
|
|
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s 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(`${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();
|
|
}));
|
|
}
|
|
|
|
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);
|
|
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;
|
|
}
|