From aeb02d023d1cd6f0fd96d2e6a95209418d5c4f55 Mon Sep 17 00:00:00 2001
From: JoshHeaps
+ Somebody shoving in less than a full raise — say $3 more when the last + raise was $8 — does not give the players behind them another go. Anyone + who has already acted owes the difference or folds; they don't get to + raise again because a short stack happened to land between them. +
++ A player who hadn't acted yet keeps full rights and may still raise, and + the minimum raise stays measured off the last full raise, not + off the short one. The action bar follows this for you. +
++ Bet more than anyone was able to call and the excess is returned to you + before the showdown — it was never contested, so it isn't part of the + pot. The pot breakdown shows it as "uncalled" rather than pretending it's + a side pot you could win. +
++ When a chopped pot won't divide evenly, the odd chip goes to the first + player left of the button, not to whoever the dealer happened to tick + first. Pot payouts here order the winners that way, so the leftover lands + on the right seat every time. +
+Follows the Tournament Directors Association rules, which most rooms and home games take their lead from. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 803d739..29f293f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -153,14 +153,33 @@ const Betting = { const delta = this.round(target - this.committed(player)); const moved = this.putIn(state, index, delta); const newBet = this.committed(player); + const previousBet = state.currentBet || 0; + + if (newBet > previousBet) { + const increase = this.round(newBet - previousBet); + // 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); + const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS; - if (newBet > (state.currentBet || 0)) { - state.lastRaiseSize = this.round(newBet - (state.currentBet || 0)); state.currentBet = newBet; - // A raise reopens the action: everyone else owes a response again. - state.seats.forEach((p, i) => { - if (i !== index && p.inHand && !p.folded) p.hasActed = false; - }); + + if (isFullRaise) { + state.lastRaiseSize = increase; + // A real raise reopens the action: everyone else owes a response again. + state.seats.forEach((p, i) => { + if (i !== index && p.inHand && !p.folded) p.hasActed = false; + }); + } + // Otherwise this is a short all-in — somebody with less than a full raise + // left shoving it in. The most misread rule in poker: it does *not* reopen + // the betting to anyone who has already acted. They owe the difference or + // they fold; they don't get to raise again because a short stack happened + // to be between them. Players yet to act keep their full rights, which + // they do here because their hasActed is already false. + // + // lastRaiseSize deliberately isn't lowered either, or the next legal raise + // would be computed off the short amount and come out too small. } player.hasActed = true; diff --git a/tests/holdem/reopeningBetting.test.js b/tests/holdem/reopeningBetting.test.js new file mode 100644 index 0000000..7ee548d --- /dev/null +++ b/tests/holdem/reopeningBetting.test.js @@ -0,0 +1,143 @@ +// When does a bet reopen the action? The most misread rule in poker, and the one a +// home game argues about longest: a player shoving in less than a full raise does not +// give everybody behind them another go. +// +// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting +// to a player who has already acted and is not facing a full raise. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, makeTable, chipsOnTable } = require("./load"); + +const actedFlags = state => state.seats + .map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded })) + .filter(s => s.live); + +describe("a short all-in does not reopen the betting", () => { + // Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls. + // Seat 2 is all-in for 13 — three more, where a full raise needs eight. + const shortAllInTable = () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 13; + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + return state; + }; + + test("players who already acted are not asked to act again", () => { + const state = shortAllInTable(); + assert.equal(state.seats[0].hasActed, true, "seat 1 has called"); + assert.equal(state.seats[3].hasActed, true, "seat 4 has raised"); + + Betting.allIn(state, 1); + + assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened"); + assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened"); + }); + + test("but they do still owe the difference", () => { + const state = shortAllInTable(); + Betting.allIn(state, 1); + + assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3"); + assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over"); + }); + + test("a player who had not acted yet keeps full rights", () => { + const state = shortAllInTable(); + assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted"); + Betting.allIn(state, 1); + assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise"); + }); + + test("the minimum raise is not shrunk by the short all-in", () => { + const state = shortAllInTable(); + assert.equal(state.lastRaiseSize, 8); + + Betting.allIn(state, 1); + + assert.equal(state.lastRaiseSize, 8, "still the last *full* raise"); + assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8"); + }); + + test("calling the short all-in ends the street", () => { + const state = shortAllInTable(); + Betting.allIn(state, 1); + Betting.call(state, 2); // seat 3, who had full rights, just calls + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(Betting.isStreetComplete(state), true); + }); + + test("no chips are created or lost by any of it", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 13; + const start = chipsOnTable(state); + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + Betting.allIn(state, 1); + Betting.call(state, 2); + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(chipsOnTable(state), start); + }); +}); + +describe("a full raise still reopens the betting", () => { + test("an all-in that is a full raise or more reopens everyone", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[1].stack = 40; + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); + Betting.call(state, 0); + + Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed + + assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go"); + assert.equal(state.seats[3].hasActed, false, "and so does seat 4"); + assert.equal(state.lastRaiseSize, 30); + assert.equal(Betting.minRaiseTo(state), 70); + }); + + test("an ordinary raise reopens everyone", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); + Betting.call(state, 0); + assert.equal(state.seats[3].hasActed, true); + + Betting.raiseTo(state, 1, 10); + + assert.equal(state.seats[3].hasActed, false); + assert.equal(state.seats[0].hasActed, false); + }); + + test("a raise exactly the size of the last one counts as full", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.raiseTo(state, 3, 10); // raise of 8 + Betting.call(state, 0); + + Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise + + assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens"); + assert.equal(state.lastRaiseSize, 8); + }); + + test("the first raise of a street is measured against the big blind", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); + Betting.call(state, 0); + Betting.call(state, 1); + Betting.check(state, 2); + Betting.closeStreet(state); // to the flop, no bet yet + + const first = Betting.nextToAct(state, state.dealerSeatIndex); + Betting.raiseTo(state, first, 2); // a bet of exactly one big blind + + const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first); + assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone"); + }); +});