A focused <input type="number"> is a spinbutton, and a browser lets the mouse wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500 becomes $501. Both go straight through to localStorage, because these fields save on `input` like everything else here. Nothing is announced and nothing looks wrong. The tool has invented a chip, on a table whose whole purpose is that the chip count is right, and the gesture that did it was a dealer reaching for another panel. The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is the only one taken away. Verified after: the value holds, one ArrowUp still moves it, typing still works, and the page still scrolls. Blur rather than preventDefault. Cancelling the event stops the value moving and stops the page moving with it, which trades a silent bug for a sidebar that mysteriously will not scroll -- a problem the dealer can see and cannot explain, instead of one they cannot see at all. The decision lives in Money as a predicate beside selectsOnFocus, so the rule is testable without a browser and main.js only does the blurring. Two things checked rather than assumed. The tagName guard in the predicate is decoration: deleting it passes every test, because nothing but an <input> carries type="number". That is now said in the comment instead of being left to look load-bearing. And the page's one <select> -- which way the blinds move -- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays where it was, so it is left out of the rule rather than defended against. 916 tests pass, 6 of them new. Four fault injections into the predicate are caught; a fifth was applied, not caught, and turned out not to be a fault. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
195 lines
9.2 KiB
JavaScript
195 lines
9.2 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";
|
||
},
|
||
|
||
// Would a wheel over this field change what it says?
|
||
//
|
||
// True only for a focused `<input type="number">`, which a browser treats as a
|
||
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
|
||
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
|
||
// Scrolling is the one nobody means: the dealer is reaching for another panel and
|
||
// the cursor happens to still be over a field they touched a moment ago.
|
||
//
|
||
// A predicate rather than a listener so the rule can be tested without a browser,
|
||
// the same way selectsOnFocus is. main.js does the blurring.
|
||
wheelWouldEdit(field, activeElement) {
|
||
// The tag check is belt and braces and known to be so: nothing but an <input>
|
||
// carries type="number", so the line below already excludes every textarea,
|
||
// select and button on the page. Deleting it passes every test in this file,
|
||
// which was checked rather than assumed. It stays for symmetry with
|
||
// selectsOnFocus above, not because it is load-bearing.
|
||
if (!field || field.tagName !== "INPUT") return false;
|
||
if (String(field.type || "").toLowerCase() !== "number") return false;
|
||
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
|
||
// blurring something that isn't focused would be a no-op with a side effect.
|
||
return activeElement === field;
|
||
},
|
||
|
||
// "+$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;
|
||
}
|