Blinds were assigned by seat offset — always button+1 and button+2 — which is only right when everyone is dealt in and there are three or more of them. Heads-up it had the blinds backwards. The button posted the big blind and acted second before the flop; the rule is the opposite on both counts. That's the end of every home tournament, dealt wrong. Worse, the offsets don't care whether anyone is sitting in those chairs. With two players at a nine-handed table, or with the seats left of the button sitting out, the blinds landed on empty seats and simply weren't posted — no money in the middle, and SB/BB badges pointing at nobody. Blinds now walk the players who are actually dealt in, starting from the button, with heads-up as its own case: the button is the small blind, acts first pre-flop, and the big blind acts first afterwards. The badges come from the same function as the money, so what the table shows and what gets posted can't disagree. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
252 lines
9.1 KiB
JavaScript
252 lines
9.1 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 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;
|
|
}
|