Home games don't run like a cardroom. People walk up mid-hand, storm off mid-hand, and pass the button round three times before anyone deals. Probing each of those found four problems, one of which loses chips. A player taken out mid-hand took their chips with them `computeContribution` opened with `if (!player.inHand) return 0`, so the moment a dealer switched a seat off, everything that seat had already put in vanished from the pot. Measured: four players in for $2 each, one taken out, pot drops from $8 to $6. Their $2 was still sitting on the seat record — it just stopped counting. A dealer following the tool would have paid out $6 of an $8 pot. Chips already pushed into the middle are dead money. They stay in the pot and go to whoever wins, whether or not the player is still in the hand, and it is not theirs to take back. The `inHand` check now guards only the table-wide base-pot default, which does have to stay tied to being dealt in or every empty chair would be treated as having anted. The pot wizard counts a departed seat as folded, so its chips are in the pot but it can never be picked as a winner. Passing the button before the cards come out Moving the button left the posted blinds where they were while everything positional started reading off the new spot, so the money and the blind positions silently disagreed. While nothing but the blinds are in, moving the button now refunds them and re-posts from where it lands — pass it round six times and the chip count is unchanged. Once anybody has acted the blinds are settled and it stops offering. Naming the blinds by hand Blinds were purely positional with no way to overrule them. The seat menu now offers "Make small blind" / "Make big blind" while only the blinds are in, and a badge with a ring marks a blind that was named rather than dealt. Naming one pins the other where it is. It's refused if it would put both blinds on one player or post to somebody who isn't dealt in, and it lasts one hand — carrying it forward would freeze the blinds on two people for the night. The first hand, and a table that empties out A fresh table puts the button on seat 1 whether or not anyone is sitting there, and a table can empty out around it. The button is moved onto a real player before a hand is dealt. Joining mid-hand A newcomer was added straight into the live seats and would be asked to act in a hand they hadn't been paying into. They now sit the hand out and are a full player from the next one. Nothing is covered on a phone any more Swept 13 viewport sizes with elementFromPoint, five points per control, on the table and every overlay. Before: the action bar covered the bottom seats on short screens (62px of felt at 360x640), the toast covered up to six seats at once — Undo button included, so a tap meant for a player hit "Undo" — and in landscape the table came out 765px tall in a 390px window, putting six of nine seats off-screen entirely. - the action bar's real height is published as `--actionBarHeight` and drives both the page's bottom padding and the toast, replacing a hardcoded 9rem that was wrong the moment the raise pad opened - landscape sizes the felt from the height that's left, not from width. The first attempt did nothing: `padding-bottom` is a percentage of the containing block's *width*, so narrowing the wrap left it full height, and a `max-width: 900px` rule later in the file was overriding it anyway. It's set outright now and lives at the end of the sheet. - on short screens the toast floats over the action bar's status readout rather than over the seats. Nowhere is clear of both; covering something you re-read beats covering something you tap. - headers go compact on short screens, which is where the vertical room came from 13/13 sizes clean, 360x640 through 844x390, portrait and landscape. Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles rather than clearing, and `manual` is undefined for positional blinds, so the hand-picked ring appeared on alternate renders. 18 new tests (76 total, all passing). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
// 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.watchHeight();
|
|
this.render();
|
|
},
|
|
|
|
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
|
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
|
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
|
// constant either: opening the raise pad grows it. Publishing the measured height
|
|
// as a custom property lets the layout and the toast both stay clear of it without
|
|
// anyone guessing a number.
|
|
watchHeight() {
|
|
const host = document.getElementById("actionBar");
|
|
if (!host || this.heightWatcher) return;
|
|
|
|
const publish = () => {
|
|
const visible = !host.hidden;
|
|
const height = visible ? host.getBoundingClientRect().height : 0;
|
|
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
|
};
|
|
|
|
if (typeof ResizeObserver === "function") {
|
|
this.heightWatcher = new ResizeObserver(publish);
|
|
this.heightWatcher.observe(host);
|
|
}
|
|
window.addEventListener("resize", publish);
|
|
window.addEventListener("orientationchange", publish);
|
|
publish();
|
|
},
|
|
|
|
// 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");
|
|
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
|
this.stopTankTimer();
|
|
this.tankSeatIndex = null;
|
|
return;
|
|
}
|
|
host.hidden = false;
|
|
document.body.classList.add("hasActionBar");
|
|
|
|
host.appendChild(this.renderStatus());
|
|
|
|
const breakdown = this.renderPotBreakdown();
|
|
if (breakdown) host.appendChild(breakdown);
|
|
|
|
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());
|
|
}
|
|
|
|
this.syncTankTimer();
|
|
},
|
|
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// Nobody is being asked to act because nobody can — say so, or the bar just
|
|
// looks like it's waiting for something.
|
|
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
|
const runout = document.createElement("span");
|
|
runout.className = "actionRunout";
|
|
const stillIn = Betting.liveSeats(this.state).length;
|
|
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
|
row.appendChild(runout);
|
|
}
|
|
|
|
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);
|
|
|
|
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,
|
|
contribution: Betting.handTotal(player),
|
|
folded: player.folded,
|
|
}));
|
|
|
|
const nameById = new Map(
|
|
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
|
);
|
|
|
|
return Render.buildPotBreakdown(
|
|
PotCalculator.layers(contributions),
|
|
PotCalculator.uncalledReturns(contributions),
|
|
id => nameById.get(id)
|
|
);
|
|
},
|
|
|
|
// Which key presses each button. Shown on the button itself and hidden on touch
|
|
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
|
// on a phone is just clutter.
|
|
KEY_HINTS: {
|
|
actionFold: "F",
|
|
actionCheck: "C",
|
|
actionCall: "C",
|
|
actionRaise: "R",
|
|
actionAdvance: "↵",
|
|
raiseConfirm: "↵",
|
|
raiseCancel: "esc",
|
|
},
|
|
|
|
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);
|
|
|
|
const hint = this.KEY_HINTS[className];
|
|
if (hint) {
|
|
const kbd = document.createElement("kbd");
|
|
kbd.className = "actionKey";
|
|
kbd.textContent = hint;
|
|
btn.appendChild(kbd);
|
|
}
|
|
|
|
if (sublabel) {
|
|
const sub = document.createElement("span");
|
|
sub.className = "actionBtnSub";
|
|
sub.textContent = sublabel;
|
|
btn.appendChild(sub);
|
|
}
|
|
|
|
btn.addEventListener("click", onClick);
|
|
return btn;
|
|
},
|
|
|
|
// 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();
|
|
},
|
|
|
|
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 who = HoldemState.displayName(player, index);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "actionButtons";
|
|
|
|
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(`${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(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
|
} else {
|
|
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => 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;
|
|
const who = HoldemState.displayName(player, index);
|
|
this.commit(`${who}'s raise to ${Money.format(target)}`, () => 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(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => 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;
|
|
}
|