The most ordinary thing that happens at a home game after midnight — somebody counts up and goes home — and the tool had nothing for it. Freeing the chair meant wiping the seat by hand, and the bank check then declared their whole stack missing: "someone has paid for chips they haven't been given", for the rest of the night. Measured on a balanced table, cashing a player out with $350 in front of them left the check $350 short. Wiping their buy-in as well silenced it, at the price of losing their entire night from the standings and from the file the dealer hands over at the end. Cashing out is two facts, and both have to survive the empty chair: the box took their cash, and the box handed their chips back. Both are kept now, so the chair opens and the money still adds up. Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" — what they bought in for, and whether they leave up or down, because that is the sentence the dealer says while their hands are moving. The bank check gains a Paid out line and reads "balanced ... once the $350 paid out to players who have left is counted", alongside the discards and the house's cut. The standings keep them with a stripe and the words "· paid out", so nobody counts money out to them twice at 2am, and the export marks them the same way. It refuses to pay anybody with money still in the middle: taking chips out of a pot other people can win, or handing the same chips over twice, are both worse than waiting for the hand to finish. The chair gets a fresh id, because the person who had it has gone and the next player to sit down is not them. Two things this turned up. The first draft worked out what a seat was holding by itself, read `Number(null)` as a perfectly good zero, and offered to pay a player with no stack set nothing at all — it asks Betting now, which owns that rule. The second was mine and the audit caught it: marking the departed row with opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer settles from. It is a stripe instead. 19 new tests, with the departed buy-in dropped from the check and the pot guard disabled on purpose to confirm they fail. Walked through in the browser: menu, confirm, payout, bank, standings, undo, and the refusal. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
165 lines
7.1 KiB
JavaScript
165 lines
7.1 KiB
JavaScript
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
|
// one press and one tap away, without opening the full editor and without hunting
|
|
// for whose turn it is in the action bar.
|
|
const SeatMenu = {
|
|
state: null,
|
|
hooks: null,
|
|
seatIndex: null,
|
|
|
|
mode: null,
|
|
|
|
open(state, hooks, seatIndex) {
|
|
this.state = state;
|
|
this.hooks = hooks;
|
|
this.seatIndex = seatIndex;
|
|
// Always the action list, never whatever the sheet was showing when it last
|
|
// closed — a seat menu that opens on a list of chairs is a menu that lost the
|
|
// dealer's place.
|
|
this.mode = null;
|
|
this.render();
|
|
document.getElementById("seatMenuOverlay").hidden = false;
|
|
},
|
|
|
|
close() {
|
|
document.getElementById("seatMenuOverlay").hidden = true;
|
|
},
|
|
|
|
// Only the actions that make sense for this seat right now — a folded player has
|
|
// nothing to go all-in with, and an empty chair has nothing to fold.
|
|
actionsFor(player, index) {
|
|
const actions = [];
|
|
const dealtIn = player.inHand;
|
|
const live = dealtIn && !player.folded;
|
|
|
|
if (live) {
|
|
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
|
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
|
} else if (dealtIn && player.folded) {
|
|
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
|
}
|
|
|
|
// The most common interruption in a cash game, and until now it meant opening
|
|
// the editor and retyping a stack from memory.
|
|
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
|
|
|
|
actions.push({
|
|
label: dealtIn ? "Sit out" : "Deal in",
|
|
run: () => this.hooks.toggleDealtIn(index),
|
|
});
|
|
|
|
// Leaving for the night, which is a different thing from sitting out a hand:
|
|
// the chips go back over the rail and the chair opens. Offered only for a seat
|
|
// that has somebody in it, since an empty chair has nobody to pay.
|
|
if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) {
|
|
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
|
}
|
|
|
|
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
|
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
|
}
|
|
|
|
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
|
// once somebody has called, moving a blind would mean unpicking a bet that
|
|
// has already been answered, and that's a decision for the dealer's mouth,
|
|
// not a menu item. Which blind a seat already has is shown rather than
|
|
// offered again.
|
|
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
|
const blinds = HoldemState.getBlindSeats(this.state);
|
|
if (blinds.sbIndex !== index) {
|
|
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
|
}
|
|
if (blinds.bbIndex !== index) {
|
|
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
|
}
|
|
if (this.state.blindOverride) {
|
|
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
|
}
|
|
}
|
|
|
|
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
|
// generated from a touch screen — so on the device this tool is mostly used on,
|
|
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
|
// same move, by the route a thumb can actually take.
|
|
if (this.state.seats.length > 1) {
|
|
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
|
}
|
|
|
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
|
return actions;
|
|
},
|
|
|
|
// The chairs this player could move to. Every other chair: swapping into an empty
|
|
// one is how a player moves, and swapping with an occupied one is two players
|
|
// changing places, which is the other half of the same job.
|
|
destinationsFor(index) {
|
|
return this.state.seats
|
|
.map((player, seat) => ({ player, seat }))
|
|
.filter(entry => entry.seat !== index);
|
|
},
|
|
|
|
openMovePicker() {
|
|
this.mode = "move";
|
|
this.render();
|
|
},
|
|
|
|
render() {
|
|
const player = this.state.seats[this.seatIndex];
|
|
const title = document.getElementById("seatMenuTitle");
|
|
const subtitle = document.getElementById("seatMenuSubtitle");
|
|
const list = document.getElementById("seatMenuActions");
|
|
list.innerHTML = "";
|
|
|
|
if (this.mode === "move") {
|
|
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
|
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
|
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "seatMenuBtn";
|
|
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
|
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
|
const named = (other.name || "").trim();
|
|
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
|
btn.addEventListener("click", () => {
|
|
const from = this.seatIndex;
|
|
this.close();
|
|
this.hooks.swapSeats(from, seat);
|
|
});
|
|
list.appendChild(btn);
|
|
});
|
|
return;
|
|
}
|
|
|
|
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
|
|
|
const stack = Betting.stackOf(this.state, player);
|
|
const owed = Betting.toCall(this.state, player);
|
|
const bits = [];
|
|
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
|
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
|
subtitle.textContent = bits.join(" · ");
|
|
|
|
this.actionsFor(player, this.seatIndex).forEach(action => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `seatMenuBtn ${action.className || ""}`;
|
|
btn.textContent = action.label;
|
|
btn.addEventListener("click", () => {
|
|
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
|
// other action is the end of the interaction.
|
|
if (action.label === "Move to…") {
|
|
action.run();
|
|
return;
|
|
}
|
|
this.close();
|
|
action.run();
|
|
});
|
|
list.appendChild(btn);
|
|
});
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = SeatMenu;
|
|
}
|