diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 7a65cbb..ad5b098 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -146,6 +146,14 @@
Everyone who has bought in shows up here, with what they'd be owed if the night ended now.
+ Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.
+
Cash taken
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index ebabf22..5c28d01 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2312,6 +2312,86 @@ body.hasActionBar #holdemSidebar {
font-style: italic;
}
+/* The people standing behind the chairs. A short list in a panel that already has a
+ lot in it, so it is built from rows rather than a table: a name, the seat it can go
+ in, and a way off the list. */
+.waitingAdd {
+ display: flex;
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.waitingAdd input {
+ flex: 1;
+ min-width: 0;
+ min-height: 44px;
+ background: #141414;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ color: #eee;
+ font-family: inherit;
+ /* 16px, or iOS zooms the whole page in when it takes focus. */
+ font-size: 16px;
+ padding: 0.35rem 0.6rem;
+}
+
+.waitingAdd .holdemBtn {
+ flex: none;
+ min-height: 44px;
+}
+
+.waitingList {
+ list-style: none;
+ counter-reset: waiting;
+ margin: 0 0 0.5rem;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.waitingRow {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ counter-increment: waiting;
+}
+
+/* The position in the queue, which is the whole point of the list. */
+.waitingRow::before {
+ content: counter(waiting) ".";
+ flex: none;
+ min-width: 1.2rem;
+ color: #9a9a9a;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.waitingName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #e8e8e8;
+ font-size: 0.85rem;
+}
+
+.waitingNote {
+ flex: none;
+ color: #9a9a9a;
+ font-size: 0.75rem;
+}
+
+.waitingRow .holdemBtn {
+ flex: none;
+ min-height: 44px;
+}
+
+.waitingDropBtn {
+ min-width: 44px;
+}
+
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 4f40c65..07084bf 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
+
+ // The waiting list, from the Session panel and from an empty chair's menu.
+ // The state change goes inside runUndoable, not before it: the snapshot is
+ // taken as the first thing that happens, so anything done ahead of it is
+ // already baked into what Undo would restore.
+ seatWaiting: (id, index) => {
+ const entry = (state.waitingList || []).find(row => row.id === id);
+ const seat = state.seats[index];
+ if (!entry || !seat) {
+ Toast.show("They aren't on the list any more.", { duration: 3000 });
+ hooks.rerender();
+ return;
+ }
+ const occupied = (seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0;
+ if (occupied) {
+ Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
+ hooks.rerender();
+ return;
+ }
+ runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
+ () => HoldemState.seatFromWaitingList(state, id, index));
+ },
+
+ dropWaiting: id => {
+ const entry = (state.waitingList || []).find(row => row.id === id);
+ if (!entry) return;
+ runUndoable(`${entry.name} taken off the waiting list.`,
+ () => HoldemState.removeFromWaitingList(state, id));
+ },
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
@@ -403,6 +432,16 @@ document.addEventListener("DOMContentLoaded", () => {
// A misdeal. Everything in the middle goes back to whoever put it there, and the
// same button deals the same hand again.
+ document.getElementById("waitingListForm").addEventListener("submit", e => {
+ e.preventDefault();
+ const field = document.getElementById("waitingNameInput");
+ const name = field.value.trim();
+ if (!name) return;
+ field.value = "";
+ runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
+ field.focus();
+ });
+
document.getElementById("misdealBtn").addEventListener("click", async () => {
const inPlay = HoldemState.unresolvedContributions(state);
const acted = state.seats.filter(p => p.hasActed).length;
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 43a4cba..b245a25 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -1058,9 +1058,63 @@ const Render = {
this.renderBankReconciliation();
this.renderSettleUp(standings);
+ this.renderWaitingList();
this.renderStorageWarning();
},
+ // Who is standing behind the chairs, in the order they turned up. Each one carries
+ // the two things a dealer does with them: sit them down, or take them off the list
+ // because they've gone to the bar.
+ renderWaitingList() {
+ const list = document.getElementById("waitingList");
+ const hint = document.getElementById("waitingEmptyHint");
+ if (!list) return;
+
+ const waiting = this.state.waitingList || [];
+ const open = HoldemState.openSeats(this.state);
+ list.innerHTML = "";
+ hint.hidden = waiting.length > 0;
+
+ waiting.forEach((entry, position) => {
+ const row = document.createElement("li");
+ row.className = "waitingRow";
+
+ const name = document.createElement("span");
+ name.className = "waitingName";
+ name.textContent = entry.name;
+ row.appendChild(name);
+
+ // Only the person at the front is offered a seat, because a list nobody
+ // keeps in order is just a pile of names — and jumping the queue is the
+ // thing the dealer gets shouted at for.
+ if (position === 0 && open.length > 0) {
+ const seat = document.createElement("button");
+ seat.type = "button";
+ seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
+ seat.textContent = `Seat ${open[0] + 1}`;
+ seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
+ seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
+ row.appendChild(seat);
+ } else if (position === 0) {
+ const full = document.createElement("span");
+ full.className = "waitingNote";
+ full.textContent = "table full";
+ row.appendChild(full);
+ }
+
+ const drop = document.createElement("button");
+ drop.type = "button";
+ drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
+ drop.textContent = "✕";
+ drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
+ drop.title = `Take ${entry.name} off the list`;
+ drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
+ row.appendChild(drop);
+
+ list.appendChild(row);
+ });
+ },
+
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
index a90c5b2..8655f7a 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -47,6 +47,21 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
+ // An empty chair with people waiting: the dealer taps the chair somebody is
+ // about to sit in, which is what their hand is doing anyway. Only the front of
+ // the queue is offered, for the same reason the panel only offers them — a list
+ // that isn't kept in order is just a pile of names.
+ const waiting = (this.state.waitingList || [])[0];
+ const emptyChair = !(player.name || "").trim() &&
+ !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0);
+ if (waiting && emptyChair) {
+ actions.push({
+ label: `Seat ${waiting.name}`,
+ className: "menuAccent",
+ run: () => this.hooks.seatWaiting(waiting.id, 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.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index c8ae787..717d8c5 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -115,6 +115,10 @@ const HoldemState = {
// out, and both halves have to stay on the record or the bank check reads
// as a shortfall for the rest of the evening.
cashedOut: [],
+ // People standing behind the chairs. A dealer at a full table keeps this
+ // list on a napkin; keeping it here means the tool can offer the next seat
+ // that opens to whoever has been waiting longest.
+ waitingList: [],
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
@@ -233,6 +237,16 @@ const HoldemState = {
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
+ parsed.waitingList = Array.isArray(parsed.waitingList)
+ ? parsed.waitingList
+ .map(row => (typeof row === "string" ? { id: null, name: row } : row))
+ .filter(row => row && String(row.name || "").trim())
+ .map(row => ({
+ id: row.id || this.makeId("waiting"),
+ name: String(row.name).trim().slice(0, 24),
+ addedAt: Number(row.addedAt) || 0,
+ }))
+ : [];
parsed.cashedOut = Array.isArray(parsed.cashedOut)
? parsed.cashedOut
.filter(row => row && row.id)
@@ -875,6 +889,59 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
+ // ---- The people standing behind the chairs ----
+ //
+ // A full home game always has a couple of them, and the list lives on a napkin or
+ // in the dealer's head — which is where the argument comes from an hour later about
+ // who said they were next. Kept in order of arrival, because that is the only
+ // ordering anybody has ever agreed to.
+
+ addToWaitingList(state, name) {
+ const trimmed = String(name || "").trim().slice(0, 24);
+ if (!trimmed) return null;
+ if (!Array.isArray(state.waitingList)) state.waitingList = [];
+ const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
+ state.waitingList.push(entry);
+ return entry;
+ },
+
+ removeFromWaitingList(state, id) {
+ if (!Array.isArray(state.waitingList)) return null;
+ const index = state.waitingList.findIndex(row => row.id === id);
+ if (index < 0) return null;
+ return state.waitingList.splice(index, 1)[0];
+ },
+
+ // Chairs with nobody in them, in seat order. What "a seat has opened" means, and
+ // the same question the deal prompt answers when it counts the players.
+ openSeats(state) {
+ return state.seats
+ .map((player, index) => ({ player, index }))
+ .filter(({ player }) => !(player.name || "").trim() &&
+ !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0))
+ .map(({ index }) => index);
+ },
+
+ // Sit the next person down. Their name goes on the chair and nothing else does:
+ // they haven't bought in yet, and guessing a stack for them would put chips on the
+ // table that nobody has paid for — which is the one thing the bank check exists to
+ // catch.
+ seatFromWaitingList(state, id, index) {
+ const seat = state.seats[index];
+ if (!seat) return { ok: false, reason: "There's no such seat." };
+ if ((seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0) {
+ return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
+ }
+ const entry = this.removeFromWaitingList(state, id);
+ if (!entry) return { ok: false, reason: "They aren't on the list any more." };
+
+ seat.name = entry.name;
+ // Not dealt in and holding nothing: the next thing that happens is the dealer
+ // taking their money, and Add chips is what says so.
+ seat.inHand = false;
+ return { ok: true, name: entry.name, index };
+ },
+
// ---- Somebody leaving with their chips ----
//
// The most ordinary thing that happens at a home game after midnight, and the tool
diff --git a/tests/holdem/waitingList.test.js b/tests/holdem/waitingList.test.js
new file mode 100644
index 0000000..ec791bd
--- /dev/null
+++ b/tests/holdem/waitingList.test.js
@@ -0,0 +1,167 @@
+// The people standing behind the chairs.
+//
+// A full home game always has a couple of them, and the list lives on a napkin or in
+// the dealer's head — which is where the argument comes from an hour later about who
+// said they were next. Order of arrival, because it is the only ordering anybody has
+// ever agreed to.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HoldemState } = require("./load");
+
+const table = (seated = 2, seats = 4) => {
+ const state = HoldemState.createDefault();
+ HoldemState.resizeSeats(state, seats);
+ ["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
+ state.seats[i].name = name;
+ state.seats[i].stack = 200;
+ state.seats[i].buyIns = 200;
+ });
+ return state;
+};
+
+describe("keeping the list", () => {
+ test("names go on in the order they arrive", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.addToWaitingList(state, "Sam");
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
+ });
+
+ test("whitespace is trimmed and nothing empty gets on", () => {
+ const state = table();
+ assert.ok(HoldemState.addToWaitingList(state, " Priya "));
+ assert.equal(HoldemState.addToWaitingList(state, " "), null);
+ assert.equal(HoldemState.addToWaitingList(state, ""), null);
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
+ });
+
+ test("two people with the same name are two people", () => {
+ // Home games have two Mikes. The list is keyed by id, not by name.
+ const state = table();
+ HoldemState.addToWaitingList(state, "Mike");
+ HoldemState.addToWaitingList(state, "Mike");
+ assert.equal(state.waitingList.length, 2);
+ assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
+
+ HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
+ assert.equal(state.waitingList.length, 1, "only one of them leaves");
+ });
+
+ test("taking somebody off gives them back, so the toast can name them", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
+ assert.equal(gone.name, "Priya");
+ assert.deepEqual(state.waitingList, []);
+ assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
+ });
+});
+
+describe("which chairs are open", () => {
+ test("the ones with nobody in them, in seat order", () => {
+ assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
+ assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
+ });
+
+ test("a chair with chips or a buy-in on it is not open", () => {
+ // Somebody who has stepped away from the table still owns their seat.
+ const state = table(2, 4);
+ state.seats[2].stack = 120;
+ state.seats[3].buyIns = 100;
+ assert.deepEqual(HoldemState.openSeats(state), []);
+ });
+});
+
+describe("sitting somebody down", () => {
+ test("their name goes on the chair and they come off the list", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.addToWaitingList(state, "Sam");
+ const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
+
+ assert.equal(result.ok, true);
+ assert.equal(state.seats[2].name, "Priya");
+ assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
+ });
+
+ test("with nothing in front of them, because they haven't bought in yet", () => {
+ // Guessing a stack would put chips on the table nobody has paid for, which is
+ // the one thing the bank check exists to catch.
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
+
+ assert.equal(state.seats[2].stack, null);
+ assert.equal(state.seats[2].buyIns, 0);
+ assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+
+ test("never into a chair somebody is already in", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
+ assert.equal(result.ok, false);
+ assert.match(result.reason, /Ann is already/);
+ assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
+ });
+
+ test("nor from a name that has already been seated", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const id = state.waitingList[0].id;
+ HoldemState.seatFromWaitingList(state, id, 2);
+ const again = HoldemState.seatFromWaitingList(state, id, 3);
+ assert.equal(again.ok, false);
+ assert.equal(state.seats[3].name, "", "the second chair is untouched");
+ });
+
+ test("a seat that doesn't exist is refused rather than invented", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
+ assert.equal(state.waitingList.length, 1);
+ });
+});
+
+describe("a seat opening up", () => {
+ test("cashing somebody out is what makes room for the next one", () => {
+ const state = table(4, 4);
+ HoldemState.addToWaitingList(state, "Priya");
+ assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
+
+ HoldemState.cashOut(state, 1);
+ assert.deepEqual(HoldemState.openSeats(state), [1]);
+
+ const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
+ assert.equal(seated.ok, true);
+ assert.equal(state.seats[1].name, "Priya");
+ // Ben's night is still on the record, and the money still adds up.
+ assert.equal(state.cashedOut.length, 1);
+ assert.equal(HoldemState.bankReconciliation(state).balanced, true);
+ });
+});
+
+describe("what a saved table remembers", () => {
+ test("the list survives a reload", () => {
+ const state = table();
+ HoldemState.addToWaitingList(state, "Priya");
+ const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
+ assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
+ });
+
+ test("a table saved before the list existed has nobody waiting", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ delete parsed.waitingList;
+ assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
+ });
+
+ test("junk in a saved list is cleaned up rather than trusted", () => {
+ const parsed = JSON.parse(JSON.stringify(table()));
+ parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
+ const migrated = HoldemState.migrate(parsed);
+ assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
+ assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
+ });
+});