I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".
This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.
Ann +$75 Dee +$110 Fay -> Dee $70
Ben -$10 Eve -$40 Cal -> Dee $40
Cal -$65 Fay -$70 Cal -> Ann $25
Eve -> Ann $40
Ben -> Ann $10
Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.
All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.
When the chips on the table don't match the cash taken
Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.
$100 of winnings has nothing to pay it -- there are more chips on the table than
cash was taken for. Fix that above before anyone cashes out.
The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.
13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.
Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
106 lines
4.5 KiB
JavaScript
106 lines
4.5 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 = []) {
|
|
const rows = [["Hand #", "Time", "Pot", "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),
|
|
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.
|
|
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
|
|
standings.forEach(row => {
|
|
rows.push([
|
|
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),
|
|
]);
|
|
});
|
|
|
|
// 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`,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = []) {
|
|
const csv = this.toCsv(handHistory, standings);
|
|
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;
|
|
}
|