Hold'em dealer: 55 polish rounds, one merge #101

Merged
jheaps merged 71 commits from release/casino-holdem-polish into master 2026-09-02 10:00:15 -06:00
6 changed files with 205 additions and 7 deletions
Showing only changes of commit 3fe16fa7f2 - Show all commits
@@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}, },
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
const movedName = nameOf(from);
const intoName = nameOf(to);
const wasEmpty = Render.isEmptySeat(state.seats[to]);
runUndoable(
wasEmpty
? `${movedName} moved to seat ${to + 1}.`
: `${movedName} and ${intoName} changed seats.`,
() => HoldemState.swapSeats(state, from, to));
},
makeDealer: index => { makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker. // Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the // After it, the blinds have to follow the button or the badges and the
@@ -49,11 +49,21 @@ const Render = {
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? ""; document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
}, },
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() { renderTableHint() {
const hint = document.getElementById("tableHint"); const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the // Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so. // only place on the table with room to say so.
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."; hint.textContent = this.canDragSeats()
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
}, },
renderNotes() { renderNotes() {
@@ -6,10 +6,16 @@ const SeatMenu = {
hooks: null, hooks: null,
seatIndex: null, seatIndex: null,
mode: null,
open(state, hooks, seatIndex) { open(state, hooks, seatIndex) {
this.state = state; this.state = state;
this.hooks = hooks; this.hooks = hooks;
this.seatIndex = seatIndex; 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(); this.render();
document.getElementById("seatMenuOverlay").hidden = false; document.getElementById("seatMenuOverlay").hidden = false;
}, },
@@ -63,29 +69,81 @@ const SeatMenu = {
} }
} }
// 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) }); actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions; 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() { render() {
const player = this.state.seats[this.seatIndex]; const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, 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 stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player); const owed = Betting.toCall(this.state, player);
const bits = []; const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`); if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`); if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · "); subtitle.textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => { this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`; btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label; btn.textContent = action.label;
btn.addEventListener("click", () => { 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(); this.close();
action.run(); action.run();
}); });
@@ -467,6 +467,21 @@ const HoldemState = {
.sort((a, b) => b.net - a.net); .sort((a, b) => b.net - a.net);
}, },
// Two players change chairs.
//
// The whole seat moves — name, stack, cards, what they have in the pot — because
// that is what changing chairs is. What stays behind is everything that belongs to
// the *position*: the button, the blinds and the acting index all name a chair, and
// a player who moves into the big blind's chair is the big blind. That is the same
// answer a card room gives.
swapSeats(state, a, b) {
const seats = state.seats || [];
if (a === b) return false;
if (!seats[a] || !seats[b]) return false;
[seats[a], seats[b]] = [seats[b], seats[a]];
return true;
},
// The one check a banker actually needs, and the one nothing here did. // The one check a banker actually needs, and the one nothing here did.
// //
// Every chip on the table is a promise to pay cash for it later. If the chips in // Every chip on the table is a promise to pay cash for it later. If the chips in
+95
View File
@@ -0,0 +1,95 @@
// Two players changing chairs.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("the whole seat moves", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].name = "Ann";
state.seats[0].cards = ["AS", "KD"];
state.seats[0].stack = 175;
state.seats[4].name = "";
HoldemState.swapSeats(state, 0, 4);
assert.equal(state.seats[4].name, "Ann");
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
assert.equal(state.seats[4].stack, 175);
assert.equal(state.seats[0].name, "");
});
test("what they have in the pot goes with them", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
const posted = state.seats.map(p => p.committed);
const before = chipsOnTable(state);
HoldemState.swapSeats(state, 1, 4);
assert.equal(state.seats[4].committed, posted[1]);
assert.equal(state.seats[1].committed, posted[4]);
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
});
test("the button stays on the chair, not on the player", () => {
// A card room moves the button around the table, not around the people. Somebody
// who sits down in the big blind's chair is the big blind.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
state.seats[0].name = "Ann";
HoldemState.swapSeats(state, 0, 2);
assert.equal(state.dealerSeatIndex, 0);
assert.notEqual(state.seats[0].name, "Ann");
});
test("the blinds follow the chairs too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const before = HoldemState.getBlindSeats(state);
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
const after = HoldemState.getBlindSeats(state);
assert.equal(after.sbIndex, before.sbIndex);
assert.equal(after.bbIndex, before.bbIndex);
});
test("swapping a seat with itself does nothing and says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
assert.equal(JSON.stringify(state), before);
});
test("a chair that isn't there is refused rather than swallowed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
assert.equal(JSON.stringify(state), before);
});
test("swapping twice puts everything back", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
const before = JSON.stringify(state);
HoldemState.swapSeats(state, 1, 3);
HoldemState.swapSeats(state, 1, 3);
assert.equal(JSON.stringify(state), before);
});
test("moving into an empty chair leaves an empty chair behind", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[1].name = "Ben";
HoldemState.swapSeats(state, 1, 5);
assert.equal(state.seats[5].name, "Ben");
assert.equal(state.seats[5].inHand, true);
assert.equal(state.seats[1].inHand, false);
});
+9 -2
View File
@@ -522,8 +522,15 @@ const LayoutAudit = {
const chips = [...document.querySelectorAll(".dealerChip")]; const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => { const shown = chips.filter(chip => {
const style = getComputedStyle(chip); const style = getComputedStyle(chip);
return style.display !== "none" && style.visibility !== "hidden" && if (style.display === "none" || style.visibility === "hidden") return false;
parseFloat(style.opacity) > 0.05; if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
}); });
const offenders = []; const offenders = [];