Handing over cash and being dealt in are one event at a real table. Here they were two, and the second one was easy to miss. Last change deals a busted player around until they rebuy. So the dealer takes their money, taps Add chips, and: Ben: inHand false -> false stack 0 -> 100 buyIns 100 -> 200 toast: "Ben added $100." He has chips, he is not in the game, and nothing on screen says so. He gets skipped hand after hand until somebody notices and finds "Deal in" in the seat menu. Now the buy-in does both, and says which: mid-hand: "Ben added $100 - in from the next hand." inHand true, folded true between hands: "Ben added $100 - dealt in." inHand true, folded false Folding him for the hand in progress is the rule "Deal in" already follows -- you can't buy into a pot you have not been paying for -- so the two doors into the game behave the same way. It is one undoable step: undo takes back the chips, the cash record and the seat. Not for a seat that was sat out while holding chips. That is a dealer who sat someone out on purpose and is topping them up, and dealing them back in would be the tool guessing at something it was not told. The test reads the seat's own recorded stack rather than stackOf(), which substitutes the table's starting stack and would make a seat with nothing recorded look like it was holding chips. busted, sat out -> back in new chair, no stack yet -> in sat out holding $100 -> stays out already in the hand -> nothing to do, including an all-in at zero chips 8 tests (214 total). Driven through the UI: the rebuy, the toast, the next hand actually dealing him in (4 players, live 4), and undo putting all three fields back. Found on the way, not fixed here Buying in on a seat with no stack of its own credits the table's starting stack *plus* the buy-in. A new player handed $100 on a table with a $100 default ends up with $200 of chips against $100 of recorded cash. I am leaving it because the right answer is genuinely unclear and it is not mine to pick. A blank stack means "no chips recorded" to the bank check, which counts it as zero, and "the table's starting stack" to the betting code and the felt, which show it as $100. Both are defensible for their own job, and which one a buy-in should follow decides whether a casual dealer who never types stacks gets $200 or $100 after a rebuy. Worth saying out loud rather than quietly picking one. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
484 lines
21 KiB
JavaScript
484 lines
21 KiB
JavaScript
// Live betting: who's to act, what it costs them, and where the money goes.
|
|
//
|
|
// This is bookkeeping, not a game — the dealer taps what the humans at the table
|
|
// actually did, and the tool keeps the arithmetic straight. It deliberately never
|
|
// blocks an action it thinks is "illegal"; a dealer overruling the maths is right
|
|
// more often than the maths is.
|
|
//
|
|
// Money model: `player.committed` is what a seat has put in on the current street.
|
|
// When a street closes it sweeps into `player.amountOverride`, which is the seat's
|
|
// total for the hand — the same field the pot badge, the pot wizard, the standings
|
|
// and the CSV already read. So the betting bar is a fast way to fill in a field
|
|
// that already existed, and nothing downstream had to change.
|
|
const Betting = {
|
|
STREETS: ["preflop", "flop", "turn", "river"],
|
|
|
|
streetLabel(street) {
|
|
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
|
},
|
|
|
|
round(amount) {
|
|
return Money.round(amount);
|
|
},
|
|
|
|
// ---- Reading the table ----
|
|
|
|
liveSeats(state) {
|
|
return state.seats
|
|
.map((player, index) => ({ player, index }))
|
|
.filter(s => s.player.inHand && !s.player.folded);
|
|
},
|
|
|
|
// Who would post if the hand were dealt right now. Used to preview the blinds
|
|
// while the table is still being set, so a dealer can see where they'll land
|
|
// before any money moves.
|
|
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
|
|
//
|
|
// Not the same as all-in: an all-in player has money riding on the hand. This is
|
|
// somebody who busted and hasn't bought back in yet, which a home game produces
|
|
// about once an hour. A seat with no stack *recorded* isn't broke either —
|
|
// stackOf falls back to the table's starting stack for those — only an explicit
|
|
// zero counts.
|
|
brokeSeats(state) {
|
|
const broke = [];
|
|
state.seats.forEach((player, index) => {
|
|
if (!player.inHand) return;
|
|
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
|
|
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
|
|
broke.push(index);
|
|
});
|
|
return broke;
|
|
},
|
|
|
|
// The table as it will be once the seats that can't post are dealt around. A view,
|
|
// not a change: the preview has to ask the same question the deal will answer, and
|
|
// asking it of a different shape of table is how the two came apart last time.
|
|
tableAsDealt(state) {
|
|
const broke = this.brokeSeats(state);
|
|
if (broke.length === 0) return state;
|
|
const skip = new Set(broke);
|
|
return {
|
|
...state,
|
|
seats: state.seats.map((player, index) =>
|
|
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
|
|
};
|
|
},
|
|
|
|
blindPreview(state) {
|
|
// Dealing relocates a button parked on an empty chair before it works out who
|
|
// posts, so the preview has to answer for the button where it *will* be. Asking
|
|
// from where it currently sits gave a different pair of blinds than the deal
|
|
// produced — the preview promising one thing and doing another is exactly the
|
|
// guessing this whole step exists to remove. Same reason the broke seats come
|
|
// out first: the preview once promised a small blind from a seat with no chips.
|
|
const dealt = this.tableAsDealt(state);
|
|
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
|
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
|
? dealt
|
|
: { ...dealt, dealerSeatIndex: buttonIndex };
|
|
|
|
const satOut = this.brokeSeats(state);
|
|
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
|
if (sbIndex === null || bbIndex === null) return null;
|
|
return {
|
|
sbIndex, bbIndex, headsUp, manual: !!manual,
|
|
buttonIndex,
|
|
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
|
satOut,
|
|
smallBlind: this.round(state.smallBlind || 0),
|
|
bigBlind: this.round(state.bigBlind || 0),
|
|
};
|
|
},
|
|
|
|
// Does handing this seat chips also put them back in the game?
|
|
//
|
|
// At a real table those are one event, and here they were two: a busted player got
|
|
// their chips and stayed sat out, silently skipped hand after hand until somebody
|
|
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
|
|
// read "Ben added $100".
|
|
//
|
|
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
|
|
// somebody out on purpose and is topping them up, and dealing them back in would be
|
|
// the tool guessing at something it wasn't told. Only a seat with no stack of its
|
|
// own: busted at zero, or a chair being bought into for the first time.
|
|
//
|
|
// Deliberately reads player.stack rather than stackOf(), which substitutes the
|
|
// table's starting stack for a seat that has none — the question here is what this
|
|
// seat was actually recorded as holding.
|
|
buyInDealsThemIn(player) {
|
|
if (!player || player.inHand) return false;
|
|
const own = player.stack;
|
|
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
|
|
return !recorded || Number(own) <= this.EPSILON_CHIPS;
|
|
},
|
|
|
|
// "bet" or "raise" — what putting more in is actually called right now.
|
|
//
|
|
// A wager into a pot nobody has wagered into is a bet; everything after that is a
|
|
// raise. The bar decided this from what the acting player still owes, which gets
|
|
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
|
|
// their own blind is a live bet and putting more in is a raise. It gets the other
|
|
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
|
|
// and wrote "Ann's raise to $10" into the undo control for it.
|
|
//
|
|
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
|
|
// blind's option and one for an opening bet). This is only what it's called, which
|
|
// for a tool a dealer announces the action from is not a small thing.
|
|
wagerVerb(state) {
|
|
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
|
|
},
|
|
|
|
committed(player) {
|
|
return this.round(player.committed || 0);
|
|
},
|
|
|
|
// Everything this seat has put in across the whole hand, including the street
|
|
// still in progress.
|
|
handTotal(player) {
|
|
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
|
},
|
|
|
|
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
|
// the stack field used to come back as-is — which then flowed into putIn(), where
|
|
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
|
stackOf(state, player) {
|
|
const own = player.stack;
|
|
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
|
return Math.max(0, this.round(own));
|
|
}
|
|
const fallback = Number(state.defaultStack);
|
|
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
|
|
},
|
|
|
|
toCall(state, player) {
|
|
return Math.max(0, this.round((state.currentBet || 0) - this.committed(player)));
|
|
},
|
|
|
|
// A raise has to at least match the last one; before any raise, the big blind
|
|
// is the yardstick.
|
|
minRaiseTo(state) {
|
|
const increment = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
|
return this.round((state.currentBet || 0) + (increment || 1));
|
|
},
|
|
|
|
potSoFar(state) {
|
|
return this.round(
|
|
state.seats.reduce((sum, p) => sum + this.handTotal(p), 0)
|
|
);
|
|
},
|
|
|
|
// ---- Moving the action ----
|
|
|
|
nextLiveIndex(state, fromIndex) {
|
|
const count = state.seatCount;
|
|
for (let step = 1; step <= count; step++) {
|
|
const index = this.seatAt(state, fromIndex, step);
|
|
const player = state.seats[index];
|
|
if (player.inHand && !player.folded) return index;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Who the dealer asks next. A player who is all-in is still in the hand and still
|
|
// wins pots, but has no decision left, so the action passes over them.
|
|
nextToAct(state, fromIndex) {
|
|
const count = state.seatCount;
|
|
for (let step = 1; step <= count; step++) {
|
|
const index = this.seatAt(state, fromIndex, step);
|
|
const player = state.seats[index];
|
|
if (player.inHand && !player.folded && this.stackOf(state, player) > this.EPSILON_CHIPS) return index;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// A player still in the hand with chips in the pot and nothing left behind. This is
|
|
// the same stack test nextToAct uses to pass over them, which is the point: the
|
|
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
|
|
// act on again.
|
|
//
|
|
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
|
|
// anything in is a busted player who shouldn't have been dealt in, not a player
|
|
// who moved all-in, and calling them all-in on the felt would be a lie.
|
|
isAllIn(state, player) {
|
|
if (!player || !player.inHand || player.folded) return false;
|
|
return this.handTotal(player) > this.EPSILON_CHIPS &&
|
|
this.stackOf(state, player) <= this.EPSILON_CHIPS;
|
|
},
|
|
|
|
// Which chair sits `step` places along in the direction of play. The table owns
|
|
// this — walking the seats is exactly where the button, the blinds and the action
|
|
// used to drift apart from each other, so there is one implementation of it.
|
|
seatAt(state, fromIndex, step) {
|
|
return HoldemState.nextSeatIndex(state, fromIndex, step);
|
|
},
|
|
|
|
// A street is done when everyone still live has either matched the bet or has
|
|
// no chips left to argue with, and everyone has had a turn since the last raise.
|
|
isStreetComplete(state) {
|
|
const live = this.liveSeats(state);
|
|
if (live.length <= 1) return true;
|
|
|
|
const unsettled = live.filter(s => {
|
|
const allIn = this.stackOf(state, s.player) <= 0;
|
|
const matched = this.toCall(state, s.player) <= 0;
|
|
return !allIn && (!matched || !s.player.hasActed);
|
|
});
|
|
return unsettled.length === 0;
|
|
},
|
|
|
|
// ---- Actions ----
|
|
|
|
// Moves `amount` from a seat's stack into the middle for this street. Returns
|
|
// what actually moved, which is less than asked for if it puts them all-in.
|
|
putIn(state, index, amount) {
|
|
const player = state.seats[index];
|
|
const stack = this.stackOf(state, player);
|
|
const moved = this.round(Math.min(Math.max(0, amount), stack));
|
|
|
|
player.stack = this.round(stack - moved);
|
|
player.committed = this.round(this.committed(player) + moved);
|
|
return moved;
|
|
},
|
|
|
|
fold(state, index) {
|
|
state.seats[index].folded = true;
|
|
state.seats[index].hasActed = true;
|
|
this.passAction(state, index);
|
|
},
|
|
|
|
check(state, index) {
|
|
state.seats[index].hasActed = true;
|
|
this.passAction(state, index);
|
|
},
|
|
|
|
call(state, index) {
|
|
const player = state.seats[index];
|
|
this.putIn(state, index, this.toCall(state, player));
|
|
player.hasActed = true;
|
|
this.passAction(state, index);
|
|
},
|
|
|
|
// `total` is what the seat's street commitment becomes — "raise to", the way a
|
|
// dealer says it out loud, not "raise by".
|
|
raiseTo(state, index, total) {
|
|
const player = state.seats[index];
|
|
const target = this.round(total);
|
|
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;
|
|
|
|
state.currentBet = newBet;
|
|
|
|
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;
|
|
this.passAction(state, index);
|
|
return moved;
|
|
},
|
|
|
|
allIn(state, index) {
|
|
const player = state.seats[index];
|
|
return this.raiseTo(state, index, this.round(this.committed(player) + this.stackOf(state, player)));
|
|
},
|
|
|
|
passAction(state, index) {
|
|
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
|
|
},
|
|
|
|
// ---- Street and hand boundaries ----
|
|
|
|
// Pulls whatever is in front of the players into their hand totals. Anything that
|
|
// reads a seat's contribution — the pot wizard above all — has to run after this,
|
|
// or it will price the pot without the chips sitting on the current street.
|
|
sweepToHandTotals(state) {
|
|
state.seats.forEach(player => {
|
|
const moved = this.committed(player);
|
|
if (moved > 0) {
|
|
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
|
}
|
|
player.committed = 0;
|
|
player.hasActed = false;
|
|
});
|
|
},
|
|
|
|
// Sweeps the street's chips into each seat's hand total and opens the next one.
|
|
closeStreet(state) {
|
|
this.sweepToHandTotals(state);
|
|
|
|
state.currentBet = 0;
|
|
state.lastRaiseSize = 0;
|
|
|
|
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
|
state.street = this.STREETS[nextStreetIndex] || "river";
|
|
|
|
// It takes two players with chips to have a bet. Once everyone left is all-in
|
|
// the rest of the board is just dealt out.
|
|
if (!this.canStillBet(state)) {
|
|
state.actingSeatIndex = null;
|
|
return;
|
|
}
|
|
|
|
// After the flop the first live player left of the button acts — which heads-up
|
|
// is the big blind, since the button is the small blind. Naming the big blind
|
|
// directly also covers a button parked on a seat nobody is sitting in, and it
|
|
// still has to be someone with chips left to bet.
|
|
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
|
const bigBlindCanAct =
|
|
headsUp &&
|
|
bbIndex !== null &&
|
|
!state.seats[bbIndex].folded &&
|
|
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
|
|
|
|
state.actingSeatIndex = bigBlindCanAct
|
|
? bbIndex
|
|
: this.nextToAct(state, state.dealerSeatIndex);
|
|
},
|
|
|
|
isHandOver(state) {
|
|
return this.liveSeats(state).length <= 1;
|
|
},
|
|
|
|
// Players still in the hand who have chips left to put in. Fewer than two of them
|
|
// means the betting is finished even though the hand isn't.
|
|
seatsWithChips(state) {
|
|
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
|
|
},
|
|
|
|
EPSILON_CHIPS: Money.EPSILON,
|
|
|
|
canStillBet(state) {
|
|
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
|
|
},
|
|
|
|
// True when the hand is still live but nobody can bet again — the dealer is just
|
|
// running the board out.
|
|
isRunningOut(state) {
|
|
// Only while a hand is actually being played. Before the deal, a table of
|
|
// busted players who haven't rebought looks exactly like a table of all-ins to
|
|
// this test, and the bar was announcing a run-out over a felt with no cards and
|
|
// no chips on it.
|
|
if (!state.bettingOpen) return false;
|
|
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
|
},
|
|
|
|
// True while nothing has happened in this hand except the blinds going in. Up to
|
|
// that point the dealer can still move the button or rename the blinds and have
|
|
// it cost nothing — which is the state a home game spends a lot of time in,
|
|
// arguing about whose turn it is before the cards come out.
|
|
onlyBlindsIn(state) {
|
|
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
|
},
|
|
|
|
// Pulls the blinds back off the table and posts them again from wherever the
|
|
// button and the blind assignment now say they belong. Refunding first is what
|
|
// keeps the chip count straight when the button gets passed round three or four
|
|
// times before anybody is happy with it.
|
|
repostBlinds(state) {
|
|
state.seats.forEach(player => {
|
|
const back = this.committed(player);
|
|
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
|
player.committed = 0;
|
|
player.hasActed = false;
|
|
});
|
|
this.beginHand(state);
|
|
},
|
|
|
|
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
|
// blind, which is where a dealer expects to be looking when the cards go out.
|
|
//
|
|
// Called when the dealer says the table is ready, not automatically when the
|
|
// previous hand ends — everything before this point is setup, where seats and the
|
|
// button can move freely because no money has been committed.
|
|
beginHand(state) {
|
|
state.bettingOpen = true;
|
|
state.street = "preflop";
|
|
state.currentBet = 0;
|
|
state.lastRaiseSize = 0;
|
|
|
|
// Deal around anyone with no chips, before anything else reads the table.
|
|
//
|
|
// A busted player who hasn't rebought used to be dealt in anyway, and the
|
|
// result was a hand nobody could finish: the table promised a small blind from
|
|
// a seat with nothing to post it with, took it from nobody, put the action on
|
|
// the big blind with only their own money in front of them, and announced
|
|
// "3 players — all-in, running out the board" before a card was dealt.
|
|
//
|
|
// Sitting them out is what a dealer does. Seat, name and buy-in record are
|
|
// untouched, so dealing them back in is one tap when they buy chips.
|
|
this.brokeSeats(state).forEach(index => {
|
|
state.seats[index].inHand = false;
|
|
state.seats[index].folded = false;
|
|
});
|
|
|
|
// Position is read off the button all through the hand, so it has to be on
|
|
// somebody before the blinds are worked out — not on the empty seat 1 a
|
|
// brand-new table starts on.
|
|
HoldemState.ensureButtonOnPlayer(state);
|
|
|
|
state.seats.forEach(player => {
|
|
player.committed = 0;
|
|
player.hasActed = false;
|
|
});
|
|
|
|
const sb = Number(state.smallBlind) || 0;
|
|
const bb = Number(state.bigBlind) || 0;
|
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
|
|
|
if (sbIndex === null || bbIndex === null) {
|
|
state.currentBet = 0;
|
|
state.actingSeatIndex = null;
|
|
return;
|
|
}
|
|
|
|
if (sb > 0) this.putIn(state, sbIndex, sb);
|
|
if (bb > 0) this.putIn(state, bbIndex, bb);
|
|
|
|
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
|
state.lastRaiseSize = bb || 0;
|
|
|
|
// Heads-up the button is the small blind and acts first before the flop; with
|
|
// three or more it's the seat after the big blind. Either way, a blind big
|
|
// enough to put that player all-in leaves them nothing to decide.
|
|
state.actingSeatIndex = headsUp
|
|
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
|
: this.nextToAct(state, bbIndex);
|
|
},
|
|
|
|
// Winners get their share put back in front of them.
|
|
creditWinnings(state, payouts) {
|
|
payouts.forEach(payout => {
|
|
const player = state.seats.find(p => p.id === payout.id);
|
|
if (!player) return;
|
|
player.stack = this.round(this.stackOf(state, player) + payout.amount);
|
|
});
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = Betting;
|
|
}
|