Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1689ecb4f0 | ||
|
|
55eb569d3b |
@@ -146,6 +146,14 @@
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<h3 class="panelSubhead">Waiting list</h3>
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
@@ -168,6 +176,7 @@
|
||||
<span id="bankPaidOut"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,34 @@ 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.
|
||||
// The seats holding the table's starting stack, written down as cash taken.
|
||||
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
||||
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
if (starting.length === 0) return;
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Write down ${Money.format(total)} taken?`,
|
||||
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
||||
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
||||
confirmLabel: "Write it down",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
||||
() => HoldemState.recordStartingBuyIns(state));
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
@@ -1210,14 +1264,43 @@ const Render = {
|
||||
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
|
||||
: "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
|
||||
// dealer's next move is to go to a particular seat, and the tool knows
|
||||
// which. The commonest cause by far is the table's own starting stack:
|
||||
// seats holding chips that were never bought, because "everyone starts
|
||||
// with 200" is how a night opens.
|
||||
const owing = HoldemState.seatsOwingCash(this.state);
|
||||
const names = owing.map(row => row.name);
|
||||
const who = names.length === 1
|
||||
? names[0]
|
||||
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
verdict.textContent = owing.length > 0
|
||||
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
|
||||
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
|
||||
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
} else {
|
||||
verdict.textContent =
|
||||
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
||||
}
|
||||
verdict.classList.toggle("bankBalanced", bank.balanced);
|
||||
verdict.classList.toggle("bankOff", !bank.balanced);
|
||||
|
||||
// One tap for the seats that are only holding the table's starting stack —
|
||||
// which is the dealer's own instruction, said once in the setup panel, rather
|
||||
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
|
||||
// sentence above and left to them, because only they know what it was.
|
||||
const record = document.getElementById("bankRecordBuyInsBtn");
|
||||
if (record) {
|
||||
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
record.hidden = starting.length === 0;
|
||||
if (starting.length > 0) {
|
||||
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
|
||||
record.title = starting.length === 1
|
||||
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
|
||||
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -789,7 +803,7 @@ const HoldemState = {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
this.chipsHeldBy(state, player) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
@@ -807,7 +821,7 @@ const HoldemState = {
|
||||
// the way out. It is no longer on the table, so it is marked as paid
|
||||
// rather than counted again at the end of the night.
|
||||
const chipsNow = seat
|
||||
? Money.round(Number(seat.stack) || 0)
|
||||
? this.chipsHeldBy(state, seat)
|
||||
: (gone ? gone.chips : 0);
|
||||
// Chips already pushed into the hand in progress are still this
|
||||
// player's money until it's won. Leaving them out makes the cash
|
||||
@@ -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
|
||||
@@ -1014,6 +1081,71 @@ const HoldemState = {
|
||||
};
|
||||
},
|
||||
|
||||
// Is anybody actually in this chair? A seat with a name on it, chips in front of
|
||||
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
|
||||
// matters the moment the table's starting stack starts standing in for a seat that
|
||||
// has never been given one — every empty chair would otherwise look like somebody
|
||||
// holding two hundred dollars.
|
||||
seatInPlay(player) {
|
||||
if (!player) return false;
|
||||
return !!(player.name || "").trim() ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
player.inHand === true;
|
||||
},
|
||||
|
||||
// What a seat is holding, the way the felt and the betting see it — which includes
|
||||
// the table's starting stack standing in for a seat that has never been given one.
|
||||
//
|
||||
// This is the number the money side of the tool was missing. "Starting stack" is
|
||||
// offered in the setup panel as the ordinary way to open a table, and everything a
|
||||
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
|
||||
// check and the standings read `seat.stack` directly instead, which is null for
|
||||
// exactly those seats: measured on a three-handed table with a $200 starting stack
|
||||
// and nobody's own stack set, the felt played for $600 while the bank check said
|
||||
// "no chips in play, balanced" and the standings listed nobody at all.
|
||||
chipsHeldBy(state, player) {
|
||||
if (!this.seatInPlay(player)) return 0;
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
// stack has never been given chips of its own — the dealer set "everyone starts
|
||||
// with 200" and sat people down, which is the ordinary way to open a table and the
|
||||
// reason this whole gap existed. A seat with its own stack and no buy-in is a
|
||||
// rebuy somebody forgot to write down, and only the dealer knows what it was.
|
||||
seatsOwingCash(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
|
||||
!((Number(player.buyIns) || 0) > 0))
|
||||
.map(({ player, index }) => ({
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips: this.chipsHeldBy(state, player),
|
||||
// True when they are holding the table's starting stack rather than one
|
||||
// the dealer typed for them.
|
||||
fromStartingStack: player.stack === null || player.stack === undefined ||
|
||||
String(player.stack).trim() === "",
|
||||
}));
|
||||
},
|
||||
|
||||
// Write down the cash for the seats that are holding the table's starting stack.
|
||||
// Only those: what somebody else's unrecorded rebuy was is a question only the
|
||||
// dealer can answer, and guessing it would be the tool inventing money.
|
||||
recordStartingBuyIns(state) {
|
||||
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
|
||||
return {
|
||||
seats: owed.length,
|
||||
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
bankReconciliation(state) {
|
||||
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
||||
// Cash the box took from people who have since left counts as taken: it is in
|
||||
@@ -1024,7 +1156,7 @@ const HoldemState = {
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
this.chipsHeldBy(state, p) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// The chips the felt plays with are the chips the box is checked against.
|
||||
//
|
||||
// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
|
||||
// "seats without their own stack start here" — and everything a dealer looks at reads
|
||||
// it: the puck, the action bar, what a player is allowed to bet. The money side did
|
||||
// not. It read `seat.stack` directly, which is null for exactly those seats, so a
|
||||
// three-handed table playing for $600 reported no chips in play, a balanced bank and a
|
||||
// standings list with nobody on it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting } = require("./load");
|
||||
|
||||
const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.defaultStack = stack;
|
||||
names.forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what the table is playing for", () => {
|
||||
test("is what the bank counts", () => {
|
||||
const state = openedWithStartingStack();
|
||||
// What the felt says each of them has.
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||
});
|
||||
|
||||
test("and the box being empty is a problem it now reports", () => {
|
||||
const bank = HoldemState.bankReconciliation(openedWithStartingStack());
|
||||
assert.equal(bank.cashIn, 0);
|
||||
assert.equal(bank.difference, 600);
|
||||
assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
|
||||
});
|
||||
|
||||
test("empty chairs are not people holding two hundred dollars", () => {
|
||||
// The starting stack stands in for a seat that hasn't been given one, and every
|
||||
// empty chair is such a seat. Counting them would invent $600 of chips at an
|
||||
// empty table.
|
||||
const state = openedWithStartingStack();
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
|
||||
|
||||
const empty = HoldemState.createDefault();
|
||||
empty.defaultStack = 200;
|
||||
assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
|
||||
assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
|
||||
});
|
||||
|
||||
test("once the cash is written down it balances", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table where everyone has their own stack is unchanged", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
["Ann", "Ben"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].stack = 150;
|
||||
state.seats[i].buyIns = 150;
|
||||
});
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.chipsInPlay, 300);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who is on the settle-up list", () => {
|
||||
test("everyone holding chips, including the ones holding the table's own stack", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
|
||||
});
|
||||
|
||||
test("with nothing bought in yet, which is the thing to notice", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
|
||||
assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming the seats the cash is missing for", () => {
|
||||
test("says who, and which kind of missing it is", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
|
||||
|
||||
const owing = HoldemState.seatsOwingCash(state);
|
||||
assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
|
||||
assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
|
||||
});
|
||||
|
||||
test("a seat whose buy-in is written down isn't on the list", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[0].buyIns = 200;
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
|
||||
});
|
||||
|
||||
test("and a table where nothing is owed has nobody on it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing the starting stacks down", () => {
|
||||
test("records exactly what those seats are holding", () => {
|
||||
const state = openedWithStartingStack();
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 3);
|
||||
assert.equal(done.total, 600);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and leaves somebody else's forgotten rebuy alone", () => {
|
||||
// Only the dealer knows what that was. Guessing would be the tool inventing
|
||||
// money, which is the fault this whole round is about.
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320;
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
|
||||
});
|
||||
|
||||
test("does nothing on a table that doesn't need it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 0);
|
||||
assert.equal(done.total, 0);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("through a hand", () => {
|
||||
test("the money adds up from the deal to the payout", () => {
|
||||
const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
|
||||
|
||||
[3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
const pot = Betting.potSoFar(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
|
||||
assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user