Merge pull request 'Make big numbers readable, and make the export add up' (#46) from feature/casino-holdem-resilience into master
Build and Deploy / build-deploy (push) Canceled after 5s
Build and Deploy / build-deploy (push) Canceled after 5s
This commit was merged in pull request #46.
This commit is contained in:
@@ -1923,6 +1923,24 @@ body.hasActionBar #holdemSidebar {
|
|||||||
margin-top: 0.1rem;
|
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 {
|
.blindsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -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 = []) {
|
toCsv(handHistory, standings = []) {
|
||||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
@@ -25,7 +29,7 @@ const HandHistoryExport = {
|
|||||||
rows.push([
|
rows.push([
|
||||||
entry.handNumber,
|
entry.handNumber,
|
||||||
this.formatTimestamp(entry.timestamp),
|
this.formatTimestamp(entry.timestamp),
|
||||||
Money.format(entry.pot),
|
Money.forSheet(entry.pot),
|
||||||
payouts,
|
payouts,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -41,12 +45,12 @@ const HandHistoryExport = {
|
|||||||
standings.forEach(row => {
|
standings.forEach(row => {
|
||||||
rows.push([
|
rows.push([
|
||||||
row.name,
|
row.name,
|
||||||
Money.format(row.in),
|
Money.forSheet(row.in),
|
||||||
Money.format(row.won),
|
Money.forSheet(row.won),
|
||||||
Money.formatSigned(row.net),
|
Money.forSheet(row.net),
|
||||||
Money.format(row.boughtIn || 0),
|
Money.forSheet(row.boughtIn || 0),
|
||||||
Money.format(row.chipsNow || 0),
|
Money.forSheet(row.chipsNow || 0),
|
||||||
Money.formatSigned(row.cashPosition || 0),
|
Money.forSheet(row.cashPosition || 0),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,27 @@ const Money = {
|
|||||||
return this.toCents(amount) / 100;
|
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) {
|
format(amount) {
|
||||||
const value = Number(amount) || 0;
|
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
|
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||||
|
|||||||
@@ -261,11 +261,14 @@ const Render = {
|
|||||||
const total = handTotal > 0
|
const total = handTotal > 0
|
||||||
? handTotal
|
? handTotal
|
||||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
: 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 stackEl = node.querySelector(".seatPuckStack");
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||||
|
this.fitAmount(stackEl);
|
||||||
|
|
||||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||||
node.classList.toggle("seatEmpty", empty);
|
node.classList.toggle("seatEmpty", empty);
|
||||||
@@ -274,6 +277,17 @@ const Render = {
|
|||||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
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
|
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||||
// (which live in a separate panel) never lose focus.
|
// (which live in a separate panel) never lose focus.
|
||||||
refreshPuck(index) {
|
refreshPuck(index) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const Money = load("money");
|
|||||||
const PotCalculator = load("potCalculator");
|
const PotCalculator = load("potCalculator");
|
||||||
const SplitCalculator = load("splitCalculator");
|
const SplitCalculator = load("splitCalculator");
|
||||||
const Betting = load("betting");
|
const Betting = load("betting");
|
||||||
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||||
const HoldemState = require(path.join(SRC, "state.js"));
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
@@ -68,6 +69,7 @@ function contributions(state) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Money,
|
Money,
|
||||||
|
HandHistoryExport,
|
||||||
PotCalculator,
|
PotCalculator,
|
||||||
SplitCalculator,
|
SplitCalculator,
|
||||||
Betting,
|
Betting,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user