Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank check had no idea. It added up the buy-ins, added up the chips on the felt, and told the dealer somebody had been paid for chips they were never given — a shortfall that grows all night and points at a miscount that never happened. Every home game that takes a drop for the beer fund hits this on hand one. So the tool takes it, rather than watching it go. Under Table Setup: a percentage, an optional cap, and "no flop, no drop" — the rule almost everyone plays, on by default, where a hand that ends before the flop pays nothing. Zero percent is a game that doesn't rake, which is most of them and the default, and nothing about that game changes. Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%, capped at $5" above the payouts, before a chip moves, because the summary is the dealer's last chance to disagree with the arithmetic and money taken quietly is the one thing a tool like this must never do. The bank check gets a House take line and says "balanced ... once the $2 in the house's cut is counted". The hand log keeps the pot as the table saw it with the cut recorded beside it, and the export has a column for it, so the night's total is one sum in a spreadsheet. Two decisions worth stating. The rake rounds *down* to a chip the table actually has: a house that can't make 40c takes nothing and the players keep it, which is the only way to be wrong about this that nobody argues with. And on a split pot it comes off the top, then what is left is shared in proportion — with the rounding leftovers going back to the winners rather than to the house, which asked for a number and gets that number, not that number plus dust. Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays the full $15 and takes nothing; with it off the same hand pays $14 and takes $1. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -86,6 +86,32 @@
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>House take</summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Rake (%)</span>
|
||||
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Cap ($)</span>
|
||||
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>No flop, no drop</span>
|
||||
<input type="checkbox" id="rakeNoFlopInput" />
|
||||
</label>
|
||||
<p class="fieldHint">
|
||||
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.
|
||||
</p>
|
||||
<p class="fieldHint" id="rakeTakenHint" hidden></p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>Without live betting</summary>
|
||||
<div class="subPanelBody">
|
||||
@@ -132,6 +158,10 @@
|
||||
<span>Taken off the table</span>
|
||||
<span id="bankDiscarded"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankRakedRow" hidden>
|
||||
<span>House take</span>
|
||||
<span id="bankRaked"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user