Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c76ebf4d18 | ||
|
|
cc060b29b8 | ||
|
|
3149b48c14 | ||
|
|
cb057b5159 |
@@ -127,6 +127,10 @@
|
||||
<span>Chips in play</span>
|
||||
<span id="bankChipsInPlay"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
|
||||
<span>Taken off the table</span>
|
||||
<span id="bankDiscarded"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -730,6 +730,18 @@ button,
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
|
||||
a gold puck on nobody looks exactly like a button that has drifted and wants
|
||||
dragging back. So it keeps the button's gold, to stay findable as the button, but
|
||||
goes hollow and dashed: the same shape, visibly holding a place rather than
|
||||
marking a player. The deal prompt says why in words; this is so the felt agrees
|
||||
with it at a glance instead of contradicting it. */
|
||||
.dealerChip.active.dealerChipDead {
|
||||
background: rgba(245, 213, 71, 0.14);
|
||||
border-style: dashed;
|
||||
color: #f0d98c;
|
||||
}
|
||||
|
||||
.blindBadge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -3394,6 +3406,38 @@ textarea:focus-visible {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
/* An aside that has something to press. It wraps rather than squeezing the button:
|
||||
on a phone the sentence takes the width it needs and the button drops under it,
|
||||
still at full tap size, instead of being crushed to a sliver at the end of a line. */
|
||||
.dealPromptLine.dealPromptDebt {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.dealPromptDebtBtn {
|
||||
/* Sized down from a full action button — it's an option beside a sentence, not
|
||||
the thing the dealer came here to press. "Post blinds & deal" stays the button
|
||||
that looks like the button. */
|
||||
flex: 0 0 auto;
|
||||
min-height: 32px;
|
||||
padding: 0.24rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
background: rgba(245, 213, 71, 0.1);
|
||||
border: 1px solid rgba(245, 213, 71, 0.45);
|
||||
color: #f0d98c;
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (pointer: coarse) {
|
||||
/* A fingertip needs 44px, and this one moves money. */
|
||||
.dealPromptDebtBtn {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Touch targets on a wide touch screen ----------
|
||||
|
||||
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
|
||||
|
||||
@@ -11,6 +11,11 @@ const ActionBar = {
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
|
||||
this.tankUnknownFor = state.bettingOpen &&
|
||||
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
|
||||
? state.actingSeatIndex
|
||||
: null;
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
},
|
||||
@@ -207,6 +212,14 @@ const ActionBar = {
|
||||
tankStartedAt: null,
|
||||
tankInterval: null,
|
||||
|
||||
// The seat that was already being asked to act when this page loaded, if there was
|
||||
// one. Its clock is unknowable: the action moved to them before this session
|
||||
// started — maybe two minutes ago, maybe last night — and starting from zero would
|
||||
// have the bar say they had just begun thinking. Which it did, every time a dealer
|
||||
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
|
||||
// clock is measuring something it actually saw.
|
||||
tankUnknownFor: null,
|
||||
|
||||
// Restarts only when the action actually moves — a re-render for any other reason
|
||||
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||
syncTankTimer() {
|
||||
@@ -220,6 +233,8 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
if (this.tankSeatIndex !== index) {
|
||||
// Once the action has moved under our own eyes, nothing is unknown any more.
|
||||
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
||||
this.tankSeatIndex = index;
|
||||
this.tankStartedAt = Date.now();
|
||||
}
|
||||
@@ -243,13 +258,33 @@ const ActionBar = {
|
||||
const el = document.getElementById("actionTank");
|
||||
if (!el || this.tankStartedAt === null) return;
|
||||
|
||||
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.";
|
||||
el.classList.remove("tankNudge", "tankSlow");
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
el.title = "How long this player has been thinking";
|
||||
el.textContent = this.formatTank(seconds);
|
||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||
},
|
||||
|
||||
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
|
||||
// open on a hand does reach that, and sixty-five minutes written as though it were
|
||||
// a minutes-and-seconds reading is a number the eye gets wrong.
|
||||
formatTank(seconds) {
|
||||
const pad = value => String(value).padStart(2, "0");
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(rest)}`
|
||||
: `${minutes}:${pad(rest)}`;
|
||||
},
|
||||
|
||||
// Shown only once someone is all-in and the money has actually split — a single
|
||||
// undivided pot needs no announcement, and a line that's always there stops being
|
||||
// read.
|
||||
@@ -566,12 +601,27 @@ const ActionBar = {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
line.textContent =
|
||||
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
|
||||
// the small blind" out loud is the whole point — a dealer who reads a preview
|
||||
// with a blind missing and isn't told why will assume the tool has lost track.
|
||||
line.textContent = preview.deadSmallBlind
|
||||
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
|
||||
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
wrap.appendChild(line);
|
||||
|
||||
// Why a position is missing. Both of these are the dead button rule doing its
|
||||
// job, and both look like a bug until somebody says otherwise.
|
||||
if (preview.deadButton || preview.deadSmallBlind) {
|
||||
const dead = document.createElement("p");
|
||||
dead.className = "dealPromptLine dealPromptAside";
|
||||
dead.textContent = preview.deadButton
|
||||
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
|
||||
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
|
||||
wrap.appendChild(dead);
|
||||
}
|
||||
|
||||
// Dealing relocates a button sitting on an empty chair. That has to be said
|
||||
// before it happens rather than discovered afterwards — a fresh table starts
|
||||
// the button on seat 1 whether or not anyone is sitting there.
|
||||
@@ -593,6 +643,44 @@ const ActionBar = {
|
||||
wrap.appendChild(out);
|
||||
}
|
||||
|
||||
// Somebody who has just sat down between the button and the big blind has had
|
||||
// the blinds go past them, and dealing them in there is a free hand in late
|
||||
// position. At a table the answer is that they post the big blind to come in
|
||||
// now, or wait for it to reach them. The tool used to do neither and say
|
||||
// nothing: it dealt them in for free, and the dealer had no way to know it had
|
||||
// happened. Both answers are offered here; neither is forced, because a home
|
||||
// game that doesn't care shouldn't have to argue with its own scoreboard.
|
||||
// One line for however many have walked up, not one each. The bar is docked
|
||||
// over the felt and the felt has already shrunk as far as it will go on a
|
||||
// phone, so every line here is taken out of the ring of seats: measured at
|
||||
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
|
||||
// bottom seat. They post together or they wait together, which is what happens
|
||||
// at a table anyway when two people sit down at once.
|
||||
if (preview.debtors.length > 0) {
|
||||
const ids = preview.debtors.map(i => this.state.seats[i].id);
|
||||
const posting = ids.every(id => preview.posting.includes(id));
|
||||
const who = listNames(preview.debtors);
|
||||
const each = preview.debtors.length > 1 ? " each" : "";
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = posting
|
||||
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
|
||||
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
|
||||
line.appendChild(text);
|
||||
|
||||
line.appendChild(this.button(
|
||||
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit(
|
||||
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
|
||||
() => { this.state.postingIn = posting ? [] : ids; })
|
||||
));
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
|
||||
@@ -78,10 +78,16 @@ const Betting = {
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
|
||||
const satOut = this.brokeSeats(state);
|
||||
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
||||
if (sbIndex === null || bbIndex === null) return null;
|
||||
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
|
||||
if (bbIndex === null) return null;
|
||||
return {
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual,
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
|
||||
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
|
||||
// Asked of the table as it will be dealt, for the same reason the blinds
|
||||
// are: someone sitting out for having no chips isn't owed a blind, and the
|
||||
// big blind of the hand about to go out isn't either.
|
||||
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
|
||||
posting: (state.postingIn || []).slice(),
|
||||
buttonIndex,
|
||||
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||
satOut,
|
||||
@@ -448,16 +454,46 @@ const Betting = {
|
||||
const bb = Number(state.bigBlind) || 0;
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
|
||||
if (sbIndex === null || bbIndex === null) {
|
||||
// A null big blind means there aren't enough players to post at all. A null
|
||||
// small blind with a big blind behind it is the dead small blind, which is a
|
||||
// hand that gets dealt like any other — it just has one blind in it.
|
||||
if (bbIndex === null) {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.postedBlinds = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sb > 0) this.putIn(state, sbIndex, sb);
|
||||
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
|
||||
if (bb > 0) this.putIn(state, bbIndex, bb);
|
||||
|
||||
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
||||
// Anyone the dealer said would post to come in past the blinds. It's a big
|
||||
// blind like any other — it buys them a hand they'd otherwise have to wait for
|
||||
// — so it goes in the middle the same way, before the count below reads it.
|
||||
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
|
||||
if (bb > 0) {
|
||||
state.seats.forEach((player, index) => {
|
||||
if (index === sbIndex || index === bbIndex) return;
|
||||
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
|
||||
});
|
||||
}
|
||||
// Deliberately not cleared here. repostBlinds takes every posted chip back and
|
||||
// deals again through this same function, so a post that cleared itself on the
|
||||
// way in would be refunded and never retaken — the dealer moves the button and
|
||||
// somebody's post quietly vanishes. startNextHand is what ends it.
|
||||
|
||||
// What the next hand's rotation is worked out from. Recorded here, where the
|
||||
// blinds are actually posted, because by the time it's needed the seats have
|
||||
// moved on and the answer can no longer be read off the table.
|
||||
state.postedBlinds = { sbIndex, bbIndex };
|
||||
|
||||
// Who got cards. Read next hand to tell a player who has been here all along
|
||||
// from one who has just walked up and had the blinds go past them.
|
||||
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
|
||||
|
||||
state.currentBet = this.round(Math.max(
|
||||
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
|
||||
this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
|
||||
// Heads-up the button is the small blind and acts first before the flop; with
|
||||
|
||||
@@ -121,6 +121,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
: `Button on ${nameOf(index)}.`, () => {
|
||||
state.dealerSeatIndex = index;
|
||||
// Putting the button somewhere by hand is the dealer overruling the
|
||||
// rotation, dead positions included. Leaving them set would hold a
|
||||
// small blind dead at a table whose button has moved out from under it.
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
@@ -376,17 +381,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
// Say where the button landed so the dealer can confirm the rotation without
|
||||
// hunting for the badge. Asked of the rotation itself rather than guessed at as
|
||||
// "the next chair along" — which was only ever right at a table with nobody
|
||||
// missing, and is the exact thing the dead button makes untrue.
|
||||
const rotation = HoldemState.nextButtonRotation(state);
|
||||
const nextDealer = rotation.buttonIndex !== null
|
||||
? rotation.buttonIndex
|
||||
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
|
||||
const where = rotation.deadButton
|
||||
? `dead button on seat ${nextDealer + 1}`
|
||||
: state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||
// Written down as well as announced. The toast is a moment; the bank check
|
||||
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||
// and reported them as missing.
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
|
||||
}
|
||||
HoldemState.startNextHand(state);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +173,12 @@ const Render = {
|
||||
layer.innerHTML = "";
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
// A button on an empty chair normally means the table has drifted and wants
|
||||
// fixing. Once it's the dead button it means the opposite — it's exactly where
|
||||
// it belongs — so it gets marked rather than looking like the thing to correct.
|
||||
const deadButtonHere = this.state.deadButton &&
|
||||
HoldemState.deadPositionsApply(this.state) &&
|
||||
!this.state.seats[dealerIndex].inHand;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -201,8 +207,11 @@ const Render = {
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
// The label now says whose it is, or what pressing it would do.
|
||||
const who = (player.name || "").trim() || `seat ${index + 1}`;
|
||||
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
|
||||
const chipLabel = hasButton
|
||||
? `${who} has the dealer button`
|
||||
? (deadButtonHere
|
||||
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
|
||||
: `${who} has the dealer button`)
|
||||
: `Give the dealer button to ${who}`;
|
||||
dealerChip.setAttribute("aria-label", chipLabel);
|
||||
dealerChip.title = chipLabel;
|
||||
@@ -876,8 +885,13 @@ const Render = {
|
||||
note.textContent =
|
||||
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
||||
} else {
|
||||
note.textContent =
|
||||
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||
// If a pot was thrown away on purpose, that's where the gap went, and the
|
||||
// dealer already answered a dialog about it. Telling them to go and fix it
|
||||
// would be sending them after their own decision.
|
||||
const discarded = Math.max(0, Number(this.state.discarded) || 0);
|
||||
note.textContent = discarded > 0
|
||||
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
|
||||
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||
}
|
||||
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||
},
|
||||
@@ -896,9 +910,22 @@ const Render = {
|
||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
||||
|
||||
// An abandoned pot the dealer confirmed throwing away is money that left the
|
||||
// table on purpose. It gets its own line so the two columns still add up in
|
||||
// front of them, rather than looking like a shortfall they have to explain.
|
||||
const discardedRow = document.getElementById("bankDiscardedRow");
|
||||
if (discardedRow) {
|
||||
discardedRow.hidden = !(bank.discarded > 0);
|
||||
if (bank.discarded > 0) {
|
||||
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
||||
verdict.textContent = bank.discarded > 0
|
||||
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
||||
: "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
|
||||
@@ -51,6 +51,9 @@ const HoldemState = {
|
||||
notes: "",
|
||||
handNumber: 1,
|
||||
handHistory: [],
|
||||
// Chips taken off the table on purpose, so the bank check can tell them
|
||||
// apart from chips that have gone missing.
|
||||
discarded: 0,
|
||||
smallBlind: 1,
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
@@ -78,6 +81,24 @@ const HoldemState = {
|
||||
// button order is the wrong answer often enough that it has to be
|
||||
// overridable. Cleared at the start of every hand.
|
||||
blindOverride: null,
|
||||
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
|
||||
// The next hand's rotation is worked out from this rather than from the
|
||||
// seating as it stands, because by then the people it needs to know about
|
||||
// are exactly the ones who have got up and gone.
|
||||
postedBlinds: null,
|
||||
// Player ids that were dealt in the last hand actually dealt. What makes a
|
||||
// seat recognisable as having *just* joined — new to the table, or back
|
||||
// from sitting out — which is the whole question behind owing a blind.
|
||||
dealtLastHand: [],
|
||||
// Ids the dealer has said will buy in past the blinds this hand, by posting
|
||||
// the big blind rather than waiting for it. Cleared every hand: it's a
|
||||
// decision about one deal, not a standing arrangement.
|
||||
postingIn: [],
|
||||
// Set when somebody leaves out of the blinds and the rotation has to hold
|
||||
// to the seats to keep the big blind moving one player at a time. The
|
||||
// button then sits on the chair they vacated, and no small blind is posted.
|
||||
deadButton: false,
|
||||
deadSmallBlind: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -146,16 +167,29 @@ const HoldemState = {
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||
|
||||
// Tables saved before live betting existed are missing its fields; fill
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||
"actingSeatIndex", "blindOverride"]
|
||||
"actingSeatIndex", "blindOverride", "postedBlinds"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
|
||||
// A table saved before the dead button existed has neither flag. False is
|
||||
// the right guess for both: it means the rotation carries on the ordinary
|
||||
// way, which is what that table was already doing.
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
// honest answer — it reads as "this table hasn't played yet", so the first
|
||||
// hand after reopening treats nobody as having just walked up, rather than
|
||||
// asking the whole table to post.
|
||||
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
||||
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
// guessing false would show "deal the hand" over a hand already in
|
||||
// progress. Anything already committed, or anyone already on the clock,
|
||||
@@ -323,6 +357,10 @@ const HoldemState = {
|
||||
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
||||
// supposed to come round to each player at the same rate, and they don't if the
|
||||
// button is rotating past chairs nobody is sitting in.
|
||||
//
|
||||
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
|
||||
// rotates by; this is for the cases it has nothing to work from — a fresh table,
|
||||
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
|
||||
advanceButton(state) {
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
@@ -361,6 +399,10 @@ const HoldemState = {
|
||||
buttonSeatForDeal(state) {
|
||||
const seated = state.seats[state.dealerSeatIndex];
|
||||
if (seated && seated.inHand) return state.dealerSeatIndex;
|
||||
// A button left on an empty chair on purpose is the dead button, and moving it
|
||||
// to a player is precisely what it's there to stop. Only a button that has been
|
||||
// stranded by accident gets rescued.
|
||||
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
|
||||
for (let step = 0; step < state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) return index;
|
||||
@@ -383,6 +425,13 @@ const HoldemState = {
|
||||
return o;
|
||||
},
|
||||
|
||||
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
|
||||
// players the button *is* the small blind and the blinds simply alternate, so
|
||||
// whatever the last rotation left behind stops applying.
|
||||
deadPositionsApply(state) {
|
||||
return state.seats.filter(p => p.inHand).length >= 3;
|
||||
},
|
||||
|
||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||
// small blind, which is the opposite of the usual order.
|
||||
getBlindSeats(state) {
|
||||
@@ -414,11 +463,88 @@ const HoldemState = {
|
||||
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
||||
}
|
||||
|
||||
// The small blind is dead: whoever would have moved into it has left the table,
|
||||
// and the seat stays empty for a hand rather than dragging the big blind
|
||||
// forward an extra place. One player gets a free hand out of position for it;
|
||||
// that's the price of nobody being charged two big blinds in an orbit.
|
||||
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
|
||||
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
|
||||
}
|
||||
|
||||
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
||||
},
|
||||
|
||||
// Seats about to be dealt a hand they haven't paid for.
|
||||
//
|
||||
// Somebody who walks up and sits down between the button and the big blind has had
|
||||
// the blinds go past them. Dealing them in there is a free hand in late position —
|
||||
// which is exactly what posting is for, and it comes out of everybody else's night
|
||||
// a couple of dollars at a time. At a real table they either post the big blind to
|
||||
// come straight in, or they wait for it to reach them.
|
||||
//
|
||||
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
|
||||
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
|
||||
// and nobody has been skipped.
|
||||
blindDebtors(state, bbIndex) {
|
||||
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
|
||||
if (!played) return [];
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player, index }) =>
|
||||
player.inHand &&
|
||||
index !== bbIndex &&
|
||||
!state.dealtLastHand.includes(player.id))
|
||||
.map(({ index }) => index);
|
||||
},
|
||||
|
||||
// Where the button goes for the next hand, and whether anything is left dead.
|
||||
//
|
||||
// Ordinarily it follows the small blind round — the next player along, which is
|
||||
// what it has always done. The case that needs care is somebody getting up out of
|
||||
// the blinds. Passing the button to the next *player* then pulls the whole blind
|
||||
// order forward a place: the player after the empty chair is never asked for a big
|
||||
// blind at all, and the one after them is asked an orbit early. Holding the
|
||||
// rotation to the seats instead is the dead button, and getting it wrong costs a
|
||||
// real player a real big blind.
|
||||
//
|
||||
// Worked out from who actually posted last hand, not from the seating as it stands,
|
||||
// because by now the people it needs to know about are the ones who have gone.
|
||||
nextButtonRotation(state) {
|
||||
const posted = state.postedBlinds;
|
||||
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
|
||||
const here = i => inRange(i) && state.seats[i].inHand;
|
||||
|
||||
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
|
||||
// the blinds were named by hand. And heads-up has its own rule — with two
|
||||
// players the button is the small blind, so there is no position to leave dead.
|
||||
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
|
||||
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
|
||||
}
|
||||
|
||||
// Both still sitting there is the ordinary case, and this comes out as the next
|
||||
// player along — the same answer as passing the button, arrived at from the
|
||||
// blinds so that the unusual case doesn't need a separate path.
|
||||
return {
|
||||
buttonIndex: posted.sbIndex,
|
||||
deadButton: !here(posted.sbIndex),
|
||||
deadSmallBlind: !here(posted.bbIndex),
|
||||
};
|
||||
},
|
||||
|
||||
startNextHand(state) {
|
||||
this.advanceButton(state);
|
||||
const rotation = this.nextButtonRotation(state);
|
||||
if (rotation.buttonIndex === null) {
|
||||
this.advanceButton(state);
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
} else {
|
||||
state.dealerSeatIndex = rotation.buttonIndex;
|
||||
state.deadButton = rotation.deadButton;
|
||||
state.deadSmallBlind = rotation.deadSmallBlind;
|
||||
}
|
||||
// Spent. Pressing Next Hand again without dealing is a dealer passing the
|
||||
// button along, not a second rotation off the same hand.
|
||||
state.postedBlinds = null;
|
||||
// Cards aren't out and nothing is posted until the dealer says so.
|
||||
state.bettingOpen = false;
|
||||
state.actingSeatIndex = null;
|
||||
@@ -429,6 +555,9 @@ const HoldemState = {
|
||||
// the next one would freeze the blinds on two players for the rest of the
|
||||
// night, which is the opposite of what naming them once is for.
|
||||
state.blindOverride = null;
|
||||
// Posting to come in is a decision about one deal. Carrying it forward would
|
||||
// charge somebody an extra big blind every hand for the rest of the night.
|
||||
state.postingIn = [];
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -590,10 +719,19 @@ const HoldemState = {
|
||||
0
|
||||
);
|
||||
|
||||
const difference = Money.round(chipsInPlay - cashIn);
|
||||
// Chips the dealer deliberately took off the table — an abandoned pot they
|
||||
// were asked about and confirmed. They are gone, so they can't be counted as
|
||||
// in play, but they aren't missing either: the box knows exactly where they
|
||||
// went. Without this the check blamed the tool's own confirmed action on
|
||||
// "someone has paid for chips they haven't been given", which sends a dealer
|
||||
// hunting for a miscount that never happened.
|
||||
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// The button when somebody gets up out of the blinds.
|
||||
//
|
||||
// Passing the button to the next *player* is right until a chair between the button
|
||||
// and the blinds empties. Then it pulls the whole blind order forward a place: the
|
||||
// player after the empty chair is never asked for a big blind, and the one after them
|
||||
// is asked an orbit early. The dead button rule holds the rotation to the seats for
|
||||
// one hand so the big blind still advances exactly one player.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const nameOf = (state, index) =>
|
||||
index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
|
||||
|
||||
const blinds = state => {
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
|
||||
};
|
||||
|
||||
const leave = (state, index) => {
|
||||
state.seats[index].name = "";
|
||||
state.seats[index].inHand = false;
|
||||
state.seats[index].stack = 0;
|
||||
state.seats[index].buyIns = 0;
|
||||
};
|
||||
|
||||
describe("somebody gets up out of the blinds", () => {
|
||||
test("the small blind leaves: dead button, and the big blind still moves one player", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
|
||||
|
||||
leave(state, 2); // Cal takes their chips and goes
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
|
||||
assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
|
||||
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
|
||||
"Dee moves from big to small and Ann posts the big blind, one player along");
|
||||
});
|
||||
|
||||
test("and Ann is not skipped, which is the money in it", () => {
|
||||
// The old rotation passed the button to the next player, which made the blinds
|
||||
// Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
|
||||
// early. That is a real big blind out of a real player's stack.
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
|
||||
});
|
||||
|
||||
test("the big blind leaves: no small blind is posted this hand", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
|
||||
leave(state, 3); // Dee goes
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
|
||||
assert.equal(sbIndex, null, "the small blind is dead");
|
||||
assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
|
||||
assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
|
||||
});
|
||||
|
||||
test("a dead small blind still deals a hand, with one blind in the middle", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.bettingOpen, true, "the hand goes ahead");
|
||||
const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
|
||||
assert.equal(state.currentBet, state.bigBlind);
|
||||
assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
|
||||
});
|
||||
|
||||
test("somebody leaving from anywhere else changes nothing", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
|
||||
leave(state, 0); // Ann, who was under the gun
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
|
||||
assert.equal(state.deadButton, false);
|
||||
assert.equal(state.deadSmallBlind, false);
|
||||
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ordinary rotation is untouched", () => {
|
||||
test("a full table passes the button one player at a time", () => {
|
||||
const state = table(0);
|
||||
const seen = [];
|
||||
for (let hand = 0; hand < 8; hand++) {
|
||||
Betting.beginHand(state);
|
||||
seen.push(blinds(state).bb);
|
||||
HoldemState.startNextHand(state);
|
||||
}
|
||||
assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
|
||||
"two clean orbits, everybody paying one big blind per orbit");
|
||||
assert.equal(state.deadButton, false);
|
||||
assert.equal(state.deadSmallBlind, false);
|
||||
});
|
||||
|
||||
test("players spread thinly around a big table don't create dead positions", () => {
|
||||
// Three players in nine chairs. The chairs between them were never in the
|
||||
// rotation, so holding to seats there would post blinds to nobody hand after
|
||||
// hand — which is why the button follows players, not chairs, by default.
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
|
||||
["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
|
||||
|
||||
const seen = [];
|
||||
for (let hand = 0; hand < 6; hand++) {
|
||||
Betting.beginHand(state);
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
|
||||
assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
|
||||
seen.push(nameOf(state, bbIndex));
|
||||
HoldemState.startNextHand(state);
|
||||
}
|
||||
assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
|
||||
});
|
||||
|
||||
test("pressing next hand twice without dealing just passes the button along", () => {
|
||||
const state = table(0);
|
||||
Betting.beginHand(state);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.dealerSeatIndex, 1);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rule knows where it stops", () => {
|
||||
test("down to two players, heads-up takes over and nothing is left dead", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
leave(state, 3); // both blinds walk at once
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
|
||||
});
|
||||
|
||||
test("a button stranded by accident is still rescued", () => {
|
||||
// Not the dead button: nobody left out of the blinds, the chair the button is
|
||||
// on just emptied. That one does want putting back on a player.
|
||||
const state = table(3);
|
||||
state.seats[3].inHand = false;
|
||||
HoldemState.ensureButtonOnPlayer(state);
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("moving the button by hand clears the dead positions", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.deadButton, true);
|
||||
|
||||
// What the seat menu's "Give the button" does.
|
||||
state.dealerSeatIndex = 0;
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
|
||||
});
|
||||
|
||||
test("naming a blind by hand overrules a dead one", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.deadSmallBlind, true);
|
||||
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 0 };
|
||||
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(manual, true);
|
||||
assert.equal(sbIndex, 1, "the dealer's word beats the rule");
|
||||
assert.equal(bbIndex, 0);
|
||||
});
|
||||
|
||||
test("a table saved before any of this reads as nothing dead", () => {
|
||||
const state = table(0);
|
||||
const parsed = JSON.parse(JSON.stringify(state));
|
||||
delete parsed.deadButton;
|
||||
delete parsed.deadSmallBlind;
|
||||
delete parsed.postedBlinds;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.equal(loaded.deadButton, false);
|
||||
assert.equal(loaded.deadSmallBlind, false);
|
||||
assert.equal(loaded.postedBlinds, null);
|
||||
|
||||
HoldemState.startNextHand(loaded);
|
||||
assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
});
|
||||
|
||||
describe("chips are not invented or lost by any of it", () => {
|
||||
test("posting one blind instead of two moves chips and doesn't make them", () => {
|
||||
// Measured across the deal, which is the step that touches stacks. A hand with
|
||||
// a dead small blind has one blind in the middle instead of two — the danger is
|
||||
// a null small blind being treated as a zero-cost posting somewhere, or the big
|
||||
// blind quietly covering for it.
|
||||
const state = table(1);
|
||||
const chips = s => s.seats.reduce(
|
||||
(sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
|
||||
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3); // the big blind goes, small blind dies
|
||||
|
||||
for (let hand = 0; hand < 4; hand++) {
|
||||
HoldemState.startNextHand(state);
|
||||
const before = chips(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a dead small blind takes exactly one blind out of one stack", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
|
||||
const before = stacks();
|
||||
Betting.beginHand(state);
|
||||
const after = stacks();
|
||||
|
||||
const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
|
||||
assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Chips the dealer took off the table on purpose.
|
||||
//
|
||||
// Starting the next hand with money still in the middle destroys it — the tool asks
|
||||
// first and says so afterwards. What it didn't do was remember, so the bank check saw
|
||||
// only that chips were gone and reported them as missing.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a table with nothing thrown away balances as before", () => {
|
||||
const bank = HoldemState.bankReconciliation(table());
|
||||
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.discarded, 0);
|
||||
});
|
||||
|
||||
test("chips that went missing are still reported", () => {
|
||||
// The check exists for this: cash in the box with no chips to match it.
|
||||
const state = table();
|
||||
state.seats[0].stack = 150;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("an abandoned pot is accounted for rather than counted as missing", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 198;
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.chipsInPlay, 795);
|
||||
assert.equal(bank.discarded, 5);
|
||||
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
|
||||
});
|
||||
|
||||
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a nonsense discarded value can't move the answer", () => {
|
||||
const state = table();
|
||||
state.discarded = -100;
|
||||
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||
|
||||
state.discarded = "not a number";
|
||||
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||
});
|
||||
|
||||
test("a table saved before this existed reads as nothing thrown away", () => {
|
||||
const saved = JSON.stringify(table());
|
||||
const parsed = JSON.parse(saved);
|
||||
delete parsed.discarded;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.equal(loaded.discarded, 0);
|
||||
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
|
||||
test("the amounts still add up the way the panel shows them", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 198;
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
// Somebody who walks up and sits down after the blinds have gone past them.
|
||||
//
|
||||
// Dealing them straight in is a free hand in late position — which is what posting
|
||||
// exists to stop, and it comes out of everybody else's night a couple of dollars at a
|
||||
// time. The tool used to do it silently. It now says so, and takes the post if the
|
||||
// dealer says to.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
return state;
|
||||
};
|
||||
|
||||
// Eve walks up and takes seat 5 while the table is running.
|
||||
const seatEve = state => {
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 200;
|
||||
state.seats[4].buyIns = 200;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const debtors = state => {
|
||||
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||
return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
|
||||
};
|
||||
|
||||
describe("who is owed a blind", () => {
|
||||
test("nobody, at a table that hasn't dealt a hand yet", () => {
|
||||
// Everyone is new on the first hand. Asking the whole table to post would be
|
||||
// the tool mistaking the start of the night for five people walking up late.
|
||||
const state = table();
|
||||
assert.deepEqual(debtors(state), []);
|
||||
});
|
||||
|
||||
test("nobody, on an ordinary hand where the same people are still sitting there", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(debtors(state), []);
|
||||
});
|
||||
|
||||
test("the player who has just sat down", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
seatEve(state);
|
||||
|
||||
assert.deepEqual(debtors(state), ["Eve"]);
|
||||
});
|
||||
|
||||
test("not once they've been dealt a hand", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
seatEve(state);
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.deepEqual(debtors(state), [], "she's one of the regulars now");
|
||||
});
|
||||
|
||||
test("not somebody who is the big blind this hand — they're paying already", () => {
|
||||
const state = table(0); // blinds Ben and Cal
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state); // blinds move to Cal and Dee
|
||||
|
||||
// Dee sits out for a hand and comes straight back into her own big blind.
|
||||
state.seats[3].inHand = false;
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[3].inHand = true;
|
||||
|
||||
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
|
||||
"the big blind is never asked to post on top of their blind");
|
||||
});
|
||||
|
||||
test("somebody back from sitting out is owed one just like a newcomer", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
state.seats[3].inHand = false; // Dee stands up for a hand
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[3].inHand = true; // and sits back down
|
||||
|
||||
assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
|
||||
});
|
||||
|
||||
test("a seat sat out for having no chips isn't owed anything", () => {
|
||||
// They aren't being dealt in, so there's no free hand to charge for.
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
state.seats[eve].stack = 0;
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting to come in", () => {
|
||||
test("the post goes in the middle and buys a hand", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
const before = Betting.stackOf(state, state.seats[eve]);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
|
||||
assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
|
||||
"out of her own stack, not conjured");
|
||||
assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
|
||||
});
|
||||
|
||||
test("not posting leaves her stack alone", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
const before = Betting.stackOf(state, state.seats[eve]);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), 0);
|
||||
assert.equal(Betting.stackOf(state, state.seats[eve]), before);
|
||||
});
|
||||
|
||||
test("the pot is what the blinds and the post add up to", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
|
||||
const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
|
||||
});
|
||||
|
||||
test("it's spent after the hand, not a standing charge", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
|
||||
Betting.beginHand(state);
|
||||
assert.ok(Betting.committed(state.seats[eve]) === 0 ||
|
||||
state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
|
||||
"she pays nothing extra on the next hand");
|
||||
});
|
||||
|
||||
test("moving the button after a post doesn't lose it", () => {
|
||||
// repostBlinds takes every posted chip back and deals again. A post that
|
||||
// cleared itself on the way in would be refunded and never retaken.
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
const chips = () => state.seats.reduce(
|
||||
(sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
|
||||
const before = chips();
|
||||
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
|
||||
assert.equal(chips(), before, "and no chips made or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("a post from somebody who isn't owed one is not taken", () => {
|
||||
// Only seats actually being dealt in can post, and only for their own chips.
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[3].id];
|
||||
state.seats[3].inHand = false;
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the dealer is shown", () => {
|
||||
test("the prompt has something to say about it, and something to press", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.deepEqual(preview.debtors, [eve]);
|
||||
assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
|
||||
});
|
||||
|
||||
test("an old save reads as a table nobody has just walked up to", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const parsed = JSON.parse(JSON.stringify(state));
|
||||
delete parsed.dealtLastHand;
|
||||
delete parsed.postingIn;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.deepEqual(loaded.dealtLastHand, []);
|
||||
assert.deepEqual(loaded.postingIn, []);
|
||||
assert.deepEqual(debtors(loaded), [],
|
||||
"reopening the tab doesn't ask the whole table to post");
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// How the think-clock reads.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const ActionBar = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
|
||||
|
||||
test("seconds under a minute", () => {
|
||||
assert.equal(ActionBar.formatTank(0), "0:00");
|
||||
assert.equal(ActionBar.formatTank(7), "0:07");
|
||||
assert.equal(ActionBar.formatTank(59), "0:59");
|
||||
});
|
||||
|
||||
test("minutes and seconds", () => {
|
||||
assert.equal(ActionBar.formatTank(60), "1:00");
|
||||
assert.equal(ActionBar.formatTank(241), "4:01");
|
||||
assert.equal(ActionBar.formatTank(3599), "59:59");
|
||||
});
|
||||
|
||||
test("past the hour it says the hour", () => {
|
||||
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
|
||||
// as minutes and seconds when it isn't.
|
||||
assert.equal(ActionBar.formatTank(3600), "1:00:00");
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(45296), "12:34:56");
|
||||
});
|
||||
|
||||
test("the minutes are padded once there are hours, and not before", () => {
|
||||
// "1:5:03" would be wrong; "05:03" on its own would be odd.
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(303), "5:03");
|
||||
});
|
||||
|
||||
test("a day is still a readable number rather than a wrapped one", () => {
|
||||
assert.equal(ActionBar.formatTank(90000), "25:00:00");
|
||||
});
|
||||
Reference in New Issue
Block a user