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
125 lines
4.8 KiB
JavaScript
125 lines
4.8 KiB
JavaScript
// Turning the standings into "who hands cash to whom".
|
|
const test = require("node:test");
|
|
const assert = require("node:assert");
|
|
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
|
|
|
|
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
|
|
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
|
|
const paidTo = (plan, name) =>
|
|
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
|
|
const paidBy = (plan, name) =>
|
|
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
|
|
|
|
test("one loser, one winner", () => {
|
|
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
|
|
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
|
|
assert.equal(plan.balanced, true);
|
|
});
|
|
|
|
test("everybody flat means nothing to do", () => {
|
|
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
|
|
assert.deepEqual(plan.payments, []);
|
|
assert.equal(plan.balanced, true);
|
|
});
|
|
|
|
test("nobody at all means nothing to do", () => {
|
|
const plan = SettleUp.plan([]);
|
|
assert.deepEqual(plan.payments, []);
|
|
assert.equal(plan.balanced, true);
|
|
});
|
|
|
|
test("each player ends up square", () => {
|
|
const table = standings([
|
|
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
|
]);
|
|
const plan = SettleUp.plan(table);
|
|
|
|
table.forEach(row => {
|
|
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
|
|
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
|
|
});
|
|
assert.equal(plan.balanced, true);
|
|
});
|
|
|
|
test("at most one payment fewer than there are people", () => {
|
|
const table = standings([
|
|
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
|
]);
|
|
const plan = SettleUp.plan(table);
|
|
assert.ok(plan.payments.length <= table.length - 1,
|
|
`${plan.payments.length} payments for ${table.length} players`);
|
|
});
|
|
|
|
test("a player who owes two people has both lines together", () => {
|
|
// The list gets read out one player at a time, so a payer's obligations must not
|
|
// be split up by somebody else's.
|
|
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
|
|
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
|
|
});
|
|
|
|
test("cents survive", () => {
|
|
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
|
|
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
|
|
assert.equal(plan.balanced, true);
|
|
});
|
|
|
|
test("more chips than cash leaves somebody unpaid, and says so", () => {
|
|
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
|
|
// cannot all be paid, and pretending otherwise is how a real person ends up short.
|
|
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
|
|
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
|
|
assert.equal(plan.shortBy, 25);
|
|
assert.equal(plan.overBy, 0);
|
|
assert.equal(plan.balanced, false);
|
|
});
|
|
|
|
test("less chips than cash leaves a payer with nowhere to send it", () => {
|
|
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
|
|
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
|
|
assert.equal(plan.overBy, 25);
|
|
assert.equal(plan.shortBy, 0);
|
|
assert.equal(plan.balanced, false);
|
|
});
|
|
|
|
test("no payment is ever for nothing", () => {
|
|
const plan = SettleUp.plan(standings([
|
|
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
|
|
]));
|
|
assert.ok(plan.payments.every(p => p.amount > 0));
|
|
assert.equal(plan.payments.length, 1);
|
|
});
|
|
|
|
test("nobody pays themselves", () => {
|
|
const plan = SettleUp.plan(standings([
|
|
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
|
|
]));
|
|
assert.ok(plan.payments.every(p => p.from !== p.to));
|
|
});
|
|
|
|
test("the payments total what the winners are owed", () => {
|
|
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
|
|
const plan = SettleUp.plan(table);
|
|
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
|
|
assert.equal(Money.round(moved), 60);
|
|
});
|
|
|
|
test("it settles a real table's standings, not just hand-made numbers", () => {
|
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
|
state.seats[0].buyIns = 100;
|
|
state.seats[1].buyIns = 100;
|
|
state.seats[2].buyIns = 100;
|
|
state.seats[0].stack = 175;
|
|
state.seats[1].stack = 90;
|
|
state.seats[2].stack = 35;
|
|
|
|
const rows = HoldemState.sessionStandings(state);
|
|
const plan = SettleUp.plan(rows);
|
|
|
|
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
|
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
|
assert.equal(plan.balanced, true);
|
|
rows.forEach(row => {
|
|
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
|
|
});
|
|
});
|