Compare commits

...
Author SHA1 Message Date
jheaps 271194cc9e Merge pull request 'Let the dealer take back an action from the bar' (#26) from feature/casino-holdem-action-undo into feature/casino-holdem-pace
Reviewed-on: #26
2026-08-31 23:34:46 -06:00
jheapsandClaude Sonnet 5 95c523738c Let the dealer take back an action from the bar
An audit of what's accumulated turned up an inconsistency worth fixing: double-
tapping a seat to fold someone was undoable, but tapping Fold in the action bar
— the button a dealer actually uses all hand — was not. Nor was Call, Raise, or
dealing a street. The safest path through the tool was the shortcut, and the
main one had no way back.

Every action taken from the bar now goes through one place that records it
first, and the bar carries a standing Undo naming what it will take back:
"Ben's fold", "dealing the flop". Twelve steps deep, because a misclick usually
gets noticed a beat late and one level of undo would only cover the luckiest
case.

It's a control rather than a toast per action. A dealer taps this bar
constantly, and a toast after every fold is noise they'd learn to ignore —
which is precisely when they'd miss the one that mattered.

The history is discarded whenever a hand starts or settles, because those
snapshots describe a hand that no longer exists. The whole-hand Undo on the
toast already covers that boundary.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:37:03 -06:00
jheapsandClaude Sonnet 5 1fcc058dd8 Show how long the current player has been thinking, and how many are left
Dealing guides put the biggest time sink not in shuffling or spreading cards
but in players who haven't noticed the action is on them — and they list the
per-street count of players remaining as part of what a dealer announces.

The action bar now carries both. A stopwatch runs on whoever is to act, going
amber at thirty seconds and red at a minute, so a dealer can see at a glance
whether someone is genuinely deciding or has stopped paying attention. Nothing
happens when it grows; it is a prompt for the dealer, not a clock on the
player. Beside it is the count of players still in the hand.

The timer restarts only when the action actually moves, so re-rendering for an
unrelated reason doesn't put a thinking player back to zero, and it ticks by
repainting its own text rather than rebuilding the buttons under the dealer's
thumb. It stops when the hand ends or the bar hides.

Also stops the side-pot breakdown from crying wolf: it now waits for someone to
actually be all-in. Unequal amounts in front of players are normal mid-street —
the blinds alone are unequal — and layering those announced a side pot on every
hand before anyone had acted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:21:39 -06:00
3 changed files with 193 additions and 8 deletions
+51 -1
View File
@@ -1075,12 +1075,37 @@ body.hasActionBar #holdemSidebar {
.actionStatus {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
gap: 0.4rem 0.6rem;
margin-bottom: 0.55rem;
font-size: 0.78rem;
}
/* Always there while there's something to take back, rather than a toast per tap. */
.actionUndo {
flex: none;
min-height: 30px;
max-width: 100%;
padding: 0.15rem 0.6rem;
border-radius: 999px;
border: 1px solid #414141;
background: #262626;
color: #bdbdbd;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionUndo:hover {
background: #333;
border-color: #4f4f4f;
color: #eee;
}
.actionStreet {
color: #d8b46a;
font-weight: 600;
@@ -1094,6 +1119,11 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
.actionPlayers {
color: #9c9c9c;
font-variant-numeric: tabular-nums;
}
.actionWho {
margin-left: auto;
color: #9c9c9c;
@@ -1102,6 +1132,26 @@ body.hasActionBar #holdemSidebar {
white-space: nowrap;
}
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
nothing else. */
.actionTank {
flex: none;
min-width: 3.1rem;
text-align: right;
color: #7d7d7d;
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
.actionTank.tankNudge {
color: #d8b46a;
}
.actionTank.tankSlow {
color: #e07b7b;
font-weight: 600;
}
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
@@ -27,6 +27,8 @@ const ActionBar = {
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
host.hidden = false;
@@ -46,6 +48,8 @@ const ActionBar = {
} else {
host.appendChild(this.renderActions());
}
this.syncTankTimer();
},
renderStatus() {
@@ -62,6 +66,14 @@ const ActionBar = {
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
row.appendChild(pot);
// How many are still in the hand — part of the standard per-street
// announcement, and the number a dealer is asked for most often.
const remaining = Betting.liveSeats(this.state).length;
const players = document.createElement("span");
players.className = "actionPlayers";
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
row.appendChild(players);
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
@@ -69,15 +81,101 @@ const ActionBar = {
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
}
// A standing control rather than a toast per action: a dealer taps this bar
// constantly, and a toast after every fold would be noise they'd learn to
// ignore — which is exactly when they'd miss the one that mattered.
const undoLabel = this.hooks.lastActionLabel();
if (undoLabel) {
const undo = document.createElement("button");
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
return row;
},
// ---- Think timer ----
//
// A player who hasn't noticed the action is on them costs more time than anything
// else a dealer does, so the bar shows how long the current player has been in the
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
// tells the dealer when it's worth a nudge.
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: null,
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
const index = this.state.actingSeatIndex;
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
if (!running) {
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
if (this.tankSeatIndex !== index) {
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
if (!this.tankInterval) {
this.tankInterval = setInterval(() => this.paintTank(), 1000);
}
this.paintTank();
},
stopTankTimer() {
if (this.tankInterval) {
clearInterval(this.tankInterval);
this.tankInterval = null;
}
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb.
paintTank() {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
const mins = Math.floor(seconds / 60);
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
renderPotBreakdown() {
// Only worth showing once someone is actually all-in. Mid-street the amounts in
// front of players are unequal all the time — the blinds alone are unequal —
// and layering that would announce a side pot on every hand before anyone had
// even acted.
const someoneAllIn = this.state.seats.some(
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
);
if (!someoneAllIn) return null;
const contributions = this.state.seats.map((player, index) => ({
id: player.id,
index,
@@ -117,7 +215,11 @@ const ActionBar = {
return btn;
},
commit(mutate) {
// Every action taken from the bar goes through here, which is also where it gets
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
// tool a dealer can't talk their way out of.
commit(label, mutate) {
this.hooks.beginAction(label);
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
@@ -128,20 +230,21 @@ const ActionBar = {
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => 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)));
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => 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)));
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
@@ -210,7 +313,8 @@ const ActionBar = {
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));
const who = HoldemState.displayName(player, index);
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
@@ -229,7 +333,7 @@ const ActionBar = {
this.hooks.resolvePot();
return;
}
this.commit(() => Betting.closeStreet(this.state));
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
return row;
@@ -83,12 +83,39 @@ document.addEventListener("DOMContentLoaded", () => {
});
};
// A short history of actions taken from the bar, so a misclicked Fold can be taken
// back — and the one before it, since a dealer usually notices a beat late. Kept in
// memory only: this is for the mistake you just made, not last night's hand.
const MAX_UNDO_STEPS = 12;
const actionHistory = [];
ActionBar.mount(state, {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
beginAction: label => {
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, step.snapshot);
hooks.save(state);
Render.renderAll();
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
},
});
// Anything that ends the hand makes the per-action history meaningless — the
// snapshots refer to a hand that no longer exists.
const clearActionHistory = () => { actionHistory.length = 0; };
// 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
@@ -97,6 +124,9 @@ document.addEventListener("DOMContentLoaded", () => {
const runUndoable = (message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
mutate();
// Starting or settling a hand invalidates the per-action history: those
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
clearActionHistory();
hooks.save(state);
Render.renderAll();
@@ -177,6 +207,7 @@ document.addEventListener("DOMContentLoaded", () => {
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
clearActionHistory();
Render.mount(state, hooks);
Toast.show("Table reset.", { duration: 2500 });
});