Hold'em dealer: 55 polish rounds, one merge #101

Merged
jheaps merged 71 commits from release/casino-holdem-polish into master 2026-09-02 10:00:15 -06:00
5 changed files with 147 additions and 8 deletions
Showing only changes of commit ab92259e3e - Show all commits
@@ -19,7 +19,7 @@ const HandHistoryExport = {
// 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 = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
@@ -41,7 +41,12 @@ const HandHistoryExport = {
rows.push(["Session Standings"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
//
// Notes go last so the money columns keep the shape a spreadsheet expects.
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
// somebody settles up from, and it was only ever visible inside that seat's
// own editor.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
@@ -51,6 +56,7 @@ const HandHistoryExport = {
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
row.notes || "",
]);
});
@@ -76,6 +82,17 @@ const HandHistoryExport = {
}
}
// The table's own notes — side bets, IOUs, the promo running until midnight.
// The dealer typed them because they matter to the night, and the export is the
// thing that outlives the browser tab; leaving them behind meant the one place
// the context was written down was the one place it couldn't be handed over.
// csvField quotes the line breaks, so a multi-line note survives as one cell.
if (notes && notes.trim()) {
rows.push([]);
rows.push(["Table Notes"]);
rows.push([notes.trim()]);
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
},
@@ -86,8 +103,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
download(handHistory, standings = [], options = {}) {
const csv = this.toCsv(handHistory, standings, options);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -580,7 +580,8 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
});
// The load happens on the first line of this file, long before there's a Toast to
@@ -532,6 +532,14 @@ const HoldemState = {
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
// "Venmo: @ann". It's carried here so the export can hand it over with
// the money it belongs to; the standings panel itself doesn't show it.
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
.filter(field => (field.label || "").trim() || (field.value || "").trim())
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
.join("; ");
return {
...t,
in: Money.round(t.in),
@@ -540,6 +548,7 @@ const HoldemState = {
boughtIn,
chipsNow,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
})
.sort((a, b) => b.net - a.net);
+97
View File
@@ -0,0 +1,97 @@
// The things the dealer wrote down, in the file they hand over.
//
// The export already carried the hands, the standings and the settle-up. It didn't
// carry either of the two places a dealer types context — the per-seat fields and the
// table's own notes — so the one place an IOU was written down was the one place it
// couldn't leave.
const test = require("node:test");
const assert = require("node:assert");
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
const tableWithNotes = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
state.seats[0].customFields = [
{ label: "Seat rent", value: "paid to 2am" },
{ label: "Venmo", value: "@ann" },
];
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
return state;
};
test("a seat's own fields ride along with its standings row", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
assert.match(csv, /IOU: \$20 to Ann/);
});
test("the money columns keep their order, with notes on the end", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
});
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.ok(calRow, "Cal should have a row");
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
assert.ok(!/undefined|null/.test(calRow));
});
test("a blank label or value doesn't produce a stray colon", () => {
const state = tableWithNotes();
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.match(calRow, /Note/);
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
});
test("the table's notes are in the file", () => {
const state = tableWithNotes();
state.notes = "High-hand promo until midnight.";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
assert.match(csv, /Table Notes/);
assert.match(csv, /High-hand promo until midnight\./);
});
test("a multi-line note survives as one cell", () => {
// csvField quotes anything with a line break in it, so the note doesn't turn into
// rows a spreadsheet reads as more players.
const state = tableWithNotes();
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
});
test("no notes means no section, not an empty heading", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
assert.ok(!/Table Notes/.test(csv));
});
test("the export still works with no standings at all", () => {
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
assert.match(csv, /Table Notes/);
});
test("asking for the standings doesn't change the seats", () => {
const state = tableWithNotes();
const before = JSON.stringify(state.seats);
HoldemState.sessionStandings(state);
assert.equal(JSON.stringify(state.seats), before);
});
+18 -3
View File
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
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.
// Standings rows: the money columns have to be numeric. Named rather than
// "everything after the first", which is what this used to say — the row now
// ends with a Notes column carrying whatever the dealer typed against the seat,
// and that is text by definition. The point of this test is that a column
// somebody totals adds up, so it checks the columns somebody totals.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
const header = splitRow(lines[standingsStart]);
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
.map(name => {
const index = header.indexOf(name);
assert.ok(index > 0, `the standings header should still have a ${name} column`);
return index;
});
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`);
const cells = splitRow(line);
moneyColumns.forEach(index => {
const cell = cells[index];
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
`standings cell "${cell}" in column ${header[index]} isn't a number`);
});
});
});