diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 90424e3..a7e6145 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1923,6 +1923,24 @@ body.hasActionBar #holdemSidebar { margin-top: 0.1rem; } +/* Tournament-sized amounts. A number has no spaces to break on, so an amount too + wide for the 64px puck neither wraps nor ellipsises — it draws straight out over + the seat next to it. These step the size down so every digit stays on its own + puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more + comfortably, but a dealer reading a rounded stack off the felt is a worse failure + than a small one, and the exact figure is what the tool is for. */ +.seatPuckStack.puckAmountLong, +.seatPuckTotal.puckAmountLong { + font-size: 0.5rem; + letter-spacing: -0.01em; +} + +.seatPuckStack.puckAmountHuge, +.seatPuckTotal.puckAmountHuge { + font-size: 0.44rem; + letter-spacing: -0.02em; +} + .blindsRow { display: flex; flex-wrap: wrap; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 571ad8a..9a63925 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -15,6 +15,10 @@ const HandHistoryExport = { } }, + // Money columns go in as bare numbers. Written as "$1,250,000.00" they import as + // text, so the one thing anybody opens this file to do — total a column and see + // 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. toCsv(handHistory, standings = []) { const rows = [["Hand #", "Time", "Pot", "Payouts"]]; // handHistory is stored newest-first; the export reads better oldest-first. @@ -25,7 +29,7 @@ const HandHistoryExport = { rows.push([ entry.handNumber, this.formatTimestamp(entry.timestamp), - Money.format(entry.pot), + Money.forSheet(entry.pot), payouts, ]); }); @@ -41,12 +45,12 @@ const HandHistoryExport = { standings.forEach(row => { rows.push([ row.name, - Money.format(row.in), - Money.format(row.won), - Money.formatSigned(row.net), - Money.format(row.boughtIn || 0), - Money.format(row.chipsNow || 0), - Money.formatSigned(row.cashPosition || 0), + Money.forSheet(row.in), + Money.forSheet(row.won), + Money.forSheet(row.net), + Money.forSheet(row.boughtIn || 0), + Money.forSheet(row.chipsNow || 0), + Money.forSheet(row.cashPosition || 0), ]); }); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 912709e..79b41fd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -25,9 +25,27 @@ const Money = { 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; - return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + 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); }, // "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 92af29c..2e21b50 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -261,11 +261,14 @@ const Render = { const total = handTotal > 0 ? handTotal : HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); - node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : ""; + const totalEl = node.querySelector(".seatPuckTotal"); + totalEl.textContent = total > 0 ? Money.format(total) : ""; + this.fitAmount(totalEl); const stackEl = node.querySelector(".seatPuckStack"); const stack = Betting.stackOf(this.state, player); stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : ""; + this.fitAmount(stackEl); node.classList.toggle("seatActing", index === this.state.actingSeatIndex); node.classList.toggle("seatEmpty", empty); @@ -274,6 +277,17 @@ const Render = { node.classList.toggle("seatLive", player.inHand && !player.folded); }, + // A seat puck is 64px of usable width. A tournament stack doesn't fit in that and, + // because the number has no spaces to break on, it doesn't wrap or ellipsise + // either — it just draws straight out over the neighbouring seat. Stepping the + // font down keeps every digit, which matters: a dealer misreading a stack is a + // worse outcome than a small one, so this shrinks rather than abbreviating. + fitAmount(el) { + const length = (el.textContent || "").length; + el.classList.toggle("puckAmountLong", length >= 8 && length < 10); + el.classList.toggle("puckAmountHuge", length >= 10); + }, + // Cheaper than a full renderSeats() — used after edits so the seat editor's inputs // (which live in a separate panel) never lose focus. refreshPuck(index) { diff --git a/tests/holdem/load.js b/tests/holdem/load.js index 24ad5d9..f2662c2 100644 --- a/tests/holdem/load.js +++ b/tests/holdem/load.js @@ -18,6 +18,7 @@ const Money = load("money"); const PotCalculator = load("potCalculator"); const SplitCalculator = load("splitCalculator"); const Betting = load("betting"); +const HandHistoryExport = load("handHistoryExport"); // state.js exports `HoldemState`, which doesn't match its filename. const HoldemState = require(path.join(SRC, "state.js")); @@ -68,6 +69,7 @@ function contributions(state) { module.exports = { Money, + HandHistoryExport, PotCalculator, SplitCalculator, Betting, diff --git a/tests/holdem/money.test.js b/tests/holdem/money.test.js new file mode 100644 index 0000000..2e2da89 --- /dev/null +++ b/tests/holdem/money.test.js @@ -0,0 +1,126 @@ +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Money, HandHistoryExport } = require("./load"); + +describe("Money.format", () => { + test("whole amounts drop the pointless cents", () => { + assert.equal(Money.format(12), "$12"); + assert.equal(Money.format(450), "$450"); + assert.equal(Money.format(1250000), "$1,250,000"); + assert.equal(Money.format(0), "$0"); + }); + + test("amounts that carry cents still show them", () => { + assert.equal(Money.format(12.5), "$12.50"); + assert.equal(Money.format(0.25), "$0.25"); + assert.equal(Money.format(3.01), "$3.01"); + }); + + test("a quarter-blind game reads exactly as it did", () => { + // The case that would be ruined by dropping cents unconditionally. + [0.25, 0.5, 1.75, 12.05].forEach(v => { + assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`); + }); + }); + + test("floating-point dust doesn't invent cents", () => { + assert.equal(Money.format(0.1 + 0.2), "$0.30"); + assert.equal(Money.format(1.0000000001), "$1"); + }); + + test("signed amounts put the sign out front, both scales", () => { + assert.equal(Money.formatSigned(87500), "+$87,500"); + assert.equal(Money.formatSigned(-87500), "-$87,500"); + assert.equal(Money.formatSigned(-12.5), "-$12.50"); + assert.equal(Money.formatSigned(0), "$0"); + }); + + test("rubbish in doesn't produce rubbish out", () => { + assert.equal(Money.format(null), "$0"); + assert.equal(Money.format(undefined), "$0"); + assert.equal(Money.format("nonsense"), "$0"); + }); +}); + +describe("Money.forSheet", () => { + test("a bare number a spreadsheet will add up", () => { + assert.equal(Money.forSheet(1250000), "1250000.00"); + assert.equal(Money.forSheet(12.5), "12.50"); + assert.equal(Money.forSheet(-87500), "-87500.00"); + }); + + test("no currency symbol and no thousands separators", () => { + const written = Money.forSheet(1234567.89); + assert.ok(!written.includes("$"), "a $ makes the cell text"); + assert.ok(!written.includes(","), "a comma makes the cell text"); + assert.equal(Number(written), 1234567.89, "and it parses straight back to a number"); + }); +}); + +describe("the exported CSV", () => { + const history = [ + { handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] }, + { handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] }, + ]; + const standings = [ + { id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 }, + { id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 }, + ]; + + // Splitting on commas isn't good enough: the timestamp column legitimately holds + // one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason. + const splitRow = line => { + const fields = []; + let field = ""; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quoted) { + if (c === '"' && line[i + 1] === '"') { field += '"'; i++; } + else if (c === '"') quoted = false; + else field += c; + } else if (c === '"') quoted = true; + else if (c === ",") { fields.push(field); field = ""; } + else field += c; + } + fields.push(field); + return fields; + }; + + test("every money cell parses as a number", () => { + const csv = HandHistoryExport.toCsv(history, standings); + const lines = csv.split("\r\n"); + + lines.slice(1, 3).forEach(line => { + const cell = splitRow(line)[2]; // Pot + assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`); + }); + + // Standings rows: every column after the name has to be numeric. + const standingsStart = lines.findIndex(l => l.startsWith("Player,")); + lines.slice(standingsStart + 1).filter(Boolean).forEach(line => { + splitRow(line).slice(1).forEach(cell => { + assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`); + }); + }); + }); + + test("a million-dollar pot doesn't need quoting any more", () => { + const csv = HandHistoryExport.toCsv(history, []); + assert.ok(csv.includes("1250000.00"), "written plainly"); + assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string"); + }); + + test("names and payout descriptions stay human", () => { + const csv = HandHistoryExport.toCsv(history, standings); + assert.ok(csv.includes("Alexandra"), "the name column is still a name"); + assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence"); + }); + + test("a name with a comma is still quoted properly", () => { + const csv = HandHistoryExport.toCsv([], [ + { id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 }, + ]); + assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed"); + }); +});