Track the betting live so the dealer taps instead of types

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
This commit is contained in:
2026-08-31 19:01:08 -06:00
co-authored by Claude Sonnet 5
parent 7143e4e701
commit 7bedee2934
7 changed files with 823 additions and 16 deletions
@@ -67,6 +67,21 @@
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</p>
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
@@ -131,6 +146,7 @@
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
<div class="seatPuckStack"></div>
</div>
</template>
@@ -154,6 +170,10 @@
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
@@ -210,6 +230,8 @@
</div>
</template>
<div id="actionBar" aria-live="polite" hidden></div>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
@@ -243,6 +265,8 @@
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
+236 -3
View File
@@ -594,6 +594,10 @@ body {
display: flex;
flex-direction: column;
gap: 0.8rem;
/* Grid items default to min-width:auto, so anything with a wide min-content —
a side-by-side field row, say — would push this column past the viewport and
drag the table out with it. */
min-width: 0;
}
.sidebarPanel {
@@ -790,16 +794,245 @@ body {
/* ---------- Pot wizard ---------- */
/* ---------- Action bar ---------- */
#actionBar {
position: fixed;
/* Centred with auto margins rather than 100vw + translate: on a phone with a
scrollbar, 100vw is wider than the viewport and the overflow drags the whole
table sideways. */
left: 0;
right: 0;
bottom: 0;
margin: 0 auto;
z-index: 55;
max-width: 720px;
background: linear-gradient(180deg, #232323, #191919);
border: 1px solid #383838;
border-bottom: none;
border-radius: 14px 14px 0 0;
padding: 0.6rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -12px 34px rgba(0, 0, 0, 0.6);
}
#actionBar[hidden] {
display: none;
}
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
body.hasActionBar #holdemSidebar {
padding-bottom: 9rem;
}
.actionStatus {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.55rem;
font-size: 0.78rem;
}
.actionStreet {
color: #d8b46a;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 0.7rem;
}
.actionPot {
color: #e9e9e9;
font-variant-numeric: tabular-nums;
}
.actionWho {
margin-left: auto;
color: #9c9c9c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionButtons {
display: flex;
gap: 0.5rem;
}
.actionBtn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
min-height: 56px;
padding: 0.5rem 0.6rem;
border-radius: 11px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #eee;
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.12s ease;
}
.actionBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.actionBtn:active {
transform: translateY(1px);
}
.actionBtnLabel {
font-weight: 600;
}
.actionBtnSub {
font-size: 0.72rem;
color: #a8a8a8;
font-variant-numeric: tabular-nums;
}
.actionFold {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.actionCheck,
.actionCall {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
}
.actionCheck .actionBtnSub,
.actionCall .actionBtnSub {
color: #bfe8cb;
}
.actionRaise {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #a9782f;
color: #f0d9a8;
}
.actionAdvance {
background: linear-gradient(180deg, #2b3f57, #1b2a3a);
border-color: #3d5f83;
color: #dceaf8;
}
/* ---------- Raise pad ---------- */
.raiseQuick {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.raiseQuickBtn {
min-height: 48px;
font-size: 0.85rem;
}
.raiseQuickBtn.selected {
border-color: #d8b46a;
background: linear-gradient(180deg, #3a2f18, #241d0f);
color: #f0d9a8;
}
.raiseEntry {
display: flex;
gap: 0.4rem;
}
.raiseInput {
flex: 1;
min-width: 0;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 11px;
color: #f2f2f2;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
text-align: center;
padding: 0.5rem;
}
.raiseInput:focus {
outline: none;
border-color: #d8b46a;
}
.raiseCancel {
flex: 0 0 auto;
min-width: 84px;
}
.raiseConfirm {
flex: 0 0 auto;
min-width: 116px;
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #a9782f;
color: #f0d9a8;
}
/* Whose turn it is, shown on the table itself as well as in the bar. */
.seat.seatActing {
border-color: #d8b46a;
box-shadow:
0 0 0 2px rgba(216, 180, 106, 0.45),
0 4px 16px rgba(0, 0, 0, 0.55);
}
.seatPuckStack {
font-size: 0.58rem;
color: #8f8f8f;
font-variant-numeric: tabular-nums;
margin-top: 0.1rem;
}
.blindsRow {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.blindsRow .fieldRow {
flex: 1 1 120px;
min-width: 0;
}
.blindsRow input {
min-width: 0;
width: 5rem;
}
/* ---------- Toasts ---------- */
#toastHost {
position: fixed;
left: 50%;
left: 0;
right: 0;
bottom: 1.25rem;
transform: translateX(-50%);
margin: 0 auto;
z-index: 80;
width: min(560px, calc(100vw - 1.5rem));
max-width: 560px;
padding: 0 0.75rem;
box-sizing: border-box;
pointer-events: none;
transition: bottom 0.18s ease;
}
/* Sit above the action bar rather than on top of it, so an Undo never covers the
Fold button the dealer is reaching for. */
body.hasActionBar #toastHost {
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
}
.toast {
@@ -0,0 +1,230 @@
// The bar a dealer's thumb lives on while a hand is running: who's to act, what it
// costs them, and one tap per decision. Sits at the bottom of the screen on a phone
// so it's reachable without looking, and re-renders after every action because the
// next player's numbers are different.
const ActionBar = {
state: null,
hooks: null,
raiseOpen: false,
raiseDraft: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.render();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
},
render() {
const host = document.getElementById("actionBar");
if (!host) return;
host.innerHTML = "";
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
return;
}
host.hidden = false;
document.body.classList.add("hasActionBar");
host.appendChild(this.renderStatus());
if (Betting.isHandOver(this.state)) {
host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
host.appendChild(this.renderStreetDone());
} else if (this.raiseOpen) {
host.appendChild(this.renderRaisePad());
} else {
host.appendChild(this.renderActions());
}
},
renderStatus() {
const row = document.createElement("div");
row.className = "actionStatus";
const street = document.createElement("span");
street.className = "actionStreet";
street.textContent = Betting.streetLabel(this.state.street);
row.appendChild(street);
const pot = document.createElement("span");
pot.className = "actionPot";
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
row.appendChild(pot);
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
const who = document.createElement("span");
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
}
return row;
},
button(label, className, onClick, sublabel) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `actionBtn ${className}`;
const main = document.createElement("span");
main.className = "actionBtnLabel";
main.textContent = label;
btn.appendChild(main);
if (sublabel) {
const sub = document.createElement("span");
sub.className = "actionBtnSub";
sub.textContent = sublabel;
btn.appendChild(sub);
}
btn.addEventListener("click", onClick);
return btn;
},
commit(mutate) {
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
},
renderActions() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const stack = Betting.stackOf(this.state, player);
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
} else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number
// they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(() => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit(() => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
}));
}
return row;
},
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
// button, and only reaches for digits when the table does something odd.
renderRaisePad() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
const wrap = document.createElement("div");
wrap.className = "raisePad";
const quick = document.createElement("div");
quick.className = "raiseQuick";
const options = [
{ label: "Min", value: minTo },
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
{ label: "All-in", value: maxTo },
];
options.forEach(option => {
const value = Math.max(minTo, Math.min(option.value, maxTo));
const btn = this.button(option.label, "raiseQuickBtn", () => {
this.raiseDraft = value;
this.render();
}, Money.format(value));
if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected");
quick.appendChild(btn);
});
wrap.appendChild(quick);
const entry = document.createElement("div");
entry.className = "raiseEntry";
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
});
entry.appendChild(input);
entry.appendChild(this.button("Cancel", "raiseCancel", () => {
this.raiseOpen = false;
this.render();
}));
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
this.commit(() => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
return wrap;
},
renderStreetDone() {
const row = document.createElement("div");
row.className = "actionButtons";
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
return;
}
this.commit(() => Betting.closeStreet(this.state));
}));
return row;
},
renderHandOver() {
const row = document.createElement("div");
row.className = "actionButtons";
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
return row;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = ActionBar;
}
@@ -0,0 +1,233 @@
// 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;
}
+43 -10
View File
@@ -8,6 +8,34 @@ document.addEventListener("DOMContentLoaded", () => {
Render.mount(state, hooks);
const openPotWizard = () => {
// Chips still in front of players belong in the pot being priced, so bank them
// before the wizard reads anyone's contribution.
Betting.sweepToHandTotals(state);
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
}
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
};
ActionBar.mount(state, {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
});
// Moving to the next hand used to ask "are you sure?" every single time —
// the most-pressed button in a session, gated behind a system dialog. It now
// just happens, and the toast offers the way back for the few seconds anyone
@@ -79,9 +107,10 @@ document.addEventListener("DOMContentLoaded", () => {
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
HoldemState.startNextHand(state)
);
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
document.getElementById("resetTableBtn").addEventListener("click", async () => {
@@ -99,13 +128,17 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Table reset.", { duration: 2500 });
});
document.getElementById("resolvePotBtn").addEventListener("click", () => {
PotWizard.open(state, payouts => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state);
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
["smallBlindInput", "smallBlind"],
["bigBlindInput", "bigBlind"],
["defaultStackInput", "defaultStack"],
].forEach(([id, key]) => {
document.getElementById(id).addEventListener("input", e => {
state[key] = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
});
@@ -32,6 +32,7 @@ const Render = {
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
renderSetupFields() {
@@ -39,6 +40,9 @@ const Render = {
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
renderTableHint() {
@@ -54,8 +58,12 @@ const Render = {
},
updatePotBadge() {
const total = HoldemState.totalPot(this.state);
document.getElementById("potBadgeAmount").textContent = Money.format(total);
// Chips still sitting in front of players on the current street are part of
// the pot as far as anyone at the table is concerned, so count them — waiting
// until the street closes would show a stale number for most of every hand.
const swept = HoldemState.totalPot(this.state);
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
},
// ---- Seats around the table (compact pucks) ----
@@ -149,8 +157,19 @@ const Render = {
span.textContent = card;
cardsEl.appendChild(span);
});
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
// What's in front of them this street reads as the live number during betting;
// between hands it falls back to their total for the hand.
const streetBet = Betting.committed(player);
const total = streetBet > 0
? streetBet
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
node.classList.toggle("seatFolded", player.folded);
@@ -283,6 +302,13 @@ const Render = {
});
});
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
this.commitSeatChange(form, player, index);
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
@@ -26,6 +26,11 @@ const HoldemState = {
chipCounts: {},
amountOverride: null,
customFields: [],
// Live-betting fields: chips in front of the player, chips they've put in
// on the current street, and whether they've answered the current bet.
stack: null,
committed: 0,
hasActed: false,
};
},
@@ -44,6 +49,13 @@ const HoldemState = {
notes: "",
handNumber: 1,
handHistory: [],
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
street: "preflop",
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
};
},
@@ -55,6 +67,20 @@ const HoldemState = {
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
// Tables saved before live betting existed are missing its fields; fill
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
parsed.seats.forEach(player => {
if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0;
if (player.hasActed === undefined) player.hasActed = false;
});
return parsed;
} catch {
return this.createDefault();
@@ -147,6 +173,8 @@ const HoldemState = {
p.cards = ["", ""];
p.chipCounts = {};
p.amountOverride = null;
p.committed = 0;
p.hasActed = false;
});
},