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
This commit is contained in:
@@ -399,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>
|
||||
|
||||
@@ -1957,6 +1957,16 @@ 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;
|
||||
|
||||
@@ -691,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();
|
||||
@@ -705,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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -276,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);
|
||||
@@ -337,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";
|
||||
@@ -424,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.
|
||||
//
|
||||
@@ -504,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 => {
|
||||
|
||||
@@ -86,6 +86,12 @@ 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.
|
||||
@@ -173,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];
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user