"Ann - $135" is the answer to the arithmetic. The dealer's actual job at that moment
is working out that $135 is a black, a green and two reds, while four people watch
them do it.
The chip legend has known the denominations all along. It was only being used to read
chips *into* an amount -- counting a seat's stack -- and never to turn an amount back
into chips.
Final Payouts
Ann - $135
1x$100, 1x$25, 2x$5
Largest first, which is greedy and so not optimal for an arbitrary set of values. 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 still gets a valid answer, possibly one chip longer than it needed to
be, which is a fair trade for not solving a knapsack in a modal.
Two cases where saying nothing is the right answer, and one where it is not.
A table with no chip legend gets the amount on its own -- it has not told us its
denominations, so there is no advice to give. But a legend that exists and cannot reach
the amount does say so: "$3 no chip covers" when the smallest chip is $5. Half a dollar
in a dollar-chip game is the dealer's problem to solve and they can only solve it if
they are told, so it is never rounded away.
12 tests (244 total): the breakdown adds up to the amount across eight amounts, cent
chips make cent amounts exactly, a jumbled legend still pays largest first, the colour
comes along for a future caller, and the two no-advice cases are told apart from the
cannot-reach one.
Driven through a real all-in hand at four sizes: $135 reads "1x$100, 1x$25, 2x$5" and
a tournament-sized $1,350 reads "2x$500, 3x$100, 2x$25", fitting without spill down to
360px. With the legend emptied the line disappears and the payout stands alone.
Contrast clean, no console errors.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
134 lines
6.0 KiB
JavaScript
134 lines
6.0 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.
|
||
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;
|
||
}
|