Seven keys drive this tool. Five of them are printed on the control they press -- F on Fold, C on Call, R on Raise, the return arrow on Deal and on Raise to, esc on Cancel. Two were printed on nothing at all: A opens the amount pad on All-in U takes back the last action A dealer who learned F, C and R from the buttons had no way to discover the other two existed. They are the two worth knowing: A is the fastest action in the tool and U is the one that saves a misclick. Both are on their controls now. The key lookup matches any of a button's classes, so the raise pad's four quick amounts can share one class for their looks while only All-in carries a key. And a legend in the sidebar, listing all seven with what they do, built from the same list the handlers are keyed off. There is a test that every key in the map has a line in the legend and that every line names a key that really is bound -- a shortcut with no line is one nobody can find, which is the whole bug this fixes. The panel is hidden on a touch device. A phone has no keys to press, and it is the same pointer question the touch-target rules ask rather than a guess about width. One thing I broke and fixed on the way. .actionKey pins itself to the top-right corner of a big action button. The undo control is a 30px pill with overflow hidden and no positioning context, so the U escaped it and floated above the action bar -- visible in the screenshot before I looked properly. It has its own inline class now, checked at six sizes: the cap sits inside the pill and inside the bar, and the pill is not clipped. 5 tests (249 total). Driven at six sizes across touch and pointer contexts: the undo pill shows U, the raise pad shows A on All-in and nothing on Min, half pot and Pot, the legend lists all seven, and it is display:none on a phone. Contrast clean, layout audit clean but for the landscape phone already known from #63, no console errors. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
642 lines
28 KiB
JavaScript
642 lines
28 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;
|
|
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,
|
|
FELT_MIN_PX: 220,
|
|
|
|
// 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.
|
|
publishFeltRoom(barHeight) {
|
|
const wrap = document.getElementById("pokerTableWrap");
|
|
if (!wrap) return;
|
|
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
|
const room = window.innerHeight - barHeight - topInDocument -
|
|
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
|
document.documentElement.style.setProperty(
|
|
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
|
|
},
|
|
|
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
|
// still holds during setup, because a seated player is a live seat whether or not
|
|
// the betting has been opened. An empty table stays uncluttered.
|
|
isRunning() {
|
|
return Betting.liveSeats(this.state).length > 0;
|
|
},
|
|
|
|
render() {
|
|
const host = document.getElementById("actionBar");
|
|
if (!host) return;
|
|
host.innerHTML = "";
|
|
|
|
if (!this.isRunning()) {
|
|
host.hidden = true;
|
|
document.body.classList.remove("hasActionBar");
|
|
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
|
this.stopTankTimer();
|
|
this.tankSeatIndex = null;
|
|
return;
|
|
}
|
|
host.hidden = false;
|
|
document.body.classList.add("hasActionBar");
|
|
|
|
host.appendChild(this.renderStatus());
|
|
|
|
const breakdown = this.renderPotBreakdown();
|
|
if (breakdown) host.appendChild(breakdown);
|
|
|
|
if (!this.state.bettingOpen) {
|
|
host.appendChild(this.renderDealPrompt());
|
|
} else if (Betting.isHandOver(this.state)) {
|
|
host.appendChild(this.renderHandOver());
|
|
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
|
host.appendChild(this.renderStreetDone());
|
|
} else if (this.raiseOpen) {
|
|
host.appendChild(this.renderRaisePad());
|
|
} else {
|
|
host.appendChild(this.renderActions());
|
|
}
|
|
|
|
this.syncTankTimer();
|
|
},
|
|
|
|
renderStatus() {
|
|
const row = document.createElement("div");
|
|
row.className = "actionStatus";
|
|
|
|
const street = document.createElement("span");
|
|
street.className = "actionStreet";
|
|
street.textContent = Betting.streetLabel(this.state.street);
|
|
row.appendChild(street);
|
|
|
|
const pot = document.createElement("span");
|
|
pot.className = "actionPot";
|
|
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
|
|
row.appendChild(pot);
|
|
|
|
// How many are still in the hand — part of the standard per-street
|
|
// announcement, and the number a dealer is asked for most often. Before the
|
|
// deal it counts the table as it will be dealt, so a busted player waiting on a
|
|
// rebuy isn't announced as one of the players. Read-only: the sitting-out
|
|
// itself happens when the hand is dealt, not while the bar is being drawn.
|
|
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
|
|
const remaining = Betting.liveSeats(counted).length;
|
|
const players = document.createElement("span");
|
|
players.className = "actionPlayers";
|
|
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
|
row.appendChild(players);
|
|
|
|
// Nobody is being asked to act because nobody can — say so, or the bar just
|
|
// looks like it's waiting for something.
|
|
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
|
const runout = document.createElement("span");
|
|
runout.className = "actionRunout";
|
|
const stillIn = Betting.liveSeats(this.state).length;
|
|
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
|
row.appendChild(runout);
|
|
}
|
|
|
|
const index = this.state.actingSeatIndex;
|
|
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
|
const player = this.state.seats[index];
|
|
const who = document.createElement("span");
|
|
who.className = "actionWho";
|
|
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
|
row.appendChild(who);
|
|
|
|
const tank = document.createElement("span");
|
|
tank.className = "actionTank";
|
|
tank.id = "actionTank";
|
|
tank.title = "How long this player has been thinking";
|
|
tank.textContent = "0:00";
|
|
row.appendChild(tank);
|
|
}
|
|
|
|
// A standing control rather than a toast per action: a dealer taps this bar
|
|
// constantly, and a toast after every fold would be noise they'd learn to
|
|
// ignore — which is exactly when they'd miss the one that mattered.
|
|
const undoLabel = this.hooks.lastActionLabel();
|
|
if (undoLabel) {
|
|
const undo = document.createElement("button");
|
|
undo.type = "button";
|
|
undo.className = "actionUndo";
|
|
undo.title = `Undo ${undoLabel}`;
|
|
undo.textContent = `↶ ${undoLabel}`;
|
|
// U undoes, and nothing said so — this control was the only place a dealer
|
|
// could have learned it, and it was the one action bar control without its
|
|
// key printed on it.
|
|
const key = document.createElement("kbd");
|
|
// Its own class, not .actionKey: that one pins itself to the top-right
|
|
// corner of a big action button, and this control is a 30px pill with
|
|
// overflow hidden and no positioning context — the cap escaped it and
|
|
// floated above the bar. Here the key belongs inline, after the label.
|
|
key.className = "actionUndoKey";
|
|
key.textContent = this.KEY_HINTS.actionUndo;
|
|
undo.appendChild(key);
|
|
undo.addEventListener("click", () => this.hooks.undoLast());
|
|
row.appendChild(undo);
|
|
}
|
|
|
|
return row;
|
|
},
|
|
|
|
// ---- Think timer ----
|
|
//
|
|
// A player who hasn't noticed the action is on them costs more time than anything
|
|
// else a dealer does, so the bar shows how long the current player has been in the
|
|
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
|
|
// tells the dealer when it's worth a nudge.
|
|
NUDGE_AFTER_S: 30,
|
|
SLOW_AFTER_S: 60,
|
|
|
|
tankSeatIndex: null,
|
|
tankStartedAt: null,
|
|
tankInterval: null,
|
|
|
|
// 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) {
|
|
this.tankSeatIndex = index;
|
|
this.tankStartedAt = Date.now();
|
|
}
|
|
|
|
if (!this.tankInterval) {
|
|
this.tankInterval = setInterval(() => this.paintTank(), 1000);
|
|
}
|
|
this.paintTank();
|
|
},
|
|
|
|
stopTankTimer() {
|
|
if (this.tankInterval) {
|
|
clearInterval(this.tankInterval);
|
|
this.tankInterval = null;
|
|
}
|
|
},
|
|
|
|
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
|
|
// buttons under the dealer's thumb.
|
|
paintTank() {
|
|
const el = document.getElementById("actionTank");
|
|
if (!el || this.tankStartedAt === null) return;
|
|
|
|
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
|
const mins = Math.floor(seconds / 60);
|
|
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
|
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
|
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
|
},
|
|
|
|
// Shown only once someone is all-in and the money has actually split — a single
|
|
// undivided pot needs no announcement, and a line that's always there stops being
|
|
// read.
|
|
renderPotBreakdown() {
|
|
// Only worth showing once someone is actually all-in. Mid-street the amounts in
|
|
// front of players are unequal all the time — the blinds alone are unequal —
|
|
// and layering that would announce a side pot on every hand before anyone had
|
|
// even acted.
|
|
const someoneAllIn = this.state.seats.some(
|
|
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
|
|
);
|
|
if (!someoneAllIn) return null;
|
|
|
|
const contributions = this.state.seats.map((player, index) => ({
|
|
id: player.id,
|
|
index,
|
|
contribution: Betting.handTotal(player),
|
|
folded: player.folded,
|
|
}));
|
|
|
|
const nameById = new Map(
|
|
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
|
);
|
|
|
|
return Render.buildPotBreakdown(
|
|
PotCalculator.layers(contributions),
|
|
PotCalculator.uncalledReturns(contributions),
|
|
id => nameById.get(id)
|
|
);
|
|
},
|
|
|
|
// Which key presses each button. Shown on the button itself and hidden on touch
|
|
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
|
// on a phone is just clutter.
|
|
KEY_HINTS: {
|
|
actionFold: "F",
|
|
actionCheck: "C",
|
|
actionCall: "C",
|
|
actionRaise: "R",
|
|
actionAdvance: "↵",
|
|
raiseConfirm: "↵",
|
|
raiseCancel: "esc",
|
|
raiseAllIn: "A",
|
|
actionUndo: "U",
|
|
},
|
|
|
|
button(label, className, onClick, sublabel) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `actionBtn ${className}`;
|
|
|
|
const main = document.createElement("span");
|
|
main.className = "actionBtnLabel";
|
|
main.textContent = label;
|
|
btn.appendChild(main);
|
|
|
|
// Looked up by any of the button's classes, so a button can carry a shared
|
|
// class for its looks and a specific one for its key — the raise pad's four
|
|
// quick amounts are all raiseQuickBtn, but only All-in has a key.
|
|
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
|
|
if (hint) {
|
|
const kbd = document.createElement("kbd");
|
|
kbd.className = "actionKey";
|
|
kbd.textContent = hint;
|
|
btn.appendChild(kbd);
|
|
}
|
|
|
|
if (sublabel) {
|
|
const sub = document.createElement("span");
|
|
sub.className = "actionBtnSub";
|
|
sub.textContent = sublabel;
|
|
btn.appendChild(sub);
|
|
}
|
|
|
|
btn.addEventListener("click", onClick);
|
|
return btn;
|
|
},
|
|
|
|
// Every action taken from the bar goes through here, which is also where it gets
|
|
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
|
// tool a dealer can't talk their way out of.
|
|
commit(label, mutate) {
|
|
this.hooks.beginAction(label);
|
|
mutate();
|
|
this.hooks.save(this.state);
|
|
this.hooks.rerender();
|
|
},
|
|
|
|
renderActions() {
|
|
const index = this.state.actingSeatIndex;
|
|
const player = this.state.seats[index];
|
|
const owed = Betting.toCall(this.state, player);
|
|
const stack = Betting.stackOf(this.state, player);
|
|
const who = HoldemState.displayName(player, index);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
|
|
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
|
|
|
|
if (owed <= 0) {
|
|
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
|
|
} else if (owed >= stack) {
|
|
// Calling would put them all-in, so say that rather than quoting a number
|
|
// they can't cover.
|
|
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
|
} else {
|
|
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
|
|
}
|
|
|
|
if (stack > owed) {
|
|
// Not "does this player owe anything" — the big blind owes nothing and is
|
|
// still raising. It's whether there is a live bet to raise.
|
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
|
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
|
|
this.raiseOpen = true;
|
|
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
|
this.render();
|
|
}));
|
|
}
|
|
|
|
return row;
|
|
},
|
|
|
|
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
|
// button, and only reaches for digits when the table does something odd.
|
|
renderRaisePad() {
|
|
// A toast is transient news about the action just taken; the raise pad is the
|
|
// dealer deciding the next one. On a short screen they compete for the same
|
|
// strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
|
|
// which floats just above the bar — gets pushed up over the table, covering
|
|
// seats outright. Rather than squeeze both in, the older one gets out of the
|
|
// way. Nothing is lost: the toast's Undo is also the bar's own undo control.
|
|
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
|
|
|
const index = this.state.actingSeatIndex;
|
|
const player = this.state.seats[index];
|
|
const stack = Betting.stackOf(this.state, player);
|
|
const maxTo = Betting.round(Betting.committed(player) + stack);
|
|
const pot = Betting.potSoFar(this.state);
|
|
// 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";
|
|
|
|
const options = [
|
|
{ label: "Min", value: minTo },
|
|
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
|
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
|
{ 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.
|
|
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;
|
|
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.`
|
|
: "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);
|
|
return wrap;
|
|
}
|
|
|
|
line.textContent =
|
|
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
|
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
|
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
|
wrap.appendChild(line);
|
|
|
|
// 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);
|
|
}
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
|
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
|
}));
|
|
wrap.appendChild(row);
|
|
|
|
return wrap;
|
|
},
|
|
|
|
renderStreetDone() {
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
|
|
const isRiver = this.state.street === "river";
|
|
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
|
|
|
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));
|
|
}));
|
|
|
|
return row;
|
|
},
|
|
|
|
renderHandOver() {
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
const live = Betting.liveSeats(this.state);
|
|
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
|
|
|
row.appendChild(this.button(
|
|
winner ? `Award pot to ${winner}` : "Resolve pot",
|
|
"actionAdvance",
|
|
() => this.hooks.resolvePot()
|
|
));
|
|
return row;
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = ActionBar;
|
|
}
|