// 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 Math.round((Number(amount) || 0) * 100) / 100; }, // ---- Reading the table ---- liveSeats(state) { return state.seats .map((player, index) => ({ player, index })) .filter(s => s.player.inHand && !s.player.folded); }, 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)); }, stackOf(state, player) { const own = player.stack; if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own); const fallback = Number(state.defaultStack); return Number.isNaN(fallback) ? 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 && this.stackOf(state, player) >= 0) return index; } return null; }, seatAt(state, fromIndex, step) { const count = state.seatCount; const delta = state.direction === "counterclockwise" ? -step : step; return ((fromIndex + delta) % count + count) % count; }, // 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); if (newBet > (state.currentBet || 0)) { state.lastRaiseSize = this.round(newBet - (state.currentBet || 0)); state.currentBet = newBet; // A 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; }); } 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.nextLiveIndex(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"; if (this.liveSeats(state).length <= 1) { 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. const { bbIndex, headsUp } = HoldemState.getBlindSeats(state); state.actingSeatIndex = headsUp && bbIndex !== null && !state.seats[bbIndex].folded ? bbIndex : this.nextLiveIndex(state, state.dealerSeatIndex); }, isHandOver(state) { return this.liveSeats(state).length <= 1; }, // 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. beginHand(state) { state.street = "preflop"; state.currentBet = 0; state.lastRaiseSize = 0; 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. state.actingSeatIndex = headsUp ? sbIndex : this.nextLiveIndex(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; }