diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 6c07afd..617e5ff 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -399,6 +399,22 @@

+
+

Who turns their hand over first

+

+ 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. +

+

+ 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. +

+
+

The odd chip in a split pot

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 0df9471..eabcf70 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -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; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index afba098..ede7c5b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -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; }, }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 3f78c8e..6dfebba 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -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 => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 888034f..a950af9 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -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]; }); diff --git a/tests/holdem/showdownOrder.test.js b/tests/holdem/showdownOrder.test.js new file mode 100644 index 0000000..cf6ff53 --- /dev/null +++ b/tests/holdem/showdownOrder.test.js @@ -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); + }); +});