Count the house's cut instead of calling it missing chips #106
@@ -86,6 +86,32 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
<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">
|
<details class="subPanel">
|
||||||
<summary>Without live betting</summary>
|
<summary>Without live betting</summary>
|
||||||
<div class="subPanelBody">
|
<div class="subPanelBody">
|
||||||
@@ -132,6 +158,10 @@
|
|||||||
<span>Taken off the table</span>
|
<span>Taken off the table</span>
|
||||||
<span id="bankDiscarded"></span>
|
<span id="bankDiscarded"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bankReconcileRow" id="bankRakedRow" hidden>
|
||||||
|
<span>House take</span>
|
||||||
|
<span id="bankRaked"></span>
|
||||||
|
</div>
|
||||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2898,6 +2898,20 @@ body.hasActionBar #toastHost {
|
|||||||
color: #ff6b6b;
|
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 {
|
.potWizardSummary ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ const HandHistoryExport = {
|
|||||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
// 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.
|
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||||
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
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 is stored newest-first; the export reads better oldest-first.
|
||||||
[...handHistory].reverse().forEach(entry => {
|
[...handHistory].reverse().forEach(entry => {
|
||||||
const payouts = (entry.payouts || [])
|
const payouts = (entry.payouts || [])
|
||||||
@@ -30,6 +34,7 @@ const HandHistoryExport = {
|
|||||||
entry.handNumber,
|
entry.handNumber,
|
||||||
this.formatTimestamp(entry.timestamp),
|
this.formatTimestamp(entry.timestamp),
|
||||||
Money.forSheet(entry.pot),
|
Money.forSheet(entry.pot),
|
||||||
|
Money.forSheet(entry.rake || 0),
|
||||||
payouts,
|
payouts,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -192,11 +192,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
|
|
||||||
PotWizard.open(state, payouts => {
|
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
|
||||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
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) {
|
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
|
// Winnings go straight back in front of the player, so the next
|
||||||
// hand's stacks are already right.
|
// hand's stacks are already right.
|
||||||
Betting.creditWinnings(state, payouts);
|
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("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||||
|
|
||||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ const Money = {
|
|||||||
return this.fromCents(Math.ceil(cents / step) * step);
|
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) {
|
chipBreakdown(amount, legend) {
|
||||||
let cents = this.toCents(amount);
|
let cents = this.toCents(amount);
|
||||||
if (cents <= 0) return { chips: [], remainder: 0 };
|
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||||
|
|||||||
@@ -232,6 +232,24 @@ const PotWizard = {
|
|||||||
return wrap;
|
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() {
|
renderSummary() {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "potWizardSummary";
|
wrap.className = "potWizardSummary";
|
||||||
@@ -240,6 +258,20 @@ const PotWizard = {
|
|||||||
heading.textContent = "Final Payouts";
|
heading.textContent = "Final Payouts";
|
||||||
wrap.appendChild(heading);
|
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) {
|
if (this.totals.size === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "fieldHint";
|
empty.className = "fieldHint";
|
||||||
@@ -248,7 +280,7 @@ const PotWizard = {
|
|||||||
} else {
|
} else {
|
||||||
const legend = (this.state && this.state.chipLegend) || [];
|
const legend = (this.state && this.state.chipLegend) || [];
|
||||||
const list = document.createElement("ul");
|
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");
|
const li = document.createElement("li");
|
||||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||||
|
|
||||||
@@ -275,13 +307,12 @@ const PotWizard = {
|
|||||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||||
applyBtn.textContent = "Start Next Hand";
|
applyBtn.textContent = "Start Next Hand";
|
||||||
applyBtn.addEventListener("click", () => {
|
applyBtn.addEventListener("click", () => {
|
||||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
// Recomputed rather than reused from the render: the same numbers, but
|
||||||
id,
|
// taken at the moment the dealer commits rather than when they last
|
||||||
label: this.playerLabelById.get(id),
|
// looked at the screen.
|
||||||
amount,
|
const paid = this.rakeSplit();
|
||||||
}));
|
|
||||||
this.close();
|
this.close();
|
||||||
this.onFinished(payouts);
|
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
||||||
});
|
});
|
||||||
actions.appendChild(applyBtn);
|
actions.appendChild(applyBtn);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ const Render = {
|
|||||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
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
|
// 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");
|
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) {
|
if (bank.balanced) {
|
||||||
verdict.textContent = bank.discarded > 0
|
verdict.textContent = accountedFor.length > 0
|
||||||
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
? `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.";
|
: "Balanced — the cash covers every chip on the table.";
|
||||||
} else if (bank.difference > 0) {
|
} else if (bank.difference > 0) {
|
||||||
verdict.textContent =
|
verdict.textContent =
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ const HoldemState = {
|
|||||||
// announced hand by hand, so this is cleared with every other one-deal
|
// announced hand by hand, so this is cleared with every other one-deal
|
||||||
// decision rather than standing until somebody turns it off.
|
// decision rather than standing until somebody turns it off.
|
||||||
straddleCount: 0,
|
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
|
// 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
|
// felt can badge them. Read off the deal rather than worked out again
|
||||||
// later, the same way the posted blinds are.
|
// 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
|
// whatever is in front of those players is already in their committed
|
||||||
// chips, and nothing is owed on top of it.
|
// chips, and nothing is owed on top of it.
|
||||||
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
|
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 = [];
|
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||||
|
|
||||||
// A table saved before dealing and betting were separate has no flag, and
|
// 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`
|
// 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,
|
// 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.
|
// 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 (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
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 wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
||||||
const ledger = state.seats
|
const ledger = state.seats
|
||||||
.map((player, index) => ({
|
.map((player, index) => ({
|
||||||
@@ -698,6 +717,7 @@ const HoldemState = {
|
|||||||
handNumber: state.handNumber,
|
handNumber: state.handNumber,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
pot,
|
pot,
|
||||||
|
rake: taken,
|
||||||
payouts,
|
payouts,
|
||||||
ledger,
|
ledger,
|
||||||
});
|
});
|
||||||
@@ -818,6 +838,67 @@ const HoldemState = {
|
|||||||
//
|
//
|
||||||
// Chips in play counts everything a player could hand back: what's in front of
|
// 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.
|
// 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) {
|
bankReconciliation(state) {
|
||||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||||
const chipsInPlay = state.seats.reduce(
|
const chipsInPlay = state.seats.reduce(
|
||||||
@@ -837,11 +918,16 @@ const HoldemState = {
|
|||||||
// hunting for a miscount that never happened.
|
// hunting for a miscount that never happened.
|
||||||
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
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 {
|
return {
|
||||||
cashIn: Money.round(cashIn),
|
cashIn: Money.round(cashIn),
|
||||||
chipsInPlay: Money.round(chipsInPlay),
|
chipsInPlay: Money.round(chipsInPlay),
|
||||||
discarded,
|
discarded,
|
||||||
|
raked,
|
||||||
difference,
|
difference,
|
||||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
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