Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5 9f3d0dce4b Call who shows first at the showdown, instead of leaving the dealer to remember
Every showdown that isn't a fold needs somebody to turn over first, and it is the one
call at the table nobody volunteers for — everyone would rather see a hand than show
one. The tool held both facts the rule needs, who last raised and who sits left of
the button, and said neither. The dealer worked it out from memory while five people
waited, every hand.

The action bar now names the seat and the reason above the resolve button:

    Dee shows first — last to bet or raise.
    Ben shows first — checked round, first left of the button.
    Dee shows first — last to put money in; nobody could bet the river.

The reason is there so the call can be repeated to whoever argues with it, which is
half of what makes it stick.

Three cases, in the order the rule asks them. Somebody bet or raised on the final
round: they show. Nobody did: first live player left of the button. Nobody *could*,
because the chips were already in: it falls to whoever last put money in, on whichever
street that was — "no bet on the final round" isn't a choice anybody made when there
was no round to bet in, so the seat left of the button hasn't earned it.

Aggression is tracked per street and per hand, because those two answers differ
exactly when it matters. A short all-in counts: not reopening the betting is a rule
about who may raise, and says nothing about who shows. Posting a blind doesn't — a
hand limped and checked all the way down has no aggressor at all, which is the case
that goes to the button.

A matching ruling goes in Common rulings, since the dealer will be asked.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:53:14 -06:00
jheapsandClaude Opus 5 01f5139740 Let the dealer find one hand in the log instead of scrolling the night
The hand log exists for one moment: somebody at the rail says they won a pot an hour
ago and wants it looked up. A night runs to sixty hands or more and the panel showed
five of them at a time, in a box fixed at 260px whether the dealer was on a phone or
a 27" screen with room for twenty. There was no way to ask for a hand — only to
scroll and read every row.

A filter box over the list now takes a name, an amount or a hand number. Matching is
loose on purpose: "fay" whatever case it's typed in, "190" for a $190 pot or a $190
payout, "$1,90" for the same typed badly, "#12" when the dealer knows the hand and
wants that exact one. Beside it, how much of the night is hidden — "8 of 60" — since
a filter matching one hand otherwise looks like a table that only played one.

The list also takes the room it has: clamp(200px, 42vh, 560px) rather than a flat
260, which is seven or eight rows instead of five on a desktop and no worse on a
phone.

Export stays the whole night, always. A CSV quietly missing the hands scrolled past
would be a record with holes in it and nothing on the file to say so — if a filter is
up when it's exported, the toast says how many went in.

Matching deliberately covers only what the row displays. Searching the full ledger as
well was the first attempt, so the hand that broke somebody could be found under the
loser's name — but the ledger holds everyone who put a chip in, and measured on a
seven-handed night where nobody folded, "Fay" returned 60 of 60. A filter that
searches text the panel doesn't show looks broken even when it's right.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:39:55 -06:00
jheapsandClaude Opus 5 c76ebf4d18 Say when somebody is being dealt in for free, and give the dealer the post
Sitting down between the button and the big blind means the blinds have gone past
you. The tool dealt those players straight in, silently. Measured: Eve takes a seat,
and the next hand she is dealt cards in late position having put nothing in — a free
hand, out of everyone else's night a couple of dollars at a time, with nothing
anywhere on screen to tell the dealer it had happened.

At a table there are two answers and the player picks one: post the big blind and
come in now, or wait for the blind to reach them. The tool did neither and named
neither.

The deal prompt now names anyone about to be dealt in who has not paid for it, and
carries the post as one tap. Nothing is forced — a home game that doesn't care can
deal as before, and the sentence is still worth having on its own, because the
dealer at least knows. The post goes in as a big blind out of that player's own
stack, is live like any blind, and is spent at the end of the hand rather than
becoming a standing charge.

Somebody back from sitting out is owed one the same as a newcomer. The big blind of
the hand isn't — they're already paying — and neither is anyone at a table that has
not dealt a hand yet, where the whole table is new and nobody has been skipped.

