diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index fa4a5a3..baa240e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -532,11 +532,26 @@ const Betting = { // // 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. + // Rounded up to something the table's chips can actually build. Half the pot is + // constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest + // chip in the default legend is a dollar, so the button offered a number the + // dealer would have to round themselves every single time. + // + // Only when the legend describes this game. A smallest chip *larger* than the big + // blind means the denominations were never set for these stakes, and rounding a + // 5c/10c table up to whole dollars would be the tool doing real damage on the + // strength of a field somebody forgot to fill in. Left exact there, and the wrong + // legend stays visible rather than being quietly applied. 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); + const exact = this.round((state.currentBet || 0) + potAfterCall * fraction); + + const step = Money.fromCents(Money.chipStepCents(state.chipLegend)); + const bigBlind = Number(state.bigBlind) || 0; + if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact; + return Money.upToChip(exact, state.chipLegend); }, // Who turns their hand over first. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 9f120b2..f0fe135 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -63,6 +63,29 @@ const Money = { // Anything the smallest chip can't make comes back as `remainder` rather than being // rounded away: a 50c share in a game whose smallest chip is a dollar is a real // problem for the dealer to solve, not a rounding error to hide. + // The smallest chip the table has said it plays with, in cents. Zero when no + // denominations are set, which means the tool has been told nothing about what + // chips exist and shouldn't pretend otherwise. + chipStepCents(legend) { + const values = (legend || []) + .map(chip => this.toCents(chip.value)) + .filter(cents => cents > 0); + return values.length > 0 ? Math.min(...values) : 0; + }, + + // Rounds an amount up to something the table can actually build out of its chips. + // + // Up rather than down or nearest: these are bet sizes, and a pot-sized bet that + // has been shaved is no longer a pot-sized bet. Rounding up also can't drop an + // amount below a legal minimum, which rounding the other way can. + upToChip(amount, legend) { + const step = this.chipStepCents(legend); + if (step <= 0) return this.round(amount); + const cents = this.toCents(amount); + if (cents <= 0) return this.round(amount); + return this.fromCents(Math.ceil(cents / step) * step); + }, + chipBreakdown(amount, legend) { let cents = this.toCents(amount); if (cents <= 0) return { chips: [], remainder: 0 }; diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js index 8a3e262..d72a995 100644 --- a/tests/holdem/potRaise.test.js +++ b/tests/holdem/potRaise.test.js @@ -9,7 +9,7 @@ const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); -const { Betting, makeTable } = require("./load"); +const { Betting, Money, makeTable } = require("./load"); describe("pre-flop at 1/2", () => { const dealt = () => { @@ -25,11 +25,13 @@ describe("pre-flop at 1/2", () => { 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", () => { + test("half pot is $5 — $4.50 rounded up to something the chips can make", () => { // The old arithmetic gave $3.50, which clamped up to the minimum and put the - // same $4 on two buttons side by side. + // same $4 on two buttons side by side. The honest half-pot is $4.50, and the + // smallest chip in the default legend is a dollar, so the button would have + // offered an amount the dealer had to round themselves every single time. const state = dealt(); - assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5); assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), Betting.minRaiseTo(state), "the two quick buttons say different things"); }); @@ -111,3 +113,68 @@ describe("it stays sane at the edges", () => { assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); }); }); + +describe("the amounts are ones the table can actually build", () => { + const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }]; + const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }]; + + const table = (legend, smallBlind, bigBlind, stack = 200) => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind }); + state.chipLegend = legend; + Betting.beginHand(state); + return state; + }; + + test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => { + const state = table(DOLLARS, 1, 2); + // Honestly $4.50. No dollar chip makes fifty cents. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5); + assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0); + }); + + test("up rather than down — a shaved pot bet is not a pot bet", () => { + const state = table(DOLLARS, 1, 2); + assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5); + }); + + test("an amount the chips already make is left exactly alone", () => { + const state = table(DOLLARS, 1, 2); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole"); + }); + + test("a cent table keeps its cents", () => { + const state = table(CENTS, 0.05, 0.1); + // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0); + }); + + test("a legend that plainly isn't for these stakes is not applied", () => { + // Dollar chips on a 5c/10c game means somebody never filled the legend in. + // Rounding those blinds up to whole dollars would be the tool doing real + // damage on the strength of a stale field, so the amounts stay exact and the + // wrong legend stays visible. + const state = table(DOLLARS, 0.05, 0.1); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + }); + + test("a table with no legend at all gets exact amounts", () => { + const state = table([], 1, 2); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + }); + + test("every fraction comes out buildable, and never shaved", () => { + const state = table(DOLLARS, 1, 2); + const index = state.actingSeatIndex; + const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]); + + [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => { + const value = Betting.potRaiseTo(state, index, fraction); + const exact = state.currentBet + potAfterCall * fraction; + assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`); + assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`); + assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0, + `${fraction} of the pot should be buildable from the table's chips`); + }); + }); +});