diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index caf7f93..79070b5 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -430,7 +430,6 @@ const ActionBar = { const player = this.state.seats[index]; const stack = Betting.stackOf(this.state, player); const maxTo = Betting.round(Betting.committed(player) + stack); - const pot = Betting.potSoFar(this.state); // Two different minimums, and conflating them was a bug. `minTo` is clamped to // what this player can actually put in, which is what the Min button should // offer. `legalMin` is what the rules require — for a short stack the two are @@ -446,10 +445,16 @@ const ActionBar = { const quick = document.createElement("div"); quick.className = "raiseQuick"; + // The pot amounts come from Betting, which knows that a pot raise is measured + // off the pot as it stands *after* the caller's own call goes in. The pad used + // to work it out here as "the bet plus the pot", which left the call out and + // made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot + // raise is $7, and a half-pot that clamped up to the minimum and sat next to + // the Min button showing the same $4 twice. const options = [ { label: "Min", value: minTo }, - { label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) }, - { label: "Pot", value: Betting.round(this.state.currentBet + pot) }, + { label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) }, + { label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) }, { label: "All-in", value: maxTo }, ]; options.forEach(option => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 6dfebba..fa4a5a3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -518,6 +518,27 @@ const Betting = { : this.nextToAct(state, bbIndex); }, + // What a pot-sized raise actually is, as a "raise to" total. + // + // Not "the bet plus the pot as it stands". The raiser calls first, and their call + // is in the pot before the raise is measured off it, so the pot being matched is + // the one that exists after the call: + // + // raise to = the current bet + (pot + what they owe) × fraction + // + // Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then + // raise $5. Leaving the call out understates every one of these, and players say + // "pot" out loud meaning a definite number. + // + // Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is + // zero, so it comes out as the pot, which is the right answer there too. + potRaiseTo(state, index, fraction = 1) { + const player = state.seats[index]; + if (!player) return 0; + const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player)); + return this.round((state.currentBet || 0) + potAfterCall * fraction); + }, + // Who turns their hand over first. // // A call the dealer makes at every showdown that isn't a fold, and one nobody at diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js new file mode 100644 index 0000000..8a3e262 --- /dev/null +++ b/tests/holdem/potRaise.test.js @@ -0,0 +1,113 @@ +// What a pot-sized raise actually is. +// +// The raise pad worked it out as "the current bet plus the pot as it stands", which +// leaves out the fact that the raiser calls first and their call is in the pot before +// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it +// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a +// definite number, and a dealer tapping the button that claims to be it should record +// what was actually said. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Betting, makeTable } = require("./load"); + +describe("pre-flop at 1/2", () => { + const dealt = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 }); + Betting.beginHand(state); + return state; + }; + + test("a pot raise from the first player to act is $7", () => { + // Call the $2, which makes the pot $5, then raise $5. + const state = dealt(); + assert.equal(Betting.potSoFar(state), 3, "the blinds"); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7); + }); + + test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => { + // The old arithmetic gave $3.50, which clamped up to the minimum and put the + // same $4 on two buttons side by side. + const state = dealt(); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), + Betting.minRaiseTo(state), "the two quick buttons say different things"); + }); + + test("the small blind owes less, so their pot raise is smaller", () => { + // They have $1 in already: call $1, pot becomes $4, raise $4, total $6. + const state = dealt(); + const { sbIndex } = require("./load").HoldemState.getBlindSeats(state); + assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1); + assert.equal(Betting.potRaiseTo(state, sbIndex), 6); + }); + + test("the big blind owes nothing, so it is just the pot", () => { + const state = dealt(); + const { bbIndex } = require("./load").HoldemState.getBlindSeats(state); + assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0); + assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot"); + }); +}); + +describe("after the flop", () => { + const flopWithBet = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 }); + Betting.beginHand(state); + while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); + Betting.closeStreet(state); + return state; + }; + + test("a pot raise facing a bet is three times the bet plus the dead money", () => { + const state = flopWithBet(); + const potBefore = Betting.potSoFar(state); // $6 carried over + const bettor = state.actingSeatIndex; + Betting.raiseTo(state, bettor, 10); + + const raiser = state.actingSeatIndex; + // Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6. + assert.equal(Betting.potRaiseTo(state, raiser), 36); + assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore); + }); + + test("half pot facing that bet is $23", () => { + const state = flopWithBet(); + Betting.raiseTo(state, state.actingSeatIndex, 10); + // Call $10, pot becomes $26, raise $13 → $23. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23); + }); + + test("facing no bet at all it is a plain pot-sized bet", () => { + const state = flopWithBet(); + assert.equal(state.currentBet, 0, "nobody has bet the flop"); + assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state)); + }); +}); + +describe("it stays sane at the edges", () => { + test("a seat that doesn't exist gets zero rather than throwing", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + assert.equal(Betting.potRaiseTo(state, 99), 0); + }); + + test("the amount is always at least a call", () => { + // A raise-to below the current bet would be nonsense to offer. Every fraction + // from a tenth up lands at or above what the player owes. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 }); + Betting.beginHand(state); + const index = state.actingSeatIndex; + [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => { + assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet, + `${fraction} of the pot should still cover the bet`); + }); + }); + + test("cent-sized blinds don't drift", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 }); + Betting.beginHand(state); + // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + }); +});