diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 5db9c99..40dbd8c 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -162,6 +162,10 @@ House take +

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 0f0df21..ebabf22 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2294,6 +2294,24 @@ body.hasActionBar #holdemSidebar { font-weight: 600; } +/* A player who has taken their chips and gone. Still on the list, because the box took + their cash and the night's record has to say so, but set apart so nobody counts money + out to them twice at 2am. + + Set apart by a stripe rather than by fading the row, which is what this first did: + at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the + list a dealer settles the night from. The row says "paid out" in words next to the + name — it doesn't need to be hard to read as well. */ +.standingsRow.standingsGone { + background: rgba(255, 255, 255, 0.04); + border-left: 2px solid #4a4a4a; + padding-left: 0.35rem; +} + +.standingsRow.standingsGone .standingsName { + font-style: italic; +} + /* ---------- Side-pot breakdown ---------- */ .potBreakdown { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 1d0bf38..582f8ba 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -54,7 +54,10 @@ const HandHistoryExport = { rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]); standings.forEach(row => { rows.push([ - row.name, + // Somebody who has gone home is marked in the file the same way the + // panel marks them, because this is the copy that gets read at the + // end of the night when nobody remembers who has already been paid. + row.cashedOut ? `${row.name} (paid out)` : row.name, Money.forSheet(row.in), Money.forSheet(row.won), Money.forSheet(row.net), diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 327cc9a..8f48301 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -96,6 +96,39 @@ document.addEventListener("DOMContentLoaded", () => { }); }, + // Somebody is done for the night. The dealer counts their chips out, the chair + // opens, and both halves of what happened stay on the record — the cash the box + // took from them, and the chips it handed back. Wiping the seat by hand did + // neither: it left the bank check declaring their whole stack missing for the + // rest of the night. + cashOut: async index => { + const allowed = HoldemState.canCashOut(state, index); + if (!allowed.ok) { + Toast.show(allowed.reason, { duration: 4000 }); + return; + } + + const preview = HoldemState.cashOutPreview(state, index); + const result = preview.net === 0 + ? "even for the night" + : preview.net > 0 + ? `up ${Money.format(preview.net)}` + : `down ${Money.format(Math.abs(preview.net))}`; + const chips = preview.breakdown ? ` — ${preview.breakdown}` : ""; + + const ok = await Dialog.confirm({ + title: `Cash out ${preview.name}?`, + message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` + + `${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` + + `night's record keeps both their buy-in and this payout so the bank still balances.`, + confirmLabel: `Pay ${Money.format(preview.chips)}`, + }); + if (!ok) return; + + runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`, + () => HoldemState.cashOut(state, index)); + }, + // 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) => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 19bf744..f9c38f5 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -1012,7 +1012,14 @@ const Render = { const template = document.getElementById("standingsRowTemplate"); standings.forEach(row => { const node = template.content.firstElementChild.cloneNode(true); - node.querySelector(".standingsName").textContent = row.name; + // Somebody who has gone home is still on the list — they played, and the + // box took their cash — but the number beside them is money already handed + // over, not chips waiting to be counted. Marked, or a dealer settling up at + // 2am pays them twice. + node.classList.toggle("standingsGone", !!row.cashedOut); + node.querySelector(".standingsName").textContent = row.cashedOut + ? `${row.name} · paid out` + : row.name; node.querySelector(".standingsIn").textContent = Money.format(row.in); node.querySelector(".standingsWon").textContent = Money.format(row.won); @@ -1169,11 +1176,23 @@ const Render = { } } + // Chips handed back to people who have gone home. The third way money leaves + // this table on purpose, and the one a dealer is asked about most: "where did + // Ann's four hundred go?" + const paidRow = document.getElementById("bankPaidOutRow"); + if (paidRow) { + paidRow.hidden = !(bank.paidOut > 0); + if (bank.paidOut > 0) { + document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut); + } + } + const verdict = document.getElementById("bankReconcileVerdict"); // What has left the table on purpose, whichever way it went. const accountedFor = [ bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null, bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null, + bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null, ].filter(Boolean); if (bank.balanced) { verdict.textContent = accountedFor.length > 0 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index 6feb934..a90c5b2 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -47,6 +47,13 @@ const SeatMenu = { run: () => this.hooks.toggleDealtIn(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. + if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) { + actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) }); + } + if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) { actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) }); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 34cfa20..c8ae787 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -110,6 +110,11 @@ const HoldemState = { // What it has taken so far tonight. A session total, not a per-hand one: // it is the number the box is checked against at the end. rakeTaken: 0, + // People who have taken their chips and gone. Kept for the night rather + // than forgotten with the seat: the box took their cash and paid it back + // 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: [], // 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. @@ -228,6 +233,17 @@ const HoldemState = { noFlopNoDrop: rake.noFlopNoDrop !== false, }; parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0)); + parsed.cashedOut = Array.isArray(parsed.cashedOut) + ? parsed.cashedOut + .filter(row => row && row.id) + .map(row => ({ + id: row.id, + name: String(row.name || "").slice(0, 40), + buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)), + chips: Math.max(0, Money.round(Number(row.chips) || 0)), + at: Number(row.at) || 0, + })) + : []; if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = []; // A table saved before dealing and betting were separate has no flag, and @@ -753,6 +769,15 @@ const HoldemState = { // Cash bought in and chips currently in front don't come from the hand log at // all, so they're read off the seat rather than summed — a rebuy isn't a hand. const seatById = new Map(state.seats.map(p => [p.id, p])); + // And for anyone who has gone home, off the record their cash-out left behind. + // Without this they read as a player who bought in for nothing and is holding + // nothing, in the very list the dealer settles the night from. + const goneById = new Map((state.cashedOut || []).map(row => [row.id, row])); + + goneById.forEach((row, id) => { + if (totals.has(id)) return; + totals.set(id, { id, name: row.name, in: 0, won: 0 }); + }); // Anyone who has money in the game belongs in the settle-up, whether or not // they've been in a logged hand yet. Building the list from the hand log alone @@ -774,8 +799,16 @@ const HoldemState = { return Array.from(totals.values()) .map(t => { const seat = seatById.get(t.id); - const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0; - const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0; + const gone = goneById.get(t.id); + const boughtIn = seat + ? Money.round(Number(seat.buyIns) || 0) + : (gone ? gone.buyIns : 0); + // What a departed player is "holding" is what the box handed them on + // 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) + : (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 // position disagree with the hand log for as long as a hand is open — @@ -806,6 +839,10 @@ const HoldemState = { // a $340 stack read as "$140" at the exact moment cash changed // hands. chipsInFront: Money.round(chipsNow + inFrontOfThem), + // Set once they have taken their chips and left, so the panel and + // the export can say so rather than showing a player still sitting + // there with a stack. + cashedOut: !!gone && !seat, cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn), notes, }; @@ -838,6 +875,84 @@ 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. + // ---- Somebody leaving with their chips ---- + // + // The most ordinary thing that happens at a home game after midnight, and the tool + // had nothing for it. A dealer's only way to free the chair was to wipe the seat by + // hand, and then the bank check — which adds up cash taken against chips on the + // table — declared the departing player's whole stack missing: "someone has paid + // for chips they haven't been given", for the rest of the night. Wiping their + // buy-in as well silenced it, at the price of losing their night from the standings + // and the file the dealer hands over. + // + // Cashing out is two facts, and both have to survive the empty chair: the box took + // their cash, and the box paid their chips back out. + + // Can this seat be cashed out right now? + canCashOut(state, index) { + const player = state.seats[index]; + if (!player) return { ok: false, reason: "There's nobody in that seat." }; + const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0)); + if (inPot > 0) { + return { + ok: false, + // Paying someone out with money still in the middle would either take + // chips out of a pot other people can win, or hand them chips twice. + reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`, + }; + } + return { ok: true }; + }, + + // What the dealer counts out, and what the seat's night comes to. + cashOutPreview(state, index) { + const player = state.seats[index]; + if (!player) return null; + // Betting owns the answer to "what is this seat holding" — including the rule + // that a seat which has never been given a stack is holding the table's + // starting one. Asking it rather than working it out again here: the first + // draft of this did, read `Number(null)` as a perfectly good zero, and offered + // to pay a player nothing. + const chips = Money.round(Math.max(0, Betting.stackOf(state, player))); + const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0)); + return { + index, + name: this.displayName(player, index), + chips, + boughtIn, + // Their result for the night, in the direction a banker says it: positive + // means the box is paying out more than it took from them. + net: Money.round(chips - boughtIn), + breakdown: Money.formatChipBreakdown(chips, state.chipLegend), + }; + }, + + // Hand them their chips and free the chair, keeping both halves on the record. + cashOut(state, index) { + const allowed = this.canCashOut(state, index); + if (!allowed.ok) return allowed; + + const preview = this.cashOutPreview(state, index); + const leavingId = state.seats[index].id; + if (!Array.isArray(state.cashedOut)) state.cashedOut = []; + state.cashedOut.push({ + id: leavingId, + name: preview.name, + buyIns: preview.boughtIn, + chips: preview.chips, + at: Date.now(), + }); + + // The chair opens for the next player. A fresh id, because the person who had + // it has gone and their night is now filed under the old one — reusing it would + // merge two people's results the moment somebody new sits down. + const seat = this.makePlayer(); + state.seats[index] = seat; + // Anyone owed a hand by this seat is no longer owed one. + state.postingIn = (state.postingIn || []).filter(id => id !== leavingId); + return { ok: true, ...preview }; + }, + // ---- The house's cut ---- // // A game that takes a rake or a time charge has chips leaving the table every hand, @@ -900,7 +1015,12 @@ const HoldemState = { }, bankReconciliation(state) { - const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0); + const gone = Array.isArray(state.cashedOut) ? state.cashedOut : []; + // Cash the box took from people who have since left counts as taken: it is in + // the box, and forgetting it with the seat is what used to make a cash-out read + // as a shortfall all night. + const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) + + gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0); const chipsInPlay = state.seats.reduce( (sum, p) => sum + @@ -922,12 +1042,17 @@ const HoldemState = { // for a better reason: the box is supposed to have them. const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0)); - const difference = Money.round(chipsInPlay + discarded + raked - cashIn); + // Chips handed back over the rail. Off the table like the other two, and the + // one the dealer can point at when somebody asks where the money went. + const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0)); + + const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn); return { cashIn: Money.round(cashIn), chipsInPlay: Money.round(chipsInPlay), discarded, raked, + paidOut, difference, // Half a cent of float dust isn't a discrepancy worth alarming anyone over. balanced: Math.abs(difference) <= Money.EPSILON, diff --git a/tests/holdem/cashOut.test.js b/tests/holdem/cashOut.test.js new file mode 100644 index 0000000..56aa2b4 --- /dev/null +++ b/tests/holdem/cashOut.test.js @@ -0,0 +1,229 @@ +// Somebody leaving with their chips. +// +// The most ordinary thing that happens at a home game after midnight, and the tool had +// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank +// check — cash taken against chips on the table — declared the departing player's whole +// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the +// price of losing their night from the standings and the file the dealer hands over. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Money, HoldemState, Betting, makeTable } = require("./load"); + +const table = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 }); + ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].buyIns = 200; + }); + return state; +}; + +describe("counting them out", () => { + test("the preview is what the dealer's hands follow", () => { + const state = table(); + state.seats[0].stack = 350; + const preview = HoldemState.cashOutPreview(state, 0); + assert.equal(preview.name, "Ann"); + assert.equal(preview.chips, 350, "what they are holding"); + assert.equal(preview.boughtIn, 200); + assert.equal(preview.net, 150, "up for the night"); + assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it"); + }); + + test("a player who is down reads as down, not as a smaller stack", () => { + const state = table(); + state.seats[1].stack = 60; + assert.equal(HoldemState.cashOutPreview(state, 1).net, -140); + }); + + test("a seat that never set a stack is holding the table's starting stack", () => { + const state = table(); + state.defaultStack = 200; + state.seats[2].stack = null; + assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200); + }); +}); + +describe("what it refuses", () => { + test("paying somebody with money still in the middle", () => { + const state = table(); + Betting.beginHand(state); + const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0); + const refusal = HoldemState.canCashOut(state, bb); + assert.equal(refusal.ok, false); + assert.match(refusal.reason, /in the pot/); + // And the refusal is the whole story: nothing moved. + assert.equal(HoldemState.cashOut(state, bb).ok, false); + assert.equal((state.cashedOut || []).length, 0); + assert.ok(state.seats[bb].name, "the seat is untouched"); + }); + + test("an empty chair, which has nobody to pay", () => { + const state = table(); + const refusal = HoldemState.canCashOut(state, 99); + assert.equal(refusal.ok, false); + }); +}); + +describe("the chair afterwards", () => { + test("is empty and ready for the next player", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.cashOut(state, 0); + const seat = state.seats[0]; + assert.equal(seat.name, ""); + assert.equal(seat.stack, null); + assert.equal(seat.buyIns, 0); + assert.equal(seat.inHand, false); + }); + + test("belongs to somebody new, so two people's nights can't merge", () => { + const state = table(); + const wasId = state.seats[0].id; + HoldemState.cashOut(state, 0); + assert.notEqual(state.seats[0].id, wasId); + }); + + test("and isn't owed the blind the person before them was posting", () => { + const state = table(); + state.postingIn = [state.seats[0].id, state.seats[1].id]; + HoldemState.cashOut(state, 0); + assert.deepEqual(state.postingIn, [state.seats[1].id]); + }); +}); + +describe("the bank check", () => { + test("balanced before, balanced after — the chips went somewhere known", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + + HoldemState.cashOut(state, 0); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.paidOut, 350); + assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took"); + assert.equal(bank.chipsInPlay, 450); + assert.equal(bank.balanced, true); + }); + + test("this is the fault it fixes: wiping the seat by hand loses $350", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; // so the table starts out balanced + // What a dealer had to do before: empty the chair themselves. + state.seats[0].name = ""; + state.seats[0].stack = null; + state.seats[0].inHand = false; + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, false); + assert.equal(bank.difference, -350, "the tool called it missing money"); + }); + + test("chips that really are missing still show as missing", () => { + // Counting a cash-out must not become a way of swallowing a genuine shortfall. + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; // balanced to start with + HoldemState.cashOut(state, 0); + state.seats[2].stack = 150; // and now fifty chips have gone astray + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, false); + assert.equal(bank.difference, -50); + }); + + test("a rake and a cash-out are counted apart", () => { + const state = table(); + state.seats[0].stack = 350; + state.rakeTaken = 12; + state.seats[1].stack = 38; // the rake came out of Ben's stack + HoldemState.cashOut(state, 0); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.raked, 12); + assert.equal(bank.paidOut, 350); + assert.equal(bank.balanced, true); + }); +}); + +describe("the record of their night", () => { + test("they stay in the standings with what they bought and what they left with", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]); + HoldemState.cashOut(state, 0); + + const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann"); + assert.ok(ann, "still on the list"); + assert.equal(ann.boughtIn, 200); + assert.equal(ann.chipsInFront, 350, "what the box handed over"); + assert.equal(ann.cashPosition, 150); + assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice"); + }); + + test("somebody still sitting there is not marked", () => { + const state = table(); + HoldemState.cashOut(state, 0); + const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben"); + assert.equal(ben.cashedOut, false); + }); + + test("two people leaving are both kept", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + HoldemState.cashOut(state, 0); + HoldemState.cashOut(state, 1); + assert.equal(state.cashedOut.length, 2); + assert.equal(HoldemState.bankReconciliation(state).paidOut, 400); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); + + test("a table saved before any of this existed has nobody cashed out", () => { + const parsed = JSON.parse(JSON.stringify(table())); + delete parsed.cashedOut; + const migrated = HoldemState.migrate(parsed); + assert.deepEqual(migrated.cashedOut, []); + assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0); + }); + + test("nonsense in a saved record is cleaned up rather than trusted", () => { + const parsed = JSON.parse(JSON.stringify(table())); + parsed.cashedOut = [ + { id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" }, + { name: "no id" }, + null, + ]; + const migrated = HoldemState.migrate(parsed); + assert.equal(migrated.cashedOut.length, 1); + assert.equal(migrated.cashedOut[0].buyIns, 0); + assert.equal(migrated.cashedOut[0].chips, 350); + assert.equal(migrated.cashedOut[0].at, 0); + }); +}); + +describe("the table carries on", () => { + test("the next hand deals round the empty chair", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.cashOut(state, 0); + HoldemState.startNextHand(state); + Betting.beginHand(state); + + assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair"); + assert.equal(Betting.liveSeats(state).length, 3); + assert.ok(state.actingSeatIndex !== null, "somebody is on the clock"); + }); + + test("and the money still adds up once it has", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + HoldemState.cashOut(state, 0); + HoldemState.startNextHand(state); + Betting.beginHand(state); + const bank = HoldemState.bankReconciliation(state); + assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn); + assert.equal(bank.balanced, true); + }); +});