diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 9c6fc28..8d39b27 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2603,6 +2603,16 @@ body.hasActionBar #toastHost { color: #d6ffe0; } +/* The chips to push, under the amount they add up to. Quieter than the payout it + belongs to — the dealer reads the name and the money first, then counts. */ +.payoutChips { + display: block; + margin-top: 0.15rem; + font-size: 0.76rem; + color: #9fb8a6; + font-variant-numeric: tabular-nums; +} + .potWizardActions { display: flex; gap: 0.6rem; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 70da18d..9f120b2 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -48,6 +48,58 @@ const Money = { 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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index c4c47f9..036a4ca 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -246,10 +246,23 @@ const PotWizard = { empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first."; wrap.appendChild(empty); } else { + const legend = (this.state && this.state.chipLegend) || []; const list = document.createElement("ul"); for (const [id, amount] of this.totals.entries()) { const li = document.createElement("li"); li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`; + + // The amount is the answer to the arithmetic; the chips are the answer + // to the dealer's job. Only when the legend has been filled in — a + // table that never set up chip colours gets the number on its own, + // which is what it had before. + const chips = Money.formatChipBreakdown(amount, legend); + if (chips) { + const note = document.createElement("span"); + note.className = "payoutChips"; + note.textContent = chips; + li.appendChild(note); + } list.appendChild(li); } wrap.appendChild(list); diff --git a/tests/holdem/chipBreakdown.test.js b/tests/holdem/chipBreakdown.test.js new file mode 100644 index 0000000..652ca42 --- /dev/null +++ b/tests/holdem/chipBreakdown.test.js @@ -0,0 +1,82 @@ +// The chips to actually push, for an amount. +const test = require("node:test"); +const assert = require("node:assert"); +const { Money } = require("./load"); + +// A standard house set. +const LEGEND = [ + { value: 1, color: "#fff" }, + { value: 5, color: "#c0392b" }, + { value: 25, color: "#27ae60" }, + { value: 100, color: "#2c3e50" }, + { value: 500, color: "#8e44ad" }, +]; + +const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend); + +test("a payout comes back as chips, largest first", () => { + assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1"); +}); + +test("it uses the fewest chips on a normal set", () => { + const { chips } = Money.chipBreakdown(500, LEGEND); + assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]); +}); + +test("a denomination that doesn't fit is skipped rather than listed as none", () => { + assert.equal(say(7), "1×$5, 2×$1"); +}); + +test("nothing to pay is nothing to say", () => { + assert.equal(say(0), ""); + assert.equal(say(-5), ""); +}); + +test("no legend means no advice, not a wrong answer", () => { + // A table that never set chip colours up gets the amount on its own. + assert.equal(say(132, []), ""); + assert.equal(say(132, null), ""); +}); + +test("a legend with no values in it yet is the same as none", () => { + assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), ""); +}); + +test("what the smallest chip can't make is reported, not rounded away", () => { + // 50c in a game whose smallest chip is a dollar is the dealer's problem to solve, + // and they can only solve it if they're told. + const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND); + assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]); + assert.equal(remainder, 0.5); + assert.equal(say(10.5), "2×$5, $0.50 no chip covers"); +}); + +test("cent chips make cent amounts exactly", () => { + const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }]; + assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01"); + assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0); +}); + +test("the chips add up to the amount", () => { + for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) { + const { chips, remainder } = Money.chipBreakdown(amount, LEGEND); + const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder; + assert.equal(Money.round(total), amount, `${amount} should be accounted for`); + } +}); + +test("a legend in any order still pays largest first", () => { + const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }]; + assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1"); +}); + +test("the colour comes along, so the felt can say it in chips", () => { + const { chips } = Money.chipBreakdown(25, LEGEND); + assert.equal(chips[0].color, "#27ae60"); +}); + +test("a legend that exists but can't reach the amount does say so", () => { + // Different from having no legend at all: this table has told us its chips, and + // the answer is that they don't go that low. + assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers"); +});