diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 558d334..5db9c99 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -391,6 +391,23 @@
++ Any player may ask for a clock once a decision is taking unreasonably + long; it is the dealer, not the table, who grants it. The player then + gets a set time and a countdown out loud — thirty seconds and ten is the + usual form. +
++ If the count reaches zero with no action, the hand is dead if + they were facing a bet, and checked if they were not: nobody is + folded for slowness when checking was free. The action bar runs the + count once you tap Call the clock, and offers the ruling as one + button when it ends. +
+diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index b2f67c2..0f0df21 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2254,6 +2254,46 @@ body.hasActionBar #holdemSidebar { font-weight: 600; } +/* Starting and stopping a clock. Quiet, because it only appears once somebody has + been thinking long enough for the table to ask. + + 44px like everything else a thumb has to find. It was drawn at 36 to keep the status + line short, and the audit called it at every touch viewport the moment it ran — which + is the rule doing its job: this control kills a hand, and the argument for shaving it + was that the row it sits in is made of text. */ +.actionClockBtn { + flex: none; + min-height: 44px; + padding: 0.25rem 0.6rem; + border-radius: 8px; + border: 1px solid #5b4a2a; + background: rgba(216, 180, 106, 0.12); + color: #e8cf9a; + font-family: inherit; + font-size: 0.72rem; + cursor: pointer; +} + +/* The count has run out. Said in the dealer's own words, above the button that + carries it out — and coloured like the thing it is, which is a hand about to be + killed for slow play. */ +.clockExpired { + margin: 0 0 0.5rem; + padding: 0.45rem 0.6rem; + border-radius: 8px; + background: rgba(224, 123, 123, 0.12); + border: 1px solid rgba(224, 123, 123, 0.45); + color: #f3c9c9; + font-size: 0.8rem; +} + +.actionBtn.actionEnforceClock { + background: linear-gradient(180deg, #6b2b2b, #4a1d1d); + border-color: #8c3a3a; + color: #ffe2e2; + font-weight: 600; +} + /* ---------- Side-pot breakdown ---------- */ .potBreakdown { @@ -3431,6 +3471,14 @@ body.hasActionBar #toastHost { bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px)); } + /* The clock's ruling, said in a sentence, is a line this screen cannot pay for: + measured at 640x360, the bar goes from 152px to 269px when the count runs out and + the bottom seats end up behind it. The button carries the same ruling in three + words — "Kill Dee's hand" — so the sentence is what goes, not the call. */ + .clockExpired { + display: none; + } + /* Nine seats around a table this small overlap each other, so they shrink to suit. */ .seat { width: 62px; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 93c57d7..b0bc889 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -194,6 +194,32 @@ const ActionBar = { 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 @@ -233,6 +259,74 @@ const ActionBar = { 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, @@ -262,6 +356,11 @@ const ActionBar = { 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) { @@ -283,6 +382,36 @@ const ActionBar = { 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."; @@ -405,6 +534,36 @@ const ActionBar = { 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"; @@ -431,7 +590,9 @@ const ActionBar = { })); } - return row; + if (!expired) return row; + wrap.appendChild(row); + return wrap; }, // Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a diff --git a/tests/holdem/callingTheClock.test.js b/tests/holdem/callingTheClock.test.js new file mode 100644 index 0000000..8e34fbe --- /dev/null +++ b/tests/holdem/callingTheClock.test.js @@ -0,0 +1,129 @@ +// Calling the clock on a player. +// +// The bar already had a stopwatch saying how long somebody had been thinking. This is +// what a dealer does about it: a procedure with a shape — a set time, a countdown said +// out loud, and a ruling at the end that depends on what the player was facing. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { ActionBar, Betting, makeTable } = require("./load"); + +// The bar rebuilds the DOM when a clock starts; none of the rules below need it. +const onTable = (state) => { + ActionBar.state = state; + ActionBar.render = () => {}; + ActionBar.stopClock(); + return ActionBar; +}; + +const dealt = () => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 }); + Betting.beginHand(state); + return state; +}; + +describe("starting one", () => { + test("it runs on the player who is actually on the clock", () => { + const state = dealt(); + const bar = onTable(state); + assert.equal(bar.clockRunning(), false, "nothing runs until it is called"); + bar.callClock(); + assert.equal(bar.clockRunning(), true); + assert.equal(bar.clockSeatIndex, state.actingSeatIndex); + }); + + test("with nobody to act there is nothing to call", () => { + const state = dealt(); + state.actingSeatIndex = null; + const bar = onTable(state); + bar.callClock(); + assert.equal(bar.clockRunning(), false); + assert.equal(bar.clockLeft(), null); + }); + + test("the dealer can put it away again", () => { + const bar = onTable(dealt()); + bar.callClock(); + bar.stopClock(); + assert.equal(bar.clockRunning(), false); + assert.equal(bar.clockLeft(), null); + }); +}); + +describe("the count", () => { + const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000); + + test("is the player's own time and then a countdown", () => { + const bar = onTable(dealt()); + bar.callClock(); + assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S); + assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts"); + assert.equal(at(bar, 35), 5); + }); + + test("stops at zero rather than going negative", () => { + const bar = onTable(dealt()); + bar.callClock(); + assert.equal(at(bar, 40), 0); + assert.equal(at(bar, 600), 0, "ten minutes later it is still just up"); + }); +}); + +describe("what happens at zero", () => { + test("facing a bet, the hand is dead", () => { + const state = dealt(); + const bar = onTable(state); + // Whoever is up pre-flop is facing the big blind. + const ruling = bar.clockRuling(); + assert.equal(ruling.verb, "fold"); + assert.match(ruling.label, /hand is dead/); + assert.match(ruling.say, /facing \$2/); + }); + + test("facing nothing, it is checked — slowness doesn't fold a free hand", () => { + const state = dealt(); + // Everyone calls round to the big blind, who has nothing to call. + [3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat)); + const bar = onTable(state); + assert.equal(state.actingSeatIndex, 2, "the big blind is up"); + const ruling = bar.clockRuling(); + assert.equal(ruling.verb, "check"); + assert.match(ruling.label, /check for/i); + }); + + test("the ruling names the player, because the dealer has to say it out loud", () => { + const state = dealt(); + state.seats[state.actingSeatIndex].name = "Priya"; + const ruling = onTable(state).clockRuling(); + assert.match(ruling.label, /Priya/); + assert.match(ruling.say, /Priya/); + }); + + test("with nobody to act there is no ruling to make", () => { + const state = dealt(); + state.actingSeatIndex = null; + assert.equal(onTable(state).clockRuling(), null); + }); +}); + +describe("what ends it", () => { + test("the player acting", () => { + const state = dealt(); + const bar = onTable(state); + bar.callClock(); + const wasOn = state.actingSeatIndex; + Betting.call(state, wasOn); + assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved"); + assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's"); + }); + + test("and it doesn't follow the action to the next player", () => { + // A clock is called on one decision. Left running it would put a countdown on + // somebody nobody asked about. + const state = dealt(); + const bar = onTable(state); + bar.callClock(); + Betting.call(state, state.actingSeatIndex); + assert.equal(bar.clockLeft(), null); + }); +});