// 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, deadSmallBlind } = HoldemState.getBlindSeats(asDealt); if (bbIndex === null) return null; return { sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind, deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand, // Asked of the table as it will be dealt, for the same reason the blinds // are: someone sitting out for having no chips isn't owed a blind, and the // big blind of the hand about to go out isn't either. debtors: HoldemState.blindDebtors(asDealt, bbIndex), posting: (state.postingIn || []).slice(), buttonIndex, buttonMoves: buttonIndex !== state.dealerSeatIndex, satOut, smallBlind: this.round(state.smallBlind || 0), bigBlind: this.round(state.bigBlind || 0), // What the straddles will be if the hand is dealt as it stands, and how // many more this table could take. straddles: this.straddleSeats(state), maxStraddles: this.maxStraddles(state), }; }, // ---- Straddles ---- // // A straddle is a blind raise put out before the cards, by the player left of the // big blind, for twice it. It buys the last word before the flop: everyone else // acts first, and the straddler still has the option when it comes back round even // if nobody raised. Re-straddles carry on left, each doubling again. // // Home games run them constantly and a dealer has to get three things right at // once — the amount, who is first to act, and what the minimum raise becomes. The // tool used to do none of them, so a dealer's only way through was to deal, have // the straddler "raise" to $4, and hand the action to the wrong player. // How many straddles this table could take. Everyone but the two blinds can put // one out; heads-up that is nobody, which is the right answer — the button is // already the small blind and there is no seat left of the big blind to straddle // from. maxStraddles(state) { return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2); }, // Who straddles and for how much, walking left from the big blind and doubling. // Asked of the table as it will be dealt, so a seat about to be sat out for having // no chips is never handed a straddle it can't post. straddleSeats(state, count = state.straddleCount) { const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state)); if (wanted <= 0) return []; const dealt = this.tableAsDealt(state); const buttonIndex = HoldemState.buttonSeatForDeal(dealt); const asDealt = buttonIndex === dealt.dealerSeatIndex ? dealt : { ...dealt, dealerSeatIndex: buttonIndex }; const { bbIndex } = HoldemState.getBlindSeats(asDealt); if (bbIndex === null) return []; const straddles = []; let index = bbIndex; let amount = this.round((Number(state.bigBlind) || 0) * 2); for (let n = 0; n < wanted; n++) { index = this.nextToAct(asDealt, index); if (index === null || index === bbIndex) break; straddles.push({ index, amount }); amount = this.round(amount * 2); } return straddles; }, // 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); // Aggression, whether or not it was a full raise — a short all-in doesn't // reopen the betting, but the player who made it is still the last one who // put money in, and at the showdown they are still the one who shows first. state.streetAggressorIndex = index; state.handAggressorIndex = index; // 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; // Whoever led this street stops being the answer the moment the next one opens. // The hand's aggressor is deliberately left alone: it's the fallback for a // river nobody could bet on. state.streetAggressorIndex = null; 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; // Posting a blind isn't aggression — a hand limped and checked all the way // down has no aggressor at all, which is exactly the case the showdown rule // sends to the seat left of the button. state.streetAggressorIndex = null; state.handAggressorIndex = null; // 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); // A null big blind means there aren't enough players to post at all. A null // small blind with a big blind behind it is the dead small blind, which is a // hand that gets dealt like any other — it just has one blind in it. if (bbIndex === null) { state.currentBet = 0; state.actingSeatIndex = null; state.postedBlinds = null; state.postedStraddles = []; return; } if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb); if (bb > 0) this.putIn(state, bbIndex, bb); // Anyone the dealer said would post to come in past the blinds. It's a big // blind like any other — it buys them a hand they'd otherwise have to wait for // — so it goes in the middle the same way, before the count below reads it. const posting = Array.isArray(state.postingIn) ? state.postingIn : []; if (bb > 0) { state.seats.forEach((player, index) => { if (index === sbIndex || index === bbIndex) return; if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb); }); } // Deliberately not cleared here. repostBlinds takes every posted chip back and // deals again through this same function, so a post that cleared itself on the // way in would be refunded and never retaken — the dealer moves the button and // somebody's post quietly vanishes. startNextHand is what ends it. // What the next hand's rotation is worked out from. Recorded here, where the // blinds are actually posted, because by the time it's needed the seats have // moved on and the answer can no longer be read off the table. state.postedBlinds = { sbIndex, bbIndex }; // Who got cards. Read next hand to tell a player who has been here all along // from one who has just walked up and had the blinds go past them. state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id); // Straddles go out after the blinds and before the cards, each one left of the // last. Posted here rather than left to the dealer to fake as a raise, because // a raise from that seat would hand the action to the wrong player. const straddles = this.straddleSeats(state); straddles.forEach(({ index, amount }) => this.putIn(state, index, amount)); state.postedStraddles = straddles.map(({ index }) => index); state.currentBet = this.round(state.seats.reduce( (most, player) => Math.max(most, this.committed(player)), 0)); // A straddle is a blind raise, so what has to be matched to raise again is the // straddle itself, not the big blind underneath it: $2 blinds with a $4 // straddle make the next raise $8, the same way the $2 blind makes it $4. // Left alone, the minimum would come out at $6 and the dealer would be talked // out of it by every player at the table. state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (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. // // A straddle moves that seat along: first to act is left of the last straddler, // and the straddler keeps the option, which falls out of nobody having acted // yet. const lastPosted = straddles.length > 0 ? straddles[straddles.length - 1].index : bbIndex; state.actingSeatIndex = headsUp ? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex)) : this.nextToAct(state, lastPosted); }, // 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 // the table volunteers for — everyone would rather see a hand than show one. The // tool knew who last raised and who sits left of the button and said neither, so // the dealer was working it out from memory while five people waited. // // Three cases, in the order the rule asks them: // * somebody bet or raised on the final round — they show first; // * the round was checked through — first live player left of the button; // * nobody could bet, because the chips were already in — the last player who // did put money in, on whichever round that was. // // Returns null when there's nothing to call: one player left, or none. showdownOrder(state) { const live = this.liveSeats(state); if (live.length < 2) return null; const seatOf = index => Number.isInteger(index) && live.some(s => s.index === index) ? index : null; const streetAggressor = seatOf(state.streetAggressorIndex); if (streetAggressor !== null) { return { index: streetAggressor, reason: "aggressor" }; } // Nobody could have bet this round, so "no bet on the final round" isn't a // choice anybody made and the seat left of the button hasn't earned the // obligation. It falls to whoever last put money in. if (!this.canStillBet(state)) { const handAggressor = seatOf(state.handAggressorIndex); if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" }; } const order = HoldemState.liveOrderFromButton(state) .filter(index => live.some(s => s.index === index)); return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null; }, // 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; }