Add the clock round to the release
This commit is contained in:
@@ -391,6 +391,23 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<section class="ruling" data-short="Straddle">
|
||||||
<h3>A straddle is a blind raise</h3>
|
<h3>A straddle is a blind raise</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -2254,6 +2254,46 @@ body.hasActionBar #holdemSidebar {
|
|||||||
font-weight: 600;
|
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 ---------- */
|
/* ---------- Side-pot breakdown ---------- */
|
||||||
|
|
||||||
.potBreakdown {
|
.potBreakdown {
|
||||||
@@ -3431,6 +3471,14 @@ body.hasActionBar #toastHost {
|
|||||||
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
|
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. */
|
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
|
||||||
.seat {
|
.seat {
|
||||||
width: 62px;
|
width: 62px;
|
||||||
|
|||||||
@@ -194,6 +194,32 @@ const ActionBar = {
|
|||||||
tank.title = "How long this player has been thinking";
|
tank.title = "How long this player has been thinking";
|
||||||
tank.textContent = "0:00";
|
tank.textContent = "0:00";
|
||||||
row.appendChild(tank);
|
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
|
// A standing control rather than a toast per action: a dealer taps this bar
|
||||||
@@ -233,6 +259,74 @@ const ActionBar = {
|
|||||||
NUDGE_AFTER_S: 30,
|
NUDGE_AFTER_S: 30,
|
||||||
SLOW_AFTER_S: 60,
|
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,
|
tankSeatIndex: null,
|
||||||
tankStartedAt: null,
|
tankStartedAt: null,
|
||||||
tankInterval: null,
|
tankInterval: null,
|
||||||
@@ -262,6 +356,11 @@ const ActionBar = {
|
|||||||
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
||||||
this.tankSeatIndex = index;
|
this.tankSeatIndex = index;
|
||||||
this.tankStartedAt = Date.now();
|
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) {
|
if (!this.tankInterval) {
|
||||||
@@ -283,6 +382,36 @@ const ActionBar = {
|
|||||||
const el = document.getElementById("actionTank");
|
const el = document.getElementById("actionTank");
|
||||||
if (!el || this.tankStartedAt === null) return;
|
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) {
|
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
|
||||||
el.textContent = "—";
|
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.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 stack = Betting.stackOf(this.state, player);
|
||||||
const who = HoldemState.displayName(player, index);
|
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");
|
const row = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
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
|
// 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