Deliberately 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 goes on a phone,
so every line costs seat area. A line apiece put the bar over two fifths of the
bottom seat at 390x844.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:27:22 -06:00
10 changed files with 956 additions and 9 deletions
@@ -141,7 +141,13 @@
</div>
<h3 class="panelSubhead">Hands</h3>
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
</div>
<div id="handHistoryList"></div>
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
@@ -393,6 +399,22 @@
</p>
</section>
<section class="ruling" data-short="Who shows first">
<h3>Who turns their hand over first</h3>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
player left of the button. Nobody has to be asked twice — a player may
always turn their hand over early if they want to.
</p>
<p>
When everyone is all-in before the river there was no final round to
lead, so it falls to whoever last put money in, on whichever street that
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</section>
<section class="ruling" data-short="Odd chip">
<h3>The odd chip in a split pot</h3>
<p>
+84 -1
View File
@@ -1957,11 +1957,62 @@ button,
color: #e07b7b;
}
/* The showdown call, sat above the resolve button. Loud enough to be read across a
table at a glance — this is something the dealer says out loud — but not so loud it
competes with the button underneath it. */
.showdownCall {
margin: 0 0 0.45rem;
text-align: center;
font-size: 0.82rem;
color: #f0d98c;
}
.handHistoryFilterRow {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.handHistoryFilter {
flex: 1 1 auto;
min-width: 0;
/* 16px or iOS zooms the whole page on focus, which on a phone at the table throws
the felt off screen mid-hand. Every other field in this panel is held at 16px
for the same reason. */
font-size: 16px;
padding: 0.35rem 0.5rem;
background: #141414;
border: 1px solid #2c2c2c;
border-radius: 6px;
color: #e6e6e6;
}
.handHistoryFilter::placeholder {
color: #8a8a8a;
}
.handHistoryFilter:focus-visible {
outline: 2px solid #d8b46a;
outline-offset: 1px;
}
.handHistoryCount {
flex: 0 0 auto;
font-size: 0.72rem;
color: #8a8a8a;
font-variant-numeric: tabular-nums;
}
#handHistoryList {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 260px;
/* Was a flat 260px, which is about four and a half rows — the same four and a half
whether the dealer is on a phone or a 27" screen with room for twenty. The floor
keeps it honest on a short window; the ceiling stops the panel from running away
on a tall one and pushing everything below it out of reach. */
max-height: clamp(200px, 42vh, 560px);
overflow-y: auto;
}
@@ -3406,6 +3457,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
@@ -643,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", () => {
@@ -653,13 +691,41 @@ const ActionBar = {
return wrap;
},
// "Dee shows first — last to raise." The call the dealer has to make at every
// showdown and that nobody at the table volunteers for, said out loud with the
// reason attached so it can be repeated to whoever argues with it.
showdownCall() {
const call = Betting.showdownOrder(this.state);
if (!call) return null;
const who = HoldemState.displayName(this.state.seats[call.index], call.index);
const because = {
aggressor: "last to bet or raise",
// Semicolon, not a second dash: the sentence already spends its dash on
// "shows first —", and two of them in one line reads as a stutter.
allInAggressor: "last to put money in; nobody could bet the river",
leftOfButton: "checked round, first left of the button",
}[call.reason];
const line = document.createElement("p");
line.className = "showdownCall";
line.textContent = `${who} shows first — ${because}.`;
return line;
},
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
const wrap = document.createElement("div");
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
if (isRiver) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
@@ -667,22 +733,33 @@ const ActionBar = {
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
wrap.appendChild(row);
return row;
return wrap;
},
renderHandOver() {
const row = document.createElement("div");
row.className = "actionButtons";
const wrap = document.createElement("div");
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
// Two or more still in with the betting finished is a showdown however it was
// arrived at — including everyone all-in on the flop, where the cards go on
// their backs and the order still decides who turns over first.
if (!winner) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
return row;
wrap.appendChild(row);
return wrap;
},
};
@@ -83,6 +83,11 @@ const Betting = {
return {
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,
@@ -271,6 +276,11 @@ const Betting = {
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
// Aggression, whether or not it was a full raise — a short all-in doesn't
// reopen the betting, but the player who made it is still the last one who
// put money in, and at the showdown they are still the one who shows first.
state.streetAggressorIndex = index;
state.handAggressorIndex = index;
// What a full raise would have had to be: at least as much again as the
// last one, or the big blind if there hasn't been a raise yet.
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
@@ -332,6 +342,10 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Whoever led this street stops being the answer the moment the next one opens.
// The hand's aggressor is deliberately left alone: it's the fallback for a
// river nobody could bet on.
state.streetAggressorIndex = null;
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
@@ -419,6 +433,11 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
// Deal around anyone with no chips, before anything else reads the table.
//
@@ -462,11 +481,30 @@ const Betting = {
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
// 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])));
@@ -480,6 +518,45 @@ const Betting = {
: this.nextToAct(state, bbIndex);
},
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
// the table volunteers for — everyone would rather see a hand than show one. The
// tool knew who last raised and who sits left of the button and said neither, so
// the dealer was working it out from memory while five people waited.
//
// Three cases, in the order the rule asks them:
// * somebody bet or raised on the final round — they show first;
// * the round was checked through — first live player left of the button;
// * nobody could bet, because the chips were already in — the last player who
// did put money in, on whichever round that was.
//
// Returns null when there's nothing to call: one player left, or none.
showdownOrder(state) {
const live = this.liveSeats(state);
if (live.length < 2) return null;
const seatOf = index =>
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
const streetAggressor = seatOf(state.streetAggressorIndex);
if (streetAggressor !== null) {
return { index: streetAggressor, reason: "aggressor" };
}
// Nobody could have bet this round, so "no bet on the final round" isn't a
// choice anybody made and the seat left of the button hasn't earned the
// obligation. It falls to whoever last put money in.
if (!this.canStillBet(state)) {
const handAggressor = seatOf(state.handAggressorIndex);
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
}
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(s => s.index === index));
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
},
// Winners get their share put back in front of them.
creditWinnings(state, payouts) {
payouts.forEach(payout => {
@@ -591,15 +591,33 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
HoldemState.clearHistory(state);
Render.historyFilter = "";
document.getElementById("handHistoryFilter").value = "";
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
// The filter is how the dealer looks at the log, not something about the table, so
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
// tab. Clearing the history clears it too, since there is nothing left to filter.
document.getElementById("handHistoryFilter").addEventListener("input", e => {
Render.historyFilter = e.target.value;
Render.renderHandHistory();
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
// Always the whole night. A filter is for reading the panel; an export that
// quietly dropped the hands scrolled past would be a record with holes in it,
// and nothing on the CSV would say so.
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
if (Render.historyFilter) {
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
{ duration: 3500 });
}
});
// The load happens on the first line of this file, long before there's a Toast to
@@ -967,9 +967,50 @@ const Render = {
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
// Typing in the filter changes the list without changing the history, and
// the redraw is skipped when the signature matches — leave this out and the
// box takes input and does nothing.
this.historyFilter || "",
].join("|");
},
// What the dealer opens the log for is one hand: the one somebody is arguing
// about. A night runs to sixty or more, five at a time in the panel, so finding it
// meant scrolling and reading every row. A name, an amount, or a hand number
// narrows it instead.
//
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
// remembering an amount shouldn't have to know which of those they mean; "#12"
// pins it to the hand number, for when they do.
handMatchesFilter(entry, query) {
if (!query) return true;
const term = query.trim().toLowerCase();
if (!term) return true;
const handNumber = String(entry.handNumber || "");
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
// Only what the row itself shows. Matching the hand's full ledger as well was
// the first attempt, on the reasoning that a dealer might hunt for the hand
// that broke somebody by the loser's name — but the ledger holds everyone who
// put a chip in, so at a table where nobody folds it matched every hand.
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
// panel doesn't display is a filter that looks broken even when it's right.
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
if (labels.includes(term)) return true;
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
const digits = term.replace(/[^0-9.]/g, "");
if (digits) {
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
.map(a => String(Money.round(Number(a) || 0)));
if (amounts.some(a => a.startsWith(digits))) return true;
if (handNumber === digits) return true;
}
return false;
},
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
// full re-render runs after every fold, call and raise. The log only changes when a
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
@@ -988,10 +1029,29 @@ const Render = {
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
// How much of the night is being hidden. Without it a filter that matches one
// hand looks exactly like a table that only played one, which is the sort of
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
const count = document.getElementById("handHistoryCount");
if (count) {
count.hidden = !query || history.length === 0;
count.textContent = `${shown.length} of ${history.length}`;
}
const noMatch = document.getElementById("handHistoryNoMatch");
if (noMatch) {
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
}
const filterBox = document.getElementById("handHistoryFilter");
if (filterBox) filterBox.hidden = history.length === 0;
// Built off-document so the panel is laid out once instead of once per row.
const fragment = document.createDocumentFragment();
const template = document.getElementById("handHistoryRowTemplate");
history.forEach(entry => {
shown.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
@@ -86,6 +86,20 @@ const HoldemState = {
// 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,
// Who last bet or raised, on this street and anywhere in the hand. Kept
// apart because the showdown rule needs both: normally the aggressor of the
// final betting round shows first, but a round nobody could bet in — chips
// already all-in — falls back to the last one who could.
streetAggressorIndex: null,
handAggressorIndex: 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.
@@ -165,7 +179,8 @@ const HoldemState = {
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
"actingSeatIndex", "blindOverride", "postedBlinds"]
"actingSeatIndex", "blindOverride", "postedBlinds",
"streetAggressorIndex", "handAggressorIndex"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
@@ -175,6 +190,12 @@ const HoldemState = {
// 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
@@ -460,6 +481,29 @@ const HoldemState = {
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
@@ -518,6 +562,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 = ["", ""];
+123
View File
@@ -0,0 +1,123 @@
// Finding one hand in a night of them.
//
// What the log is actually opened for is a single hand — the one somebody at the rail
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
// there was no way to ask for one: the dealer scrolled and read every row.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const hand = (handNumber, pot, payouts, ledger = []) => ({
handNumber, pot, timestamp: Date.now(), payouts, ledger,
});
// A night as the tool records it: labels carry the cards the wizard was shown.
const NIGHT = [
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
];
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
describe("no filter", () => {
test("shows the whole night", () => {
assert.deepEqual(found(""), [1, 2, 12, 120]);
assert.deepEqual(found(" "), [1, 2, 12, 120]);
assert.deepEqual(found(null), [1, 2, 12, 120]);
});
});
describe("by name", () => {
test("finds the hand somebody won", () => {
assert.deepEqual(found("Fay"), [2]);
});
test("case doesn't matter — nobody types a capital at 2am", () => {
assert.deepEqual(found("fay"), [2]);
assert.deepEqual(found("FAY"), [2]);
});
test("a name inside a label with cards on it still matches", () => {
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
// rather than the whole cell.
assert.deepEqual(found("ann"), [1]);
});
test("matches what the row shows, not everyone who was in the hand", () => {
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
// and searching text the panel doesn't display is a filter that looks broken
// even when it's right. Measured on a seven-handed night where nobody folded,
// matching the ledger returned 60 of 60 for every name at the table.
assert.deepEqual(found("gus"), []);
});
test("a split pot is found by either winner", () => {
assert.deepEqual(found("cal"), [120]);
assert.deepEqual(found("dee"), [120]);
});
test("a name nobody has finds nothing rather than everything", () => {
assert.deepEqual(found("zed"), []);
});
});
describe("by amount", () => {
test("finds a pot", () => {
assert.deepEqual(found("190"), [2]);
});
test("a dollar sign and commas are ignored", () => {
assert.deepEqual(found("$190"), [2]);
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
});
test("finds a payout that isn't the whole pot", () => {
assert.deepEqual(found("38"), [120], "half of a chopped $76");
});
test("a partial amount narrows rather than missing", () => {
// Half remembering "something around forty" should still get there.
assert.deepEqual(found("4"), [1], "$40");
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
});
test("a bare number is an exact hand number, not a prefix of one", () => {
// Amounts match on their leading digits, because half a remembered figure is
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
// knows which hand they want, and dragging 120 and 128 along with it is noise.
// "#12" is there for when they do want the prefix.
assert.ok(!found("12").includes(120));
});
});
describe("by hand number", () => {
test("a bare number matches the hand number as well as amounts", () => {
assert.ok(found("12").includes(12));
});
test("a hash pins it to the hand number and nothing else", () => {
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
});
test("a hash with a number nobody played finds nothing", () => {
assert.deepEqual(found("#7"), []);
});
});
describe("odd input can't break it", () => {
test("punctuation on its own doesn't match everything", () => {
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
});
test("an entry with pieces missing doesn't throw", () => {
const broken = { handNumber: 5, pot: 10 };
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
});
});
+238
View File
@@ -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;
});
});
+202
View File
@@ -0,0 +1,202 @@
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
// table volunteers for — everyone would rather see a hand than show one. The tool knew
// who last raised and who sits left of the button and said neither.
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, stack = 200) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
Betting.beginHand(state);
return state;
};
const call = state => {
const answer = Betting.showdownOrder(state);
return answer && { who: state.seats[answer.index].name, reason: answer.reason };
};
// Everybody left just calls whatever is in front of them, then the street closes.
const checkRound = state => {
let guard = 0;
while (state.actingSeatIndex !== null && guard++ < 12) {
const index = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
else Betting.check(state, index);
}
Betting.closeStreet(state);
};
describe("a bet on the river", () => {
test("the last player to raise shows first", () => {
const state = table(0); // blinds Ben and Cal
checkRound(state); // pre-flop
checkRound(state); // flop
checkRound(state); // turn
Betting.raiseTo(state, state.actingSeatIndex, 20);
const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
});
test("a re-raise moves it to the last one, not the first", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const first = state.actingSeatIndex;
Betting.raiseTo(state, first, 20);
const second = state.actingSeatIndex;
Betting.raiseTo(state, second, 60);
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[second].name);
});
test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
// The rule about reopening is about who may raise. It says nothing about who
// shows, and the player who shoved is still the last one who put money in.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const bettor = state.actingSeatIndex;
Betting.raiseTo(state, bettor, 40);
const shortStack = state.actingSeatIndex;
// Over the $40 but only by $15, where a full raise would need $40 more. Going
// *under* the bet would be a short call, not a short raise, and wouldn't make
// them the aggressor at all.
state.seats[shortStack].stack = 55;
Betting.allIn(state, shortStack);
assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[shortStack].name);
});
});
describe("a river checked through", () => {
test("the first live player left of the button shows first", () => {
const state = table(0); // button Ann
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
});
test("an earlier street's raiser doesn't carry over to the river", () => {
// The rule is about the *final* round. Leading the flop and then checking the
// river doesn't oblige anyone.
const state = table(0);
checkRound(state); // pre-flop
Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
checkRound(state); // turn
checkRound(state); // river
assert.equal(call(state).reason, "leftOfButton");
});
test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
const state = table(0); // button Ann, so Ben would be first
checkRound(state); checkRound(state); checkRound(state);
Betting.fold(state, 1); // Ben gets out on the river
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
Betting.closeStreet(state);
assert.equal(call(state).who, "Cal", "the next one round who is still in");
});
});
describe("everyone all-in before the river", () => {
test("the last player who could put money in shows first", () => {
// There was no final round to lead, so "checked round" isn't a choice anybody
// made and the seat left of the button hasn't earned the obligation.
const state = table(0, 30);
checkRound(state); // pre-flop
const shover = state.actingSeatIndex;
Betting.allIn(state, shover);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
});
test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
// Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
// and calling is not aggression. Nobody chose to lead, so nobody is on the hook
// and it goes back to the seat left of the button.
const state = table(0, 2);
checkRound(state);
assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
const answer = call(state);
assert.ok(answer, "there is still a showdown to order");
assert.equal(answer.reason, "leftOfButton");
});
});
describe("when there is nothing to call", () => {
test("one player left isn't a showdown", () => {
const state = table(0);
[1, 2, 3].forEach(i => Betting.fold(state, i));
assert.equal(Betting.showdownOrder(state), null);
});
test("an aggressor who has since folded doesn't get named", () => {
// Can't happen in play — you don't fold after raising unopposed — but the
// index is a number on the state and a reset table could hold a stale one.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 1;
state.seats[1].folded = true;
const answer = call(state);
assert.notEqual(answer.who, "Ben");
assert.equal(answer.reason, "leftOfButton");
});
test("an index pointing at no seat at all is ignored", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 99;
assert.equal(call(state).reason, "leftOfButton");
});
});
describe("the tracking resets when it should", () => {
test("a new hand starts with nobody on the hook", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
Betting.raiseTo(state, state.actingSeatIndex, 20);
assert.notEqual(state.handAggressorIndex, null);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
});
test("posting a blind is not aggression", () => {
const state = table(0);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null);
});
});