Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a92a389245 |
@@ -130,6 +130,12 @@
|
|||||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="settleUp" hidden>
|
||||||
|
<h3 class="panelSubhead">Settle up</h3>
|
||||||
|
<ul id="settleUpList" class="settleUpList"></ul>
|
||||||
|
<p id="settleUpNote" class="fieldHint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="panelSubhead">Hands</h3>
|
<h3 class="panelSubhead">Hands</h3>
|
||||||
<div id="handHistoryList"></div>
|
<div id="handHistoryList"></div>
|
||||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||||
@@ -443,6 +449,7 @@
|
|||||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
|||||||
@@ -1655,6 +1655,46 @@ button,
|
|||||||
color: #f0b46a;
|
color: #f0b46a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The settle-up list is read out loud, one line per payment, so it's laid out like
|
||||||
|
the bank rows above it: name on the left, money hard right on tabular figures so a
|
||||||
|
column of amounts lines up on the decimal point. */
|
||||||
|
.settleUpList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settleUpRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.18rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settleUpRow:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settleUpWho {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settleUpAmount {
|
||||||
|
color: #f2d489;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same amber the bank check uses when the columns don't agree — it's the same fault
|
||||||
|
being reported twice, and it should look like it. */
|
||||||
|
.fieldHint.settleUpOff {
|
||||||
|
color: #f0b46a;
|
||||||
|
}
|
||||||
|
|
||||||
.standingsHeader {
|
.standingsHeader {
|
||||||
color: #8a8a8a;
|
color: #8a8a8a;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -53,6 +53,27 @@ const HandHistoryExport = {
|
|||||||
Money.forSheet(row.cashPosition || 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");
|
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||||
|
|||||||
@@ -730,6 +730,58 @@ const Render = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.renderBankReconciliation();
|
this.renderBankReconciliation();
|
||||||
|
this.renderSettleUp(standings);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||||
|
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
|
||||||
|
// and it's the part of the night people actually get wrong.
|
||||||
|
renderSettleUp(standings) {
|
||||||
|
const host = document.getElementById("settleUp");
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const plan = SettleUp.plan(standings);
|
||||||
|
const note = document.getElementById("settleUpNote");
|
||||||
|
const list = document.getElementById("settleUpList");
|
||||||
|
|
||||||
|
// Nothing to show before anyone is up or down. A panel that says "no payments"
|
||||||
|
// to a table that hasn't played a hand is just noise in the sidebar.
|
||||||
|
host.hidden = plan.payments.length === 0 && plan.balanced;
|
||||||
|
if (host.hidden) return;
|
||||||
|
|
||||||
|
list.innerHTML = "";
|
||||||
|
plan.payments.forEach(payment => {
|
||||||
|
const row = document.createElement("li");
|
||||||
|
row.className = "settleUpRow";
|
||||||
|
|
||||||
|
const who = document.createElement("span");
|
||||||
|
who.className = "settleUpWho";
|
||||||
|
// An arrow rather than "pays": the direction is the whole content of the
|
||||||
|
// line, and it survives being read at a glance from across the table.
|
||||||
|
who.textContent = `${payment.from} → ${payment.to}`;
|
||||||
|
row.appendChild(who);
|
||||||
|
|
||||||
|
const amount = document.createElement("span");
|
||||||
|
amount.className = "settleUpAmount";
|
||||||
|
amount.textContent = Money.format(payment.amount);
|
||||||
|
row.appendChild(amount);
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plan.balanced) {
|
||||||
|
const count = plan.payments.length;
|
||||||
|
note.textContent = count === 1
|
||||||
|
? "One payment and the night is square."
|
||||||
|
: `${count} payments and the night is square.`;
|
||||||
|
} else if (plan.shortBy > 0) {
|
||||||
|
note.textContent =
|
||||||
|
`${Money.format(plan.shortBy)} 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.`;
|
||||||
|
} else {
|
||||||
|
note.textContent =
|
||||||
|
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||||
|
}
|
||||||
|
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Who hands cash to whom at the end of the night.
|
||||||
|
//
|
||||||
|
// The standings panel already works out what everyone is up or down. It then stopped,
|
||||||
|
// which leaves the dealer with six signed numbers at 2am and the actual job still to
|
||||||
|
// do: turning them into payments. That's the step people get wrong — not the poker.
|
||||||
|
//
|
||||||
|
// Everything is done in cents. A settlement that has to come out exactly right is the
|
||||||
|
// last place to let a float land on 41.99999999.
|
||||||
|
const SettleUp = {
|
||||||
|
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
|
||||||
|
// and for this problem that's the right trade: finding the true minimum number of
|
||||||
|
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
|
||||||
|
// than there are people, and each payment it does emit clears somebody out of the
|
||||||
|
// list entirely. A dealer wants a short list they can read down, not a proof.
|
||||||
|
//
|
||||||
|
// Payers are the outer loop so that everything one person owes is printed together
|
||||||
|
// — the list is read out loud, one player at a time.
|
||||||
|
plan(standings) {
|
||||||
|
const owed = [];
|
||||||
|
const owing = [];
|
||||||
|
|
||||||
|
(standings || []).forEach(row => {
|
||||||
|
const cents = Money.toCents(row.cashPosition);
|
||||||
|
if (cents > 0) owed.push({ name: row.name, cents });
|
||||||
|
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
|
||||||
|
});
|
||||||
|
|
||||||
|
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||||
|
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const payments = [];
|
||||||
|
let creditor = 0;
|
||||||
|
owing.forEach(payer => {
|
||||||
|
while (payer.cents > 0 && creditor < owed.length) {
|
||||||
|
const taker = owed[creditor];
|
||||||
|
const amount = Math.min(payer.cents, taker.cents);
|
||||||
|
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
|
||||||
|
payer.cents -= amount;
|
||||||
|
taker.cents -= amount;
|
||||||
|
if (taker.cents === 0) creditor++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// What's left over when the chips on the table don't match the cash that came
|
||||||
|
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
|
||||||
|
// is always the thing the bank check is already warning about: a rebuy nobody
|
||||||
|
// wrote down, or a miscount. Reported rather than quietly spread across the
|
||||||
|
// payments, because inventing a transfer to make the columns agree is how a
|
||||||
|
// real person ends up short.
|
||||||
|
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
|
||||||
|
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
payments,
|
||||||
|
// Only one of these can be non-zero: either there are more chips than cash
|
||||||
|
// or less, never both.
|
||||||
|
shortBy: unpaid,
|
||||||
|
overBy: unspent,
|
||||||
|
balanced: unpaid === 0 && unspent === 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SettleUp;
|
||||||
|
}
|
||||||
@@ -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 SettleUp = load("settleUp");
|
||||||
const HandHistoryExport = load("handHistoryExport");
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
// render.js exports { Render } rather than the namespace directly.
|
// render.js exports { Render } rather than the namespace directly.
|
||||||
@@ -77,6 +78,7 @@ module.exports = {
|
|||||||
HandHistoryExport,
|
HandHistoryExport,
|
||||||
PotCalculator,
|
PotCalculator,
|
||||||
SplitCalculator,
|
SplitCalculator,
|
||||||
|
SettleUp,
|
||||||
Betting,
|
Betting,
|
||||||
HoldemState,
|
HoldemState,
|
||||||
makeTable,
|
makeTable,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user