Run the clock on a player, instead of counting in your head
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
This commit is contained in:
@@ -391,6 +391,23 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="Clock">
|
||||
<h3>Calling the clock on a player</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
If the count reaches zero with no action, the hand is <em>dead</em> if
|
||||
they were facing a bet, and <em>checked</em> if they were not: nobody is
|
||||
folded for slowness when checking was free. The action bar runs the
|
||||
count once you tap <em>Call the clock</em>, and offers the ruling as one
|
||||
button when it ends.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="Straddle">
|
||||
<h3>A straddle is a blind raise</h3>
|
||||
<p>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user