Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86886ac126 | ||
|
|
f966d8c340 | ||
|
|
0e2821257f |
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
@@ -162,6 +163,10 @@
|
||||
<span>House take</span>
|
||||
<span id="bankRaked"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
|
||||
<span>Paid out</span>
|
||||
<span id="bankPaidOut"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +396,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,64 @@ 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;
|
||||
}
|
||||
|
||||
/* A player who has taken their chips and gone. Still on the list, because the box took
|
||||
their cash and the night's record has to say so, but set apart so nobody counts money
|
||||
out to them twice at 2am.
|
||||
|
||||
Set apart by a stripe rather than by fading the row, which is what this first did:
|
||||
at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
|
||||
list a dealer settles the night from. The row says "paid out" in words next to the
|
||||
name — it doesn't need to be hard to read as well. */
|
||||
.standingsRow.standingsGone {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 2px solid #4a4a4a;
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
|
||||
.standingsRow.standingsGone .standingsName {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Side-pot breakdown ---------- */
|
||||
|
||||
.potBreakdown {
|
||||
@@ -3431,6 +3489,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
|
||||
|
||||
@@ -459,6 +459,55 @@ const Betting = {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
|
||||
// of the first two cards exposed — and the tool had no way to do it. The only
|
||||
// control that ended a live hand was Next Hand, which says out loud that it takes
|
||||
// the money off the table without paying anybody. That is right for an abandoned
|
||||
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
|
||||
// who put them in.
|
||||
//
|
||||
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
|
||||
// while the action history is still there. It lives in memory, so a dealer who
|
||||
// refreshes the page mid-hand, or who is three calls in, has nothing.
|
||||
//
|
||||
// What is deliberately kept: the button, the blinds, the straddle and anyone
|
||||
// posting in. A misdeal is the *same* hand dealt again, not the next one.
|
||||
takeHandBack(state) {
|
||||
const returned = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
if (back > 0) {
|
||||
player.stack = this.round(this.stackOf(state, player) + back);
|
||||
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
|
||||
}
|
||||
player.committed = 0;
|
||||
player.amountOverride = null;
|
||||
player.hasActed = false;
|
||||
player.folded = false;
|
||||
player.cards = ["", ""];
|
||||
});
|
||||
|
||||
state.bettingOpen = false;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||
// would have counted as is being dealt again from the same button.
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
|
||||
return {
|
||||
returned,
|
||||
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Pulls the blinds back off the table and posts them again from wherever the
|
||||
// button and the blind assignment now say they belong. Refunding first is what
|
||||
// keeps the chip count straight when the button gets passed round three or four
|
||||
|
||||
@@ -54,7 +54,10 @@ const HandHistoryExport = {
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
// Somebody who has gone home is marked in the file the same way the
|
||||
// panel marks them, because this is the copy that gets read at the
|
||||
// end of the night when nobody remembers who has already been paid.
|
||||
row.cashedOut ? `${row.name} (paid out)` : row.name,
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
|
||||
@@ -96,6 +96,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
},
|
||||
|
||||
// Somebody is done for the night. The dealer counts their chips out, the chair
|
||||
// opens, and both halves of what happened stay on the record — the cash the box
|
||||
// took from them, and the chips it handed back. Wiping the seat by hand did
|
||||
// neither: it left the bank check declaring their whole stack missing for the
|
||||
// rest of the night.
|
||||
cashOut: async index => {
|
||||
const allowed = HoldemState.canCashOut(state, index);
|
||||
if (!allowed.ok) {
|
||||
Toast.show(allowed.reason, { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = HoldemState.cashOutPreview(state, index);
|
||||
const result = preview.net === 0
|
||||
? "even for the night"
|
||||
: preview.net > 0
|
||||
? `up ${Money.format(preview.net)}`
|
||||
: `down ${Money.format(Math.abs(preview.net))}`;
|
||||
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Cash out ${preview.name}?`,
|
||||
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
||||
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
||||
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
||||
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
||||
() => HoldemState.cashOut(state, index));
|
||||
},
|
||||
|
||||
// Two players change chairs. Undoable like every other seat action, because a
|
||||
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||
swapSeats: (from, to) => {
|
||||
@@ -368,6 +401,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
||||
// same button deals the same hand again.
|
||||
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
||||
const inPlay = HoldemState.unresolvedContributions(state);
|
||||
const acted = state.seats.filter(p => p.hasActed).length;
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Misdeal — take the hand back?",
|
||||
message: inPlay > Betting.EPSILON_CHIPS
|
||||
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
||||
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
||||
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
||||
confirmLabel: "Take it back",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// The label is both the toast a dealer reads now and the words on the undo
|
||||
// control afterwards, so it says what happened to the money rather than naming
|
||||
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
||||
const label = inPlay > Betting.EPSILON_CHIPS
|
||||
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
||||
: "Misdeal — nothing was in yet, same button deals again.";
|
||||
runUndoable(label, () => Betting.takeHandBack(state));
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||
|
||||
@@ -33,6 +33,7 @@ const Render = {
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderHeaderActions();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
@@ -62,6 +63,16 @@ const Render = {
|
||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||
},
|
||||
|
||||
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
||||
// is nothing to take back, and afterwards the header would be offering to unwind a
|
||||
// hand that has already been paid — so the button comes and goes with the hand
|
||||
// rather than sitting there all night waiting to be pressed by accident.
|
||||
renderHeaderActions() {
|
||||
const misdeal = document.getElementById("misdealBtn");
|
||||
if (!misdeal) return;
|
||||
misdeal.hidden = !this.state.bettingOpen;
|
||||
},
|
||||
|
||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||
// generated from a touch screen, so on a phone the last clause of the hint below
|
||||
// described something impossible — on the device the rest of the sentence was
|
||||
@@ -1012,7 +1023,14 @@ const Render = {
|
||||
const template = document.getElementById("standingsRowTemplate");
|
||||
standings.forEach(row => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".standingsName").textContent = row.name;
|
||||
// Somebody who has gone home is still on the list — they played, and the
|
||||
// box took their cash — but the number beside them is money already handed
|
||||
// over, not chips waiting to be counted. Marked, or a dealer settling up at
|
||||
// 2am pays them twice.
|
||||
node.classList.toggle("standingsGone", !!row.cashedOut);
|
||||
node.querySelector(".standingsName").textContent = row.cashedOut
|
||||
? `${row.name} · paid out`
|
||||
: row.name;
|
||||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||||
|
||||
@@ -1169,11 +1187,23 @@ const Render = {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips handed back to people who have gone home. The third way money leaves
|
||||
// this table on purpose, and the one a dealer is asked about most: "where did
|
||||
// Ann's four hundred go?"
|
||||
const paidRow = document.getElementById("bankPaidOutRow");
|
||||
if (paidRow) {
|
||||
paidRow.hidden = !(bank.paidOut > 0);
|
||||
if (bank.paidOut > 0) {
|
||||
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
// What has left the table on purpose, whichever way it went.
|
||||
const accountedFor = [
|
||||
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
|
||||
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
|
||||
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
|
||||
].filter(Boolean);
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = accountedFor.length > 0
|
||||
|
||||
@@ -47,6 +47,13 @@ const SeatMenu = {
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||
// that has somebody in it, since an empty chair has nobody to pay.
|
||||
if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
|
||||
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||
}
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ const HoldemState = {
|
||||
// What it has taken so far tonight. A session total, not a per-hand one:
|
||||
// it is the number the box is checked against at the end.
|
||||
rakeTaken: 0,
|
||||
// People who have taken their chips and gone. Kept for the night rather
|
||||
// than forgotten with the seat: the box took their cash and paid it back
|
||||
// out, and both halves have to stay on the record or the bank check reads
|
||||
// as a shortfall for the rest of the evening.
|
||||
cashedOut: [],
|
||||
// Which seats actually put a straddle out when the hand was dealt, so the
|
||||
// felt can badge them. Read off the deal rather than worked out again
|
||||
// later, the same way the posted blinds are.
|
||||
@@ -228,6 +233,17 @@ const HoldemState = {
|
||||
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
||||
};
|
||||
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
||||
parsed.cashedOut = Array.isArray(parsed.cashedOut)
|
||||
? parsed.cashedOut
|
||||
.filter(row => row && row.id)
|
||||
.map(row => ({
|
||||
id: row.id,
|
||||
name: String(row.name || "").slice(0, 40),
|
||||
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
|
||||
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
|
||||
at: Number(row.at) || 0,
|
||||
}))
|
||||
: [];
|
||||
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
@@ -753,6 +769,15 @@ const HoldemState = {
|
||||
// Cash bought in and chips currently in front don't come from the hand log at
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
// And for anyone who has gone home, off the record their cash-out left behind.
|
||||
// Without this they read as a player who bought in for nothing and is holding
|
||||
// nothing, in the very list the dealer settles the night from.
|
||||
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
|
||||
|
||||
goneById.forEach((row, id) => {
|
||||
if (totals.has(id)) return;
|
||||
totals.set(id, { id, name: row.name, in: 0, won: 0 });
|
||||
});
|
||||
|
||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
@@ -774,8 +799,16 @@ const HoldemState = {
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||
const gone = goneById.get(t.id);
|
||||
const boughtIn = seat
|
||||
? Money.round(Number(seat.buyIns) || 0)
|
||||
: (gone ? gone.buyIns : 0);
|
||||
// What a departed player is "holding" is what the box handed them on
|
||||
// the way out. It is no longer on the table, so it is marked as paid
|
||||
// rather than counted again at the end of the night.
|
||||
const chipsNow = seat
|
||||
? Money.round(Number(seat.stack) || 0)
|
||||
: (gone ? gone.chips : 0);
|
||||
// Chips already pushed into the hand in progress are still this
|
||||
// player's money until it's won. Leaving them out makes the cash
|
||||
// position disagree with the hand log for as long as a hand is open —
|
||||
@@ -806,6 +839,10 @@ const HoldemState = {
|
||||
// a $340 stack read as "$140" at the exact moment cash changed
|
||||
// hands.
|
||||
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
||||
// Set once they have taken their chips and left, so the panel and
|
||||
// the export can say so rather than showing a player still sitting
|
||||
// there with a stack.
|
||||
cashedOut: !!gone && !seat,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
@@ -838,6 +875,84 @@ const HoldemState = {
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
// ---- Somebody leaving with their chips ----
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool
|
||||
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
|
||||
// hand, and then the bank check — which adds up cash taken against chips on the
|
||||
// table — declared the departing player's whole stack missing: "someone has paid
|
||||
// for chips they haven't been given", for the rest of the night. Wiping their
|
||||
// buy-in as well silenced it, at the price of losing their night from the standings
|
||||
// and the file the dealer hands over.
|
||||
//
|
||||
// Cashing out is two facts, and both have to survive the empty chair: the box took
|
||||
// their cash, and the box paid their chips back out.
|
||||
|
||||
// Can this seat be cashed out right now?
|
||||
canCashOut(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return { ok: false, reason: "There's nobody in that seat." };
|
||||
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
|
||||
if (inPot > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
// Paying someone out with money still in the middle would either take
|
||||
// chips out of a pot other people can win, or hand them chips twice.
|
||||
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// What the dealer counts out, and what the seat's night comes to.
|
||||
cashOutPreview(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return null;
|
||||
// Betting owns the answer to "what is this seat holding" — including the rule
|
||||
// that a seat which has never been given a stack is holding the table's
|
||||
// starting one. Asking it rather than working it out again here: the first
|
||||
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
|
||||
// to pay a player nothing.
|
||||
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
|
||||
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips,
|
||||
boughtIn,
|
||||
// Their result for the night, in the direction a banker says it: positive
|
||||
// means the box is paying out more than it took from them.
|
||||
net: Money.round(chips - boughtIn),
|
||||
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
|
||||
};
|
||||
},
|
||||
|
||||
// Hand them their chips and free the chair, keeping both halves on the record.
|
||||
cashOut(state, index) {
|
||||
const allowed = this.canCashOut(state, index);
|
||||
if (!allowed.ok) return allowed;
|
||||
|
||||
const preview = this.cashOutPreview(state, index);
|
||||
const leavingId = state.seats[index].id;
|
||||
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
|
||||
state.cashedOut.push({
|
||||
id: leavingId,
|
||||
name: preview.name,
|
||||
buyIns: preview.boughtIn,
|
||||
chips: preview.chips,
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
// The chair opens for the next player. A fresh id, because the person who had
|
||||
// it has gone and their night is now filed under the old one — reusing it would
|
||||
// merge two people's results the moment somebody new sits down.
|
||||
const seat = this.makePlayer();
|
||||
state.seats[index] = seat;
|
||||
// Anyone owed a hand by this seat is no longer owed one.
|
||||
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
|
||||
return { ok: true, ...preview };
|
||||
},
|
||||
|
||||
// ---- The house's cut ----
|
||||
//
|
||||
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
||||
@@ -900,7 +1015,12 @@ const HoldemState = {
|
||||
},
|
||||
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
||||
// Cash the box took from people who have since left counts as taken: it is in
|
||||
// the box, and forgetting it with the seat is what used to make a cash-out read
|
||||
// as a shortfall all night.
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
|
||||
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
@@ -922,12 +1042,17 @@ const HoldemState = {
|
||||
// for a better reason: the box is supposed to have them.
|
||||
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
|
||||
// Chips handed back over the rail. Off the table like the other two, and the
|
||||
// one the dealer can point at when somebody asks where the money went.
|
||||
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
raked,
|
||||
paidOut,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Somebody leaving with their chips.
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||||
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||||
// check — cash taken against chips on the table — declared the departing player's whole
|
||||
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||||
// price of losing their night from the standings and the file the dealer hands over.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("counting them out", () => {
|
||||
test("the preview is what the dealer's hands follow", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
const preview = HoldemState.cashOutPreview(state, 0);
|
||||
assert.equal(preview.name, "Ann");
|
||||
assert.equal(preview.chips, 350, "what they are holding");
|
||||
assert.equal(preview.boughtIn, 200);
|
||||
assert.equal(preview.net, 150, "up for the night");
|
||||
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||||
});
|
||||
|
||||
test("a player who is down reads as down, not as a smaller stack", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 60;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||||
});
|
||||
|
||||
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||||
const state = table();
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("paying somebody with money still in the middle", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||||
const refusal = HoldemState.canCashOut(state, bb);
|
||||
assert.equal(refusal.ok, false);
|
||||
assert.match(refusal.reason, /in the pot/);
|
||||
// And the refusal is the whole story: nothing moved.
|
||||
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||||
assert.equal((state.cashedOut || []).length, 0);
|
||||
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||||
});
|
||||
|
||||
test("an empty chair, which has nobody to pay", () => {
|
||||
const state = table();
|
||||
const refusal = HoldemState.canCashOut(state, 99);
|
||||
assert.equal(refusal.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chair afterwards", () => {
|
||||
test("is empty and ready for the next player", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
const seat = state.seats[0];
|
||||
assert.equal(seat.name, "");
|
||||
assert.equal(seat.stack, null);
|
||||
assert.equal(seat.buyIns, 0);
|
||||
assert.equal(seat.inHand, false);
|
||||
});
|
||||
|
||||
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||||
const state = table();
|
||||
const wasId = state.seats[0].id;
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.notEqual(state.seats[0].id, wasId);
|
||||
});
|
||||
|
||||
test("and isn't owed the blind the person before them was posting", () => {
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check", () => {
|
||||
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||||
assert.equal(bank.chipsInPlay, 450);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // so the table starts out balanced
|
||||
// What a dealer had to do before: empty the chair themselves.
|
||||
state.seats[0].name = "";
|
||||
state.seats[0].stack = null;
|
||||
state.seats[0].inHand = false;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||||
});
|
||||
|
||||
test("chips that really are missing still show as missing", () => {
|
||||
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // balanced to start with
|
||||
HoldemState.cashOut(state, 0);
|
||||
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a rake and a cash-out are counted apart", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.rakeTaken = 12;
|
||||
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.raked, 12);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of their night", () => {
|
||||
test("they stay in the standings with what they bought and what they left with", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.cashOut(state, 0);
|
||||
|
||||
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||||
assert.ok(ann, "still on the list");
|
||||
assert.equal(ann.boughtIn, 200);
|
||||
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||||
assert.equal(ann.cashPosition, 150);
|
||||
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||||
});
|
||||
|
||||
test("somebody still sitting there is not marked", () => {
|
||||
const state = table();
|
||||
HoldemState.cashOut(state, 0);
|
||||
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||||
assert.equal(ben.cashedOut, false);
|
||||
});
|
||||
|
||||
test("two people leaving are both kept", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.equal(state.cashedOut.length, 2);
|
||||
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table saved before any of this existed has nobody cashed out", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.cashedOut;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.cashedOut, []);
|
||||
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.cashedOut = [
|
||||
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||||
{ name: "no id" },
|
||||
null,
|
||||
];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.cashedOut.length, 1);
|
||||
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||||
assert.equal(migrated.cashedOut[0].chips, 350);
|
||||
assert.equal(migrated.cashedOut[0].at, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table carries on", () => {
|
||||
test("the next hand deals round the empty chair", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
|
||||
test("and the money still adds up once it has", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking a hand back.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
|
||||
// the first two cards exposed — and the tool had no way to do it. The only control that
|
||||
// ended a live hand was Next Hand, which says out loud that it takes the money off the
|
||||
// table without paying anybody. That is right for an abandoned pot and wrong for a
|
||||
// misdeal, where the blinds and the calls belong to the people who put them in.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const dealt = (extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the money", () => {
|
||||
test("goes back to the people who put it in", () => {
|
||||
const state = dealt();
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("including calls and raises, not just the blinds", () => {
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
|
||||
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 95, "blinds and both wagers");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
});
|
||||
|
||||
test("and across streets, once chips have been swept into hand totals", () => {
|
||||
const state = dealt();
|
||||
[3, 4, 5, 0, 1, 2].forEach(seat => {
|
||||
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
});
|
||||
Betting.closeStreet(state);
|
||||
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
|
||||
});
|
||||
|
||||
test("the table has exactly the chips it started with", () => {
|
||||
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
const total = chipsOnTable(fresh);
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
});
|
||||
|
||||
test("nothing is thrown away — the discard pile doesn't move", () => {
|
||||
const state = dealt();
|
||||
state.discarded = 8;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
|
||||
});
|
||||
|
||||
test("it says what went back and to whom", () => {
|
||||
const state = dealt();
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.returned.length, 2, "the two blinds");
|
||||
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
|
||||
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table afterwards", () => {
|
||||
test("is waiting to deal the same hand again", () => {
|
||||
const state = dealt();
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.bettingOpen, false, "the dealer says when");
|
||||
assert.equal(state.street, "preflop");
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
assert.equal(state.currentBet, 0);
|
||||
});
|
||||
|
||||
test("keeps the button where it was — a misdeal is not the next hand", () => {
|
||||
const state = dealt({ dealerSeatIndex: 3 });
|
||||
const handNumber = state.handNumber;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.dealerSeatIndex, 3);
|
||||
assert.equal(state.handNumber, handNumber, "no hand has been played");
|
||||
assert.equal((state.handHistory || []).length, 0, "and none is logged");
|
||||
});
|
||||
|
||||
test("keeps the straddle, which was announced and still stands", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
state.straddleCount = 1;
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3]);
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.straddleCount, 1, "still on for the redeal");
|
||||
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
|
||||
});
|
||||
|
||||
test("clears the cards and the folds, because none of it happened", () => {
|
||||
const state = dealt();
|
||||
state.seats[3].cards = ["As", "Kd"];
|
||||
Betting.fold(state, 3);
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats[3].cards, ["", ""]);
|
||||
assert.equal(state.seats[3].folded, false);
|
||||
assert.ok(state.seats.every(p => !p.hasActed));
|
||||
});
|
||||
|
||||
test("deals again cleanly, with the same blinds on the same players", () => {
|
||||
const state = dealt();
|
||||
const first = state.seats.map(p => Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank still balances", () => {
|
||||
test("through a misdeal and the redeal after it", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 60);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.cashIn, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking back a hand nobody had money in", () => {
|
||||
test("is harmless: no blinds, nothing moves", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 0);
|
||||
assert.deepEqual(taken.returned, []);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user