The most ordinary thing that happens at a home game after midnight — somebody counts up and goes home — and the tool had nothing for it. Freeing the chair meant wiping the seat by hand, and the bank check then declared their whole stack missing: "someone has paid for chips they haven't been given", for the rest of the night. Measured on a balanced table, cashing a player out with $350 in front of them left the check $350 short. Wiping their buy-in as well silenced it, at the price of losing their entire night from the standings and from the file the dealer hands over at the end. Cashing out is two facts, and both have to survive the empty chair: the box took their cash, and the box handed their chips back. Both are kept now, so the chair opens and the money still adds up. Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" — what they bought in for, and whether they leave up or down, because that is the sentence the dealer says while their hands are moving. The bank check gains a Paid out line and reads "balanced ... once the $350 paid out to players who have left is counted", alongside the discards and the house's cut. The standings keep them with a stripe and the words "· paid out", so nobody counts money out to them twice at 2am, and the export marks them the same way. It refuses to pay anybody with money still in the middle: taking chips out of a pot other people can win, or handing the same chips over twice, are both worse than waiting for the hand to finish. The chair gets a fresh id, because the person who had it has gone and the next player to sit down is not them. Two things this turned up. The first draft worked out what a seat was holding by itself, read `Number(null)` as a perfectly good zero, and offered to pay a player with no stack set nothing at all — it asks Betting now, which owns that rule. The second was mine and the audit caught it: marking the departed row with opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer settles from. It is a stripe instead. 19 new tests, with the departed buy-in dropped from the check and the pot guard disabled on purpose to confirm they fail. Walked through in the browser: menu, confirm, payout, bank, standings, undo, and the refusal. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
131 lines
6.1 KiB
JavaScript
131 lines
6.1 KiB
JavaScript
// Turns the in-memory hand history into a CSV file the dealer can download for a
|
|
// paper trail — a house manager or a disputed chip count needs something that
|
|
// outlives the browser tab, not just a scrollable panel in the sidebar.
|
|
const HandHistoryExport = {
|
|
csvField(value) {
|
|
const str = String(value ?? "");
|
|
return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
|
|
},
|
|
|
|
formatTimestamp(timestamp) {
|
|
try {
|
|
return new Date(timestamp).toLocaleString();
|
|
} catch {
|
|
return "";
|
|
}
|
|
},
|
|
|
|
// 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 = [], { notes = "" } = {}) {
|
|
// The rake gets its own column rather than being folded into the pot: the two
|
|
// numbers answer different questions — what was in the middle, and what the
|
|
// house took out of it — and a night's worth of the second one is what the box
|
|
// is counted against.
|
|
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
|
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
|
[...handHistory].reverse().forEach(entry => {
|
|
const payouts = (entry.payouts || [])
|
|
.map(p => `${p.label}: ${Money.format(p.amount)}`)
|
|
.join("; ");
|
|
rows.push([
|
|
entry.handNumber,
|
|
this.formatTimestamp(entry.timestamp),
|
|
Money.forSheet(entry.pot),
|
|
Money.forSheet(entry.rake || 0),
|
|
payouts,
|
|
]);
|
|
});
|
|
|
|
// The settle-up numbers go in the same file as the hands they came from, so a
|
|
// dealer handing over one export isn't asked for a second one at cash-out.
|
|
if (standings.length > 0) {
|
|
rows.push([]);
|
|
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.
|
|
//
|
|
// 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([
|
|
// Somebody who has gone home is marked in the file the same way the
|
|
// panel marks them, because this is the copy that gets read at the
|
|
// end of the night when nobody remembers who has already been paid.
|
|
row.cashedOut ? `${row.name} (paid out)` : row.name,
|
|
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),
|
|
row.notes || "",
|
|
]);
|
|
});
|
|
|
|
// And the answer the standings are read to get to. Whoever opens this file
|
|
// wants the payment list, not to redo the arithmetic in a spreadsheet.
|
|
const plan = SettleUp.plan(standings);
|
|
if (plan.payments.length > 0) {
|
|
rows.push([]);
|
|
rows.push(["Settle Up"]);
|
|
rows.push(["Pays", "Receives", "Amount"]);
|
|
plan.payments.forEach(payment => {
|
|
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
|
|
});
|
|
if (!plan.balanced) {
|
|
// The file is the paper trail, so the caveat travels with it rather
|
|
// than living only in a sidebar nobody exported.
|
|
rows.push([
|
|
plan.shortBy > 0
|
|
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
|
|
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
},
|
|
|
|
fileName() {
|
|
const now = new Date();
|
|
const pad = n => String(n).padStart(2, "0");
|
|
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
return `holdem-hand-history-${stamp}.csv`;
|
|
},
|
|
|
|
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");
|
|
link.href = url;
|
|
link.download = this.fileName();
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = HandHistoryExport;
|
|
}
|