// 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`); }; if (typeof ResizeObserver === "function") { this.heightWatcher = new ResizeObserver(publish); this.heightWatcher.observe(host); } window.addEventListener("resize", publish); window.addEventListener("orientationchange", publish); publish(); }, // Anything to act on? A hand with nobody dealt in doesn't get a bar. 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 (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. const remaining = Betting.liveSeats(this.state).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}`; 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", }, 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); const hint = this.KEY_HINTS[className]; 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) { row.appendChild(this.button(owed > 0 ? "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() { 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); const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo); 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", () => { 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", "Raise to amount"); input.addEventListener("input", () => { this.raiseDraft = input.value.replace(/[^0-9.]/g, ""); }); entry.appendChild(input); entry.appendChild(this.button("Cancel", "raiseCancel", () => { this.raiseOpen = false; this.render(); })); entry.appendChild(this.button("Raise to", "raiseConfirm", () => { const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo)); this.raiseOpen = false; const who = HoldemState.displayName(player, index); this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target)); })); wrap.appendChild(entry); 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; }