diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 59881ed..667a39a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -69,7 +69,7 @@ document.addEventListener("DOMContentLoaded", () => { hooks.rerender(); return; } - const occupied = (seat.name || "").trim() || Number(seat.buyIns) > 0 || Number(seat.stack) > 0; + const occupied = HoldemState.seatInPlay(seat); if (occupied) { Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 }); hooks.rerender(); @@ -366,12 +366,11 @@ document.addEventListener("DOMContentLoaded", () => { // in progress. Shrinking the table throws these away permanently, and it also // orphans them in the session standings, where they keep their winnings but lose // the stack and buy-in the settle-up is worked out from. - const seatHasSomeoneIn = player => - !!(player.name && player.name.trim()) || - player.inHand || - (Number(player.stack) || 0) > 0 || - (Number(player.buyIns) || 0) > 0 || - (Number(player.amountOverride) || 0) > 0; + // One question, asked in one place. This predicate existed here, in the seat menu, + // in the action bar and twice in the state module, in four slightly different + // spellings — and a chair that counts as occupied for the waiting list but empty + // for the seat count is how somebody's chips get thrown away. + const seatHasSomeoneIn = player => HoldemState.seatInPlay(player); document.getElementById("seatCountInput").addEventListener("change", async e => { const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount)); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 12eb7fb..25607fd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -936,14 +936,20 @@ const Render = { const list = document.getElementById("colourUpList"); if (!host || !list) return; + // What each player is holding, asked the same way the felt asks it — which + // includes the table's starting stack standing in for a seat that has never + // been given one. Reading `player.stack` here meant that a table opened the + // ordinary way, with "everyone starts with 200", showed an empty colour-up + // panel: nobody to count out, at the table where every single stack is a + // round number made of the largest chips there are. const legend = this.state.chipLegend || []; const rows = this.state.seats - .map((player, index) => ({ player, index })) - .filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0) - .map(({ player, index }) => ({ + .map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) })) + .filter(({ held }) => held > 0) + .map(({ player, index, held }) => ({ name: HoldemState.displayName(player, index), - stack: Betting.round(Number(player.stack) || 0), - chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend), + stack: held, + chips: Money.formatChipBreakdown(held, legend), })) .filter(row => row.chips); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index 8655f7a..c86b436 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -52,8 +52,7 @@ const SeatMenu = { // 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); + const emptyChair = !HoldemState.seatInPlay(player); if (waiting && emptyChair) { actions.push({ label: `Seat ${waiting.name}`, @@ -65,7 +64,7 @@ const SeatMenu = { // 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) { + if (HoldemState.seatInPlay(player)) { actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) }); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 420ab27..7f1d75c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -917,8 +917,7 @@ const HoldemState = { openSeats(state) { return state.seats .map((player, index) => ({ player, index })) - .filter(({ player }) => !(player.name || "").trim() && - !(Number(player.buyIns) > 0) && !(Number(player.stack) > 0)) + .filter(({ player }) => !this.seatInPlay(player)) .map(({ index }) => index); }, @@ -929,7 +928,7 @@ const HoldemState = { 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) { + if (this.seatInPlay(seat)) { return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` }; } const entry = this.removeFromWaitingList(state, id); diff --git a/tests/holdem/oneStackRule.test.js b/tests/holdem/oneStackRule.test.js new file mode 100644 index 0000000..1184831 --- /dev/null +++ b/tests/holdem/oneStackRule.test.js @@ -0,0 +1,133 @@ +// One question, one answer. +// +// The round before this found the bank check and the standings reading `seat.stack` +// where the felt reads the table's starting stack — two parts of the tool disagreeing +// about how much money is on the table. This is the sweep that followed: everywhere +// else the same two questions get asked, asked the same way. +// +// "What is this seat holding?" -> HoldemState.chipsHeldBy +// "Is anybody in this chair?" -> HoldemState.seatInPlay +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { HoldemState, Betting, SettleUp } = require("./load"); + +const openedOnTheDefault = (stack = 200) => { + const state = HoldemState.createDefault(); + HoldemState.resizeSeats(state, 6); + state.defaultStack = stack; + ["Ann", "Ben", "Cal"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].inHand = true; + state.seats[i].buyIns = stack; + }); + return state; +}; + +describe("what a seat is holding", () => { + test("is the same number the felt plays with", () => { + const state = openedOnTheDefault(); + state.seats.forEach((player, i) => { + if (i > 2) return; + assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player)); + }); + }); + + test("and is nothing at all for an empty chair", () => { + const state = openedOnTheDefault(); + // Betting's own answer for an empty chair is the table default, because it is + // only ever asked about seats that are in a hand. The money side has to ask + // about all ten chairs, so it checks whether anybody is in them first. + assert.equal(Betting.stackOf(state, state.seats[5]), 200); + assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0); + }); + + test("counts a stack the dealer typed over the table's default", () => { + const state = openedOnTheDefault(); + state.seats[0].stack = 75; + assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75); + }); +}); + +describe("is anybody in this chair", () => { + const empty = () => HoldemState.createDefault().seats[0]; + + test("an untouched chair is empty", () => { + assert.equal(HoldemState.seatInPlay(empty()), false); + assert.equal(HoldemState.seatInPlay(null), false); + }); + + test("a name, chips, cash, a bet or a dealt hand all count", () => { + [ + ["a name", p => { p.name = "Ann"; }], + ["chips", p => { p.stack = 50; }], + ["cash taken", p => { p.buyIns = 100; }], + ["a bet in front of them", p => { p.committed = 5; }], + ["money already swept in", p => { p.amountOverride = 20; }], + ["a hand dealt to them", p => { p.inHand = true; }], + ].forEach(([what, mark]) => { + const seat = empty(); + mark(seat); + assert.equal(HoldemState.seatInPlay(seat), true, what); + }); + }); + + test("whitespace is not a name", () => { + const seat = empty(); + seat.name = " "; + assert.equal(HoldemState.seatInPlay(seat), false); + }); +}); + +describe("the parts that used to disagree", () => { + test("the open seats and the seat count see the same empty chairs", () => { + const state = openedOnTheDefault(); + // Three players, six chairs. + assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]); + const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length; + assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length); + }); + + test("a chair holding only a dealt hand is not offered to the waiting list", () => { + // The old test looked at name, buy-ins and stack. A seat marked in-hand with + // none of those would have been handed to the next person in the queue, on top + // of whoever was already playing it. + const state = openedOnTheDefault(); + state.seats[4].inHand = true; + assert.deepEqual(HoldemState.openSeats(state), [3, 5]); + + HoldemState.addToWaitingList(state, "Priya"); + const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4); + assert.equal(result.ok, false); + }); + + test("the bank, the standings and the settle-up all price the night the same way", () => { + const state = openedOnTheDefault(); + state.seats[0].stack = 320; // Ann is up + state.seats[1].stack = 80; // Ben is down + + const bank = HoldemState.bankReconciliation(state); + const standings = HoldemState.sessionStandings(state); + const chipsFromStandings = standings + .filter(row => !row.cashedOut) + .reduce((sum, row) => sum + row.chipsInFront, 0); + + assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table"); + assert.equal(bank.balanced, true); + + // And the payments the dealer is told to make add up to the same money. + const plan = SettleUp.plan(standings); + const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0); + const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0); + assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed); + }); + + test("a player still holding the table's starting stack is in all three", () => { + const state = openedOnTheDefault(); + const standings = HoldemState.sessionStandings(state); + assert.equal(standings.length, 3); + assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]); + assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600); + assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody"); + }); +});