diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index d2bb9d2..558d334 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -86,6 +86,32 @@

Blinds post themselves each hand. Seats without their own stack start here.

+
+ House take +
+
+ + +
+ +

+ Taken from the pot when it's awarded, and shown before anything is pushed. + Rounded down to a chip the table has, so the players keep what the house + can't make. Leave the percentage at zero for a game that doesn't rake. +

+ +
+
+
Without live betting
@@ -132,6 +158,10 @@ Taken off the table
+

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 4d9912e..b2f67c2 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2898,6 +2898,20 @@ body.hasActionBar #toastHost { color: #ff6b6b; } +/* What the house takes, above the payouts it comes out of. Stated in the summary + rather than only in the log afterwards: this is the dealer's last chance to + disagree with the arithmetic before the chips move, and money taken quietly is the + one thing a tool like this must never do. */ +.potWizardRake { + margin: 0 0 0.7rem; + padding: 0.45rem 0.6rem; + border-radius: 8px; + background: rgba(216, 180, 106, 0.1); + border: 1px solid rgba(216, 180, 106, 0.35); + color: #e8cf9a; + font-size: 0.82rem; +} + .potWizardSummary ul { list-style: none; padding: 0; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 0257d3f..1d0bf38 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -20,7 +20,11 @@ const HandHistoryExport = { // who owes what — doesn't work without cleaning every cell first. The commas were // quoted correctly, so the file parsed; it just wouldn't add up. toCsv(handHistory, standings = [], { notes = "" } = {}) { - const rows = [["Hand #", "Time", "Pot", "Payouts"]]; + // The rake gets its own column rather than being folded into the pot: the two + // numbers answer different questions — what was in the middle, and what the + // house took out of it — and a night's worth of the second one is what the box + // is counted against. + const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]]; // handHistory is stored newest-first; the export reads better oldest-first. [...handHistory].reverse().forEach(entry => { const payouts = (entry.payouts || []) @@ -30,6 +34,7 @@ const HandHistoryExport = { entry.handNumber, this.formatTimestamp(entry.timestamp), Money.forSheet(entry.pot), + Money.forSheet(entry.rake || 0), payouts, ]); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 6439cf9..327cc9a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -192,11 +192,15 @@ document.addEventListener("DOMContentLoaded", () => { hooks.save(state); Render.renderAll(); - PotWizard.open(state, payouts => { + PotWizard.open(state, (payouts, { rake = 0 } = {}) => { const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", "); - runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => { + const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid; + runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => { + if (rake > 0) { + state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake); + } if (payouts.length > 0) { - HoldemState.recordHand(state, payouts); + HoldemState.recordHand(state, payouts, rake); // Winnings go straight back in front of the player, so the next // hand's stacks are already right. Betting.creditWinnings(state, payouts); @@ -462,6 +466,25 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + // The house's cut. Kept as a group rather than three loose fields, because + // migrate() guarantees the shape and half a rake object would be a table nobody + // could read back. + const readRake = () => { + const percent = Number(document.getElementById("rakePercentInput").value); + const cap = Number(document.getElementById("rakeCapInput").value); + state.rake = { + percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0, + cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null, + noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked, + }; + hooks.save(state); + Render.renderAll(); + }; + ["rakePercentInput", "rakeCapInput"].forEach(id => { + document.getElementById(id).addEventListener("input", readRake); + }); + document.getElementById("rakeNoFlopInput").addEventListener("change", readRake); + document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor()); document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm()); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index f0fe135..1d03ef4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -86,6 +86,21 @@ const Money = { return this.fromCents(Math.ceil(cents / step) * step); }, + // And down, for money coming *out* of a pot rather than going into it. + // + // The rake is the one amount in this tool that leaves the table, so the direction + // of the rounding is a decision about whose money it is. Down: a house that can't + // make 40c out of its chips takes nothing rather than a dollar, and the players + // keep the difference. Being wrong in the players' favour is the only way to be + // wrong about this that nobody has to argue about. + downToChip(amount, legend) { + const step = this.chipStepCents(legend); + if (step <= 0) return this.round(amount); + const cents = this.toCents(amount); + if (cents <= 0) return 0; + return this.fromCents(Math.floor(cents / step) * step); + }, + chipBreakdown(amount, legend) { let cents = this.toCents(amount); if (cents <= 0) return { chips: [], remainder: 0 }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index 036a4ca..7ab20d4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -232,6 +232,24 @@ const PotWizard = { return wrap; }, + // What the house takes out of this pot, and what is left to push. + // + // Worked out here rather than at the end so the dealer sees it before anything + // moves: the summary is the last chance to disagree with the arithmetic, and a + // rake that only appeared in the hand log afterwards would be the tool taking + // money quietly. + rakeSplit() { + const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({ + id, + label: this.playerLabelById.get(id), + amount, + })); + const pot = gross.reduce((sum, p) => sum + p.amount, 0); + const sawFlop = (this.state.street || "preflop") !== "preflop"; + const rake = HoldemState.rakeOn(this.state, pot, sawFlop); + return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) }; + }, + renderSummary() { const wrap = document.createElement("div"); wrap.className = "potWizardSummary"; @@ -240,6 +258,20 @@ const PotWizard = { heading.textContent = "Final Payouts"; wrap.appendChild(heading); + const split = this.rakeSplit(); + const rakeSet = Number((this.state.rake || {}).percent) > 0; + if (rakeSet && this.totals.size > 0) { + const line = document.createElement("p"); + line.className = "potWizardRake"; + const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : ""; + line.textContent = split.rake > 0 + ? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.` + : split.sawFlop + ? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.` + : "No flop, no drop — nothing taken from a pot that ended before the flop."; + wrap.appendChild(line); + } + if (this.totals.size === 0) { const empty = document.createElement("p"); empty.className = "fieldHint"; @@ -248,7 +280,7 @@ const PotWizard = { } else { const legend = (this.state && this.state.chipLegend) || []; const list = document.createElement("ul"); - for (const [id, amount] of this.totals.entries()) { + for (const { id, amount } of split.payouts) { const li = document.createElement("li"); li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`; @@ -275,13 +307,12 @@ const PotWizard = { applyBtn.className = "holdemBtn holdemBtnAccent"; applyBtn.textContent = "Start Next Hand"; applyBtn.addEventListener("click", () => { - const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({ - id, - label: this.playerLabelById.get(id), - amount, - })); + // Recomputed rather than reused from the render: the same numbers, but + // taken at the moment the dealer commits rather than when they last + // looked at the screen. + const paid = this.rakeSplit(); this.close(); - this.onFinished(payouts); + this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot }); }); actions.appendChild(applyBtn); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 566bb15..19bf744 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -47,6 +47,19 @@ const Render = { document.getElementById("smallBlindInput").value = this.state.smallBlind ?? ""; document.getElementById("bigBlindInput").value = this.state.bigBlind ?? ""; document.getElementById("defaultStackInput").value = this.state.defaultStack ?? ""; + + const rake = this.state.rake || {}; + document.getElementById("rakePercentInput").value = rake.percent || ""; + document.getElementById("rakeCapInput").value = rake.cap ?? ""; + document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false; + + // What the house has actually taken tonight, said where the rake is set rather + // than only in the bank check — a dealer who changes the percentage mid-session + // wants to see the running total right there. + const taken = Math.max(0, Number(this.state.rakeTaken) || 0); + const hint = document.getElementById("rakeTakenHint"); + hint.hidden = taken <= 0; + if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`; }, // Whether this device can actually drag a seat. HTML5 drag events are never @@ -1145,10 +1158,26 @@ const Render = { } } + // Same idea for the house's cut, and the more important of the two: a raked + // game moves chips off the table every single hand, so without this line the + // check reads as a shortfall that grows all night. + const rakedRow = document.getElementById("bankRakedRow"); + if (rakedRow) { + rakedRow.hidden = !(bank.raked > 0); + if (bank.raked > 0) { + document.getElementById("bankRaked").textContent = Money.format(bank.raked); + } + } + 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, + ].filter(Boolean); if (bank.balanced) { - verdict.textContent = bank.discarded > 0 - ? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.` + verdict.textContent = accountedFor.length > 0 + ? `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 = diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index d3404ae..34cfa20 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -104,6 +104,12 @@ const HoldemState = { // announced hand by hand, so this is cleared with every other one-deal // decision rather than standing until somebody turns it off. straddleCount: 0, + // What the house takes, if it takes anything. Percent zero is a game that + // doesn't rake, which is nearly all of them and the default. + rake: { percent: 0, cap: null, noFlopNoDrop: true }, + // 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, // 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. @@ -213,6 +219,15 @@ const HoldemState = { // whatever is in front of those players is already in their committed // chips, and nothing is owed on top of it. if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0; + // A table saved before the house took anything takes nothing, which is + // also the right answer for one that never will. + const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {}; + parsed.rake = { + percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)), + cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null, + noFlopNoDrop: rake.noFlopNoDrop !== false, + }; + parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0)); if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = []; // A table saved before dealing and betting were separate has no flag, and @@ -679,11 +694,15 @@ const HoldemState = { // Must be called before startNextHand() clears the seats: the per-player `ledger` // it stores alongside the payouts is what session standings are summed from later, // and it needs this hand's contributions while they're still on the table. - recordHand(state, payouts) { + recordHand(state, payouts, rake = 0) { if (typeof state.handNumber !== "number") state.handNumber = 1; if (!Array.isArray(state.handHistory)) state.handHistory = []; - const pot = payouts.reduce((sum, p) => sum + p.amount, 0); + // The pot as it was, house cut included — the number the table saw in the + // middle. What each player took is separately recorded and is net of it, so + // the two together say where every chip went. + const taken = Money.round(Math.max(0, Number(rake) || 0)); + const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken); const wonById = new Map(payouts.map(p => [p.id, p.amount])); const ledger = state.seats .map((player, index) => ({ @@ -698,6 +717,7 @@ const HoldemState = { handNumber: state.handNumber, timestamp: Date.now(), pot, + rake: taken, payouts, ledger, }); @@ -818,6 +838,67 @@ 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 house's cut ---- + // + // A game that takes a rake or a time charge has chips leaving the table every hand, + // and until now the bank check called them missing: it added up the buy-ins, + // added up the chips on the felt, and told the dealer somebody had been paid for + // chips they never got. The house's cut is the one thing on this table that is + // supposed to go and not come back, so it is counted rather than mourned. + // + // Percentage with a cap, which is what nearly every room and home game runs. Zero + // percent is a game that doesn't rake, which is the default and the common case. + + // What the house takes from a pot of this size. + // + // "No flop, no drop" is the rule almost everyone plays: a hand that ends before the + // flop pays nothing, because the players never got a hand out of it. It is on by + // default and can be turned off for a house that rakes every pot. + rakeOn(state, pot, sawFlop = true) { + const rake = state.rake || {}; + const percent = Number(rake.percent) || 0; + const amount = Money.round(Number(pot) || 0); + if (percent <= 0 || amount <= 0) return 0; + if (rake.noFlopNoDrop !== false && !sawFlop) return 0; + + const uncapped = (amount * percent) / 100; + const cap = Number(rake.cap); + const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped; + // Never more than the pot, however the numbers were typed in. + return Money.downToChip(Math.min(capped, amount), state.chipLegend); + }, + + // The same payouts with the house's cut taken out of them. + // + // Off the top of the whole pot, then shared out in proportion — which for the hand + // a dealer actually deals, with one winner, is just "winner gets the pot less the + // rake". Split pots take it proportionally, and whatever the rounding leaves over + // goes back to the winners in turn rather than to the house: the house asked for a + // number, and it gets that number, not that number plus dust. + applyRake(payouts, rake) { + const total = payouts.reduce((sum, p) => sum + p.amount, 0); + const taken = Money.round(Math.min(Math.max(0, rake), total)); + if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 }; + + const kept = Money.toCents(total) - Money.toCents(taken); + const shares = payouts.map(p => { + const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total); + return { payout: p, cents: Math.floor(exact) }; + }); + + // Floor everybody, then hand the leftover cents out one at a time, biggest + // share first — the same shape as the odd-chip rule the split calculator uses. + let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0); + [...shares] + .sort((a, b) => b.payout.amount - a.payout.amount) + .forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } }); + + return { + payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })), + rake: taken, + }; + }, + bankReconciliation(state) { const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0); const chipsInPlay = state.seats.reduce( @@ -837,11 +918,16 @@ const HoldemState = { // hunting for a miscount that never happened. const discarded = Math.max(0, Money.round(Number(state.discarded) || 0)); - const difference = Money.round(chipsInPlay + discarded - cashIn); + // Chips the house has taken. Off the table for good, like the discards, and + // 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); return { cashIn: Money.round(cashIn), chipsInPlay: Money.round(chipsInPlay), discarded, + raked, 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/rake.test.js b/tests/holdem/rake.test.js new file mode 100644 index 0000000..cafda51 --- /dev/null +++ b/tests/holdem/rake.test.js @@ -0,0 +1,230 @@ +// The house's cut. +// +// A game that rakes has chips leaving the table every hand, and the bank check used to +// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been +// paid for chips they never got. The cut is the one thing here that is supposed to go +// and not come back. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load"); + +const raking = (rake, extra = {}) => { + const state = makeTable({ seatCount: 6, stack: 200, ...extra }); + state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake }; + return state; +}; + +describe("what the house takes", () => { + test("nothing, unless somebody says otherwise", () => { + const state = makeTable({ seatCount: 6 }); + assert.equal(state.rake.percent, 0); + assert.equal(HoldemState.rakeOn(state, 100), 0); + }); + + test("a percentage of the pot", () => { + assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5); + assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6); + }); + + test("no more than the cap", () => { + const state = raking({ percent: 5, cap: 5 }); + assert.equal(HoldemState.rakeOn(state, 100), 5); + assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size"); + assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage"); + }); + + test("no flop, no drop", () => { + const state = raking({ percent: 5 }); + assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop"); + assert.equal(HoldemState.rakeOn(state, 100, true), 5); + }); + + test("unless the house rakes every pot", () => { + const state = raking({ percent: 5, noFlopNoDrop: false }); + assert.equal(HoldemState.rakeOn(state, 100, false), 5); + }); + + test("nothing it can't make out of the table's chips, rounded the players' way", () => { + // 5% of $30 is $1.50, and this table's smallest chip is a dollar. + const state = raking({ percent: 5 }); + assert.equal(HoldemState.rakeOn(state, 30), 1); + // 5% of $10 is 50c — under a chip, so the house takes nothing at all. + assert.equal(HoldemState.rakeOn(state, 10), 0); + }); + + test("never more than the pot, whatever gets typed in", () => { + assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20); + assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1); + }); + + test("nothing out of an empty pot", () => { + assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0); + }); +}); + +describe("taking it out of the pot", () => { + const winner = amount => [{ id: "a", label: "Ann", amount }]; + + test("one winner just gets the pot less the cut", () => { + const { payouts, rake } = HoldemState.applyRake(winner(100), 5); + assert.equal(rake, 5); + assert.deepEqual(payouts.map(p => p.amount), [95]); + }); + + test("a split pot shares what's left, in proportion", () => { + const { payouts } = HoldemState.applyRake( + [{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10); + assert.deepEqual(payouts.map(p => p.amount), [54, 36]); + }); + + test("the rounding leftovers go back to the players, not to the house", () => { + // $100 split three ways less $7: 93 doesn't divide evenly, and every cent of + // it has to land on somebody. + const { payouts, rake } = HoldemState.applyRake([ + { id: "a", label: "Ann", amount: 34 }, + { id: "b", label: "Ben", amount: 33 }, + { id: "c", label: "Cal", amount: 33 }, + ], 7); + assert.equal(rake, 7); + assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93); + }); + + test("what goes out plus what the house keeps is what was in the middle", () => { + [[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => { + const result = HoldemState.applyRake(winner(pot), rake); + assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`); + }); + }); + + test("asking for more than the pot takes the pot, not more", () => { + const { payouts, rake } = HoldemState.applyRake(winner(20), 50); + assert.equal(rake, 20); + assert.deepEqual(payouts.map(p => p.amount), [0]); + }); + + test("no rake, no change — the same payouts come back", () => { + const { payouts, rake } = HoldemState.applyRake(winner(80), 0); + assert.equal(rake, 0); + assert.deepEqual(payouts.map(p => p.amount), [80]); + }); +}); + +describe("what the box is checked against", () => { + test("raked chips are gone but not missing", () => { + const state = raking({ percent: 5 }); + state.seats.forEach(p => { p.buyIns = 200; }); + const before = HoldemState.bankReconciliation(state); + assert.equal(before.balanced, true); + + // The house takes $10 out of the middle: somebody's stack is $10 lighter. + state.seats[0].stack = 190; + state.rakeTaken = 10; + const after = HoldemState.bankReconciliation(state); + assert.equal(after.raked, 10); + assert.equal(after.balanced, true, "the check still balances"); + }); + + test("and are told apart from the ones that went astray", () => { + const state = raking({ percent: 5 }); + state.seats.forEach(p => { p.buyIns = 200; }); + state.seats[0].stack = 180; + state.rakeTaken = 10; + const check = HoldemState.bankReconciliation(state); + assert.equal(check.raked, 10); + assert.equal(check.difference, -10, "ten dollars really are missing"); + assert.equal(check.balanced, false); + }); + + test("a table that has never raked reads exactly as it did before", () => { + const state = makeTable({ seatCount: 6 }); + state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; }); + const check = HoldemState.bankReconciliation(state); + assert.equal(check.raked, 0); + assert.equal(check.balanced, true); + }); +}); + +describe("the record of it", () => { + test("the hand log keeps the pot as the table saw it, and the cut beside it", () => { + const state = raking({ percent: 5 }); + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5); + const [entry] = state.handHistory; + assert.equal(entry.pot, 100, "the pot in the middle"); + assert.equal(entry.rake, 5); + assert.equal(entry.payouts[0].amount, 95, "what the winner actually took"); + }); + + test("a hand with no rake records a zero rather than nothing", () => { + const state = makeTable({ seatCount: 6 }); + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]); + assert.equal(state.handHistory[0].rake, 0); + assert.equal(state.handHistory[0].pot, 40); + }); + + test("a table saved before the house took anything takes nothing", () => { + const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 }))); + delete parsed.rake; + delete parsed.rakeTaken; + const migrated = HoldemState.migrate(parsed); + assert.equal(migrated.rake.percent, 0); + assert.equal(migrated.rake.noFlopNoDrop, true); + assert.equal(migrated.rakeTaken, 0); + }); + + test("nonsense in a saved rake is cleaned up rather than trusted", () => { + const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 }))); + parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false }; + parsed.rakeTaken = -20; + const migrated = HoldemState.migrate(parsed); + assert.equal(migrated.rake.percent, 0); + assert.equal(migrated.rake.cap, null); + assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice"); + assert.equal(migrated.rakeTaken, 0); + }); +}); + +describe("the file the dealer hands over", () => { + const { HandHistoryExport } = require("./load"); + + test("has a column for what the house took", () => { + const state = raking({ percent: 5 }); + HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5); + HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0); + + // Oldest hand first, and money as bare numbers so a spreadsheet can total it. + const csv = HandHistoryExport.toCsv(state.handHistory); + const [header, raked, unraked] = csv.split("\n"); + assert.match(header, /Pot,House take,Payouts/); + assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken"); + assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was"); + }); +}); + +describe("a raked hand, end to end", () => { + test("the chips add up: stacks plus the house's cut equal what was bought in", () => { + // 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip. + // At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to + // nothing — which is the rule working, not a hand worth testing the bank on. + const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 }); + state.seats.forEach(p => { p.buyIns = 200; }); + Betting.beginHand(state); + // Everyone calls the big blind, then it is checked down. + state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); }); + Betting.sweepToHandTotals(state); + + const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0); + const rake = HoldemState.rakeOn(state, pot, true); + assert.equal(pot, 60); + assert.equal(rake, 3); + + const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake); + state.rakeTaken = rake; + HoldemState.recordHand(state, payouts, rake); + Betting.creditWinnings(state, payouts); + HoldemState.startNextHand(state); + + assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); +});