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
172 lines
7.8 KiB
JavaScript
172 lines
7.8 KiB
JavaScript
// Shared money handling for every casino surface. Kept apart from the table's
|
||
// rendering so the quick-split calculator can pull it in without dragging the whole
|
||
// seat/chip UI along with it.
|
||
//
|
||
// Everything the dealer touches is money, and money is decimal, so every module used
|
||
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
|
||
// sixteen times in six files, which is sixteen chances for one of them to drift.
|
||
// It lives here now, and the modules that do arithmetic call these instead.
|
||
const Money = {
|
||
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
|
||
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
|
||
// exact `> 0` would treat that player as still having chips to bet.
|
||
EPSILON: 0.004,
|
||
|
||
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
|
||
toCents(amount) {
|
||
return Math.round((Number(amount) || 0) * 100);
|
||
},
|
||
|
||
fromCents(cents) {
|
||
return cents / 100;
|
||
},
|
||
|
||
round(amount) {
|
||
return this.toCents(amount) / 100;
|
||
},
|
||
|
||
// Cents only when there are cents.
|
||
//
|
||
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||
// 25c-blind game reads exactly as it did.
|
||
format(amount) {
|
||
const value = Number(amount) || 0;
|
||
const hasCents = this.toCents(value) % 100 !== 0;
|
||
return `$${value.toLocaleString(undefined, {
|
||
minimumFractionDigits: hasCents ? 2 : 0,
|
||
maximumFractionDigits: 2,
|
||
})}`;
|
||
},
|
||
|
||
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||
// and no thousands separators, because those are what make a column of money
|
||
// import as text and refuse to add up.
|
||
forSheet(amount) {
|
||
return (this.toCents(amount) / 100).toFixed(2);
|
||
},
|
||
|
||
// The chips to actually push, for an amount.
|
||
//
|
||
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
|
||
// still have to work out that $132 is two blacks, a green, a red and two whites
|
||
// while six people watch. The chip legend already knows the denominations.
|
||
//
|
||
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
|
||
// values — but poker chips are not arbitrary. Every real set is canonical (each
|
||
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
|
||
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
|
||
// use one chip more than it had to.
|
||
//
|
||
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||
// problem for the dealer to solve, not a rounding error to hide.
|
||
// The smallest chip the table has said it plays with, in cents. Zero when no
|
||
// denominations are set, which means the tool has been told nothing about what
|
||
// chips exist and shouldn't pretend otherwise.
|
||
chipStepCents(legend) {
|
||
const values = (legend || [])
|
||
.map(chip => this.toCents(chip.value))
|
||
.filter(cents => cents > 0);
|
||
return values.length > 0 ? Math.min(...values) : 0;
|
||
},
|
||
|
||
// Rounds an amount up to something the table can actually build out of its chips.
|
||
//
|
||
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
|
||
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
|
||
// amount below a legal minimum, which rounding the other way can.
|
||
upToChip(amount, legend) {
|
||
const step = this.chipStepCents(legend);
|
||
if (step <= 0) return this.round(amount);
|
||
const cents = this.toCents(amount);
|
||
if (cents <= 0) return this.round(amount);
|
||
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 };
|
||
|
||
const values = (legend || [])
|
||
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
|
||
.filter(entry => entry.cents > 0)
|
||
.sort((a, b) => b.cents - a.cents);
|
||
|
||
const chips = [];
|
||
values.forEach(entry => {
|
||
const count = Math.floor(cents / entry.cents);
|
||
if (count <= 0) return;
|
||
cents -= count * entry.cents;
|
||
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
|
||
});
|
||
|
||
return { chips, remainder: this.fromCents(cents) };
|
||
},
|
||
|
||
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
|
||
//
|
||
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
|
||
// hasn't told us its denominations, so there is no advice to give and the amount
|
||
// stands on its own. A legend that exists but can't reach the amount is different,
|
||
// and does say so.
|
||
formatChipBreakdown(amount, legend) {
|
||
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
|
||
if (!hasDenominations) return "";
|
||
|
||
const { chips, remainder } = this.chipBreakdown(amount, legend);
|
||
if (chips.length === 0 && remainder === 0) return "";
|
||
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
|
||
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
|
||
return parts.join(", ");
|
||
},
|
||
|
||
// Should focusing this field select what's already in it?
|
||
//
|
||
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
|
||
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
|
||
// it already has. A dealer taps the box because they want a *different* number,
|
||
// and a tap puts the caret after the suggestion instead of over it. Measured on a
|
||
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
|
||
// showing 200, tap, type "50" — 20050.
|
||
//
|
||
// Only number boxes. A name or a note is a thing you edit, and selecting all of
|
||
// it on focus would be the same mistake pointing the other way.
|
||
selectsOnFocus(field) {
|
||
if (!field || field.tagName !== "INPUT") return false;
|
||
const type = String(field.type || "").toLowerCase();
|
||
if (type === "number") return true;
|
||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||
},
|
||
|
||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||
// glance, and the sign belongs out front where the eye lands first.
|
||
formatSigned(amount) {
|
||
const value = Number(amount) || 0;
|
||
if (value > 0) return `+${this.format(value)}`;
|
||
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
||
return this.format(0);
|
||
},
|
||
};
|
||
|
||
if (typeof module !== "undefined" && module.exports) {
|
||
module.exports = Money;
|
||
}
|