diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 56c2494..31be248 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -318,15 +318,34 @@ document.addEventListener("DOMContentLoaded", () => { Render.renderAll(); }); - document.getElementById("nextHandBtn").addEventListener("click", () => { + document.getElementById("nextHandBtn").addEventListener("click", async () => { + // Moving on wipes every seat's contribution, so an unresolved pot is simply + // destroyed — measured at a four-handed table, pressing this with $8 in the + // middle took the table from 800 chips to 792, and the toast said nothing + // about it. Chips only ever leave this tool because a dealer meant them to. + const unresolved = HoldemState.unresolvedContributions(state); + + if (unresolved > Betting.EPSILON_CHIPS) { + const ok = await Dialog.confirm({ + title: `Throw away ${Money.format(unresolved)}?`, + message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.", + confirmLabel: "Throw it away", + danger: true, + }); + if (!ok) return; + } + // startNextHand moves the button on by one; say where it landed so the // dealer can confirm the rotation without hunting for the badge. const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1); const where = state.dedicatedDealer ? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}` : `button on seat ${nextDealer + 1}`; + const discarded = unresolved > Betting.EPSILON_CHIPS + ? ` ${Money.format(unresolved)} taken off the table.` + : ""; - runUndoable(`Next hand — ${where}. Blinds posted.`, () => { + runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => { HoldemState.startNextHand(state); Betting.beginHand(state); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index f9d23fe..c4c47f9 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -272,11 +272,25 @@ const PotWizard = { }); actions.appendChild(applyBtn); - const closeBtn = document.createElement("button"); - closeBtn.className = "holdemBtn holdemBtnGhost"; - closeBtn.textContent = "Close"; - closeBtn.addEventListener("click", () => this.close()); - actions.appendChild(closeBtn); + // This button cancels — it does not pay anybody. Under a heading reading + // "Final Payouts" with an amount beside a name, "Close" reads like dismissing + // something already done, and a dealer who taps it walks away believing the + // pot has been pushed. It hasn't: the chips are still in the middle and the + // hand isn't logged. So it says what it does. + const cancelBtn = document.createElement("button"); + cancelBtn.className = "holdemBtn holdemBtnGhost"; + cancelBtn.textContent = "Cancel — don't pay yet"; + cancelBtn.addEventListener("click", () => this.close()); + actions.appendChild(cancelBtn); + + if (this.totals.size > 0) { + const note = document.createElement("p"); + note.className = "fieldHint potWizardPendingNote"; + note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand."; + wrap.appendChild(actions); + wrap.appendChild(note); + return wrap; + } wrap.appendChild(actions); return wrap; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 9eac9b8..43e2937 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -167,6 +167,38 @@ const HoldemState = { return !player.inHand; }, + // What starting the next hand would take off the table for good: exactly the + // fields startNextHand clears. + // + // Deliberately not totalPot(). The table-wide base pot is a standing ante — a + // stand-in for "everyone puts the same in every hand", used by a dealer who + // isn't tracking stacks — so it re-applies to the next hand rather than being + // lost with this one. Counting it would mean warning about money that isn't + // going anywhere, on every single hand, for exactly the dealers who chose the + // simplest way to run the table. + unresolvedContributions(state) { + const total = state.seats.reduce((sum, player) => { + const override = player.amountOverride; + let banked = 0; + + if (override !== null && override !== undefined && String(override).trim() !== "") { + const parsed = Number(override); + if (!Number.isNaN(parsed)) banked = Math.max(0, parsed); + } + + if (banked === 0) { + banked = (state.chipLegend || []).reduce((chips, chip) => { + const count = Number(player.chipCounts[chip.id]) || 0; + return chips + count * Number(chip.value || 0); + }, 0); + } + + return sum + banked + Math.max(0, Number(player.committed) || 0); + }, 0); + + return Money.round(total); + }, + totalPot(state) { return state.seats.reduce( (sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount), diff --git a/tests/holdem/potSafety.test.js b/tests/holdem/potSafety.test.js new file mode 100644 index 0000000..b687839 --- /dev/null +++ b/tests/holdem/potSafety.test.js @@ -0,0 +1,103 @@ +// Chips only ever leave this tool because a dealer meant them to. Starting the next +// hand clears every seat's contribution, so an unresolved pot is simply destroyed — +// the UI now asks first, and the number it quotes has to be exactly what goes. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load"); + +// The same measure the confirmation uses. +const unresolvedPot = state => HoldemState.unresolvedContributions(state); + +describe("what starting the next hand destroys", () => { + test("the quoted amount is exactly what leaves the table", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + const before = chipsOnTable(state); + const quoted = unresolvedPot(state); + assert.ok(quoted > 0, "there is a pot to warn about"); + + HoldemState.startNextHand(state); + + assert.equal( + Money.toCents(chipsOnTable(state)), + Money.toCents(before - quoted), + "the dealer must be told the real number, not an approximation" + ); + }); + + test("chips still in front of players on the current street are counted too", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); // blinds are `committed`, not yet banked + Betting.raiseTo(state, 0, 25); // and so is a raise mid-street + + const before = chipsOnTable(state); + const quoted = unresolvedPot(state); + HoldemState.startNextHand(state); + + assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted)); + assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`); + }); + + test("a hand that was paid out properly leaves nothing to warn about", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + const pot = HoldemState.totalPot(state); + const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }]; + HoldemState.recordHand(state, payouts); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand"); + }); + + test("settling then dealing on conserves every chip", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + + for (let hand = 0; hand < 5; hand++) { + Betting.beginHand(state); + const live = Betting.liveSeats(state); + live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); }); + Betting.sweepToHandTotals(state); + + const pot = HoldemState.totalPot(state); + const winner = Betting.liveSeats(state)[0]; + const payouts = [{ id: winner.player.id, label: "w", amount: pot }]; + HoldemState.recordHand(state, payouts); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`); + } + }); + + test("an empty table has no phantom pot to warn about", () => { + const state = makeTable({ seatCount: 6, seated: [] }); + assert.equal(unresolvedPot(state), 0); + }); + + test("a standing ante is never warned about, because it isn't lost", () => { + // The table default is a stand-in for "everyone puts the same in every hand", + // used by a dealer not tracking stacks at all. It re-applies to the next hand + // rather than going anywhere, so counting it would nag this dealer before + // every single hand — which is why the warning measures the fields + // startNextHand actually clears rather than the pot badge. + const state = makeTable({ seatCount: 9, seated: [0, 1] }); + state.basePotAmount = 25; + state.seats.forEach(p => { p.amountOverride = null; }); + + // The base pot carries into the next hand, so nothing is being thrown away + // and the dealer must not be asked to confirm anything. + assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot"); + assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning"); + + HoldemState.startNextHand(state); + assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too"); + }); +});