Until now a dealer kept the money in their head and typed each player's total into a form. This adds the running hand: blinds post themselves, every seat has a stack, and one tap per decision moves the action on. The bar sits at the bottom of the screen where a thumb already is, and shows only what's true right now — Fold, then Check or Call with the amount owed, then Bet or Raise. Calling more than a player has left says "Call all-in" rather than quoting a number they can't cover. Raising offers min, half pot, pot and all-in before it offers a keypad, because a dealer calling "raise to sixty" wants a button. When everyone has answered, the bar becomes Deal Flop, then Turn, River, and finally Showdown, which hands off to the pot wizard that already existed. Winnings go straight back in front of the winner, so the next hand's stacks are already right. The money hooks into what was already there rather than running beside it: a street's chips sweep into each seat's amountOverride, the same field the pot badge, the pot wizard, the session standings and the CSV export all read. So nothing downstream changed, and a dealer who prefers the old typed workflow still has it. Tables saved before this exist without any of these fields, so load() fills them in rather than making someone reset a table mid-session. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
234 lines
8.5 KiB
JavaScript
234 lines
8.5 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";
|
|
state.actingSeatIndex = this.liveSeats(state).length > 1
|
|
? this.nextLiveIndex(state, state.dealerSeatIndex)
|
|
: null;
|
|
},
|
|
|
|
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 = this.seatAt(state, state.dealerSeatIndex, 1);
|
|
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
|
|
|
|
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
|
|
if (bb > 0 && state.seats[bbIndex].inHand) 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;
|
|
state.actingSeatIndex = 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;
|
|
}
|