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
230 lines
9.2 KiB
JavaScript
230 lines
9.2 KiB
JavaScript
// Somebody leaving with their chips.
|
||
//
|
||
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||
// check — cash taken against chips on the table — declared the departing player's whole
|
||
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||
// price of losing their night from the standings and the file the dealer hands over.
|
||
const test = require("node:test");
|
||
const { describe } = require("node:test");
|
||
const assert = require("node:assert");
|
||
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||
|
||
const table = () => {
|
||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||
state.seats[i].name = name;
|
||
state.seats[i].buyIns = 200;
|
||
});
|
||
return state;
|
||
};
|
||
|
||
describe("counting them out", () => {
|
||
test("the preview is what the dealer's hands follow", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
const preview = HoldemState.cashOutPreview(state, 0);
|
||
assert.equal(preview.name, "Ann");
|
||
assert.equal(preview.chips, 350, "what they are holding");
|
||
assert.equal(preview.boughtIn, 200);
|
||
assert.equal(preview.net, 150, "up for the night");
|
||
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||
});
|
||
|
||
test("a player who is down reads as down, not as a smaller stack", () => {
|
||
const state = table();
|
||
state.seats[1].stack = 60;
|
||
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||
});
|
||
|
||
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||
const state = table();
|
||
state.defaultStack = 200;
|
||
state.seats[2].stack = null;
|
||
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||
});
|
||
});
|
||
|
||
describe("what it refuses", () => {
|
||
test("paying somebody with money still in the middle", () => {
|
||
const state = table();
|
||
Betting.beginHand(state);
|
||
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||
const refusal = HoldemState.canCashOut(state, bb);
|
||
assert.equal(refusal.ok, false);
|
||
assert.match(refusal.reason, /in the pot/);
|
||
// And the refusal is the whole story: nothing moved.
|
||
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||
assert.equal((state.cashedOut || []).length, 0);
|
||
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||
});
|
||
|
||
test("an empty chair, which has nobody to pay", () => {
|
||
const state = table();
|
||
const refusal = HoldemState.canCashOut(state, 99);
|
||
assert.equal(refusal.ok, false);
|
||
});
|
||
});
|
||
|
||
describe("the chair afterwards", () => {
|
||
test("is empty and ready for the next player", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
HoldemState.cashOut(state, 0);
|
||
const seat = state.seats[0];
|
||
assert.equal(seat.name, "");
|
||
assert.equal(seat.stack, null);
|
||
assert.equal(seat.buyIns, 0);
|
||
assert.equal(seat.inHand, false);
|
||
});
|
||
|
||
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||
const state = table();
|
||
const wasId = state.seats[0].id;
|
||
HoldemState.cashOut(state, 0);
|
||
assert.notEqual(state.seats[0].id, wasId);
|
||
});
|
||
|
||
test("and isn't owed the blind the person before them was posting", () => {
|
||
const state = table();
|
||
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||
HoldemState.cashOut(state, 0);
|
||
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||
});
|
||
});
|
||
|
||
describe("the bank check", () => {
|
||
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.seats[1].stack = 50;
|
||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||
|
||
HoldemState.cashOut(state, 0);
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
assert.equal(bank.paidOut, 350);
|
||
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||
assert.equal(bank.chipsInPlay, 450);
|
||
assert.equal(bank.balanced, true);
|
||
});
|
||
|
||
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.seats[1].stack = 50; // so the table starts out balanced
|
||
// What a dealer had to do before: empty the chair themselves.
|
||
state.seats[0].name = "";
|
||
state.seats[0].stack = null;
|
||
state.seats[0].inHand = false;
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
assert.equal(bank.balanced, false);
|
||
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||
});
|
||
|
||
test("chips that really are missing still show as missing", () => {
|
||
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.seats[1].stack = 50; // balanced to start with
|
||
HoldemState.cashOut(state, 0);
|
||
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
assert.equal(bank.balanced, false);
|
||
assert.equal(bank.difference, -50);
|
||
});
|
||
|
||
test("a rake and a cash-out are counted apart", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.rakeTaken = 12;
|
||
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||
HoldemState.cashOut(state, 0);
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
assert.equal(bank.raked, 12);
|
||
assert.equal(bank.paidOut, 350);
|
||
assert.equal(bank.balanced, true);
|
||
});
|
||
});
|
||
|
||
describe("the record of their night", () => {
|
||
test("they stay in the standings with what they bought and what they left with", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||
HoldemState.cashOut(state, 0);
|
||
|
||
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||
assert.ok(ann, "still on the list");
|
||
assert.equal(ann.boughtIn, 200);
|
||
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||
assert.equal(ann.cashPosition, 150);
|
||
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||
});
|
||
|
||
test("somebody still sitting there is not marked", () => {
|
||
const state = table();
|
||
HoldemState.cashOut(state, 0);
|
||
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||
assert.equal(ben.cashedOut, false);
|
||
});
|
||
|
||
test("two people leaving are both kept", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.seats[1].stack = 50;
|
||
HoldemState.cashOut(state, 0);
|
||
HoldemState.cashOut(state, 1);
|
||
assert.equal(state.cashedOut.length, 2);
|
||
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||
});
|
||
|
||
test("a table saved before any of this existed has nobody cashed out", () => {
|
||
const parsed = JSON.parse(JSON.stringify(table()));
|
||
delete parsed.cashedOut;
|
||
const migrated = HoldemState.migrate(parsed);
|
||
assert.deepEqual(migrated.cashedOut, []);
|
||
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||
});
|
||
|
||
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||
const parsed = JSON.parse(JSON.stringify(table()));
|
||
parsed.cashedOut = [
|
||
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||
{ name: "no id" },
|
||
null,
|
||
];
|
||
const migrated = HoldemState.migrate(parsed);
|
||
assert.equal(migrated.cashedOut.length, 1);
|
||
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||
assert.equal(migrated.cashedOut[0].chips, 350);
|
||
assert.equal(migrated.cashedOut[0].at, 0);
|
||
});
|
||
});
|
||
|
||
describe("the table carries on", () => {
|
||
test("the next hand deals round the empty chair", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
HoldemState.cashOut(state, 0);
|
||
HoldemState.startNextHand(state);
|
||
Betting.beginHand(state);
|
||
|
||
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||
assert.equal(Betting.liveSeats(state).length, 3);
|
||
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||
});
|
||
|
||
test("and the money still adds up once it has", () => {
|
||
const state = table();
|
||
state.seats[0].stack = 350;
|
||
state.seats[1].stack = 50;
|
||
HoldemState.cashOut(state, 0);
|
||
HoldemState.startNextHand(state);
|
||
Betting.beginHand(state);
|
||
const bank = HoldemState.bankReconciliation(state);
|
||
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||
assert.equal(bank.balanced, true);
|
||
});
|
||
});
|