Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40fa16920b | ||
|
|
09eac536b5 |
@@ -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 => {
|
||||
|
||||
@@ -518,6 +518,42 @@ 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.
|
||||
// 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));
|
||||
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.
|
||||
//
|
||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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, Money, 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 $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. 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), 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);
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user