// Taking a chip colour out of the legend. // // This was one click of an unlabelled 24px button, and it was not a change to a legend. // computeContribution() prices a seat by running the legend over that seat's chip // counts, so a colour that leaves takes every chip counted in it off the table. // Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of // $26,874 with it, with no confirmation, nothing said afterwards, and no way back — // a colour added again is a new colour with a new id, while the counts stay keyed to // the one that left. The counts were left behind too, so the saved file carried money // that nothing could price and nothing could show. // // What is tested here is mostly the number in the warning, because a warning that // overstates is worse than none: it is the one thing a dealer would have to trust at // the exact moment they are about to lose track of the box. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { HoldemState, Money } = require("./load"); const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => { const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 9); state.basePotAmount = basePot; ["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; state.seats[i].buyIns = 500; state.seats[i].chipCounts = {}; }); counts.forEach(([seat, chipIndex, n]) => { state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n; }); overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; }); return state; }; const onTable = state => state.seats.reduce( (sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0); // --------------------------------------------------------------------------- describe("what a chip colour is holding up", () => { test("is the money, named seat by seat", () => { const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id); assert.equal(usage.total, 350); assert.equal(usage.chips, 14); assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [ ["Christopher", 4, 100], ["Mike from work", 10, 250], ]); }); test("is nothing at all for a colour nobody counted", () => { const state = tableWith({ counts: [[0, 2, 4]] }); const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id); assert.equal(usage.total, 0); assert.deepEqual(usage.seats, []); assert.ok(usage.chip, "the colour itself is still found, so it can be named"); }); test("and nothing for a colour that isn't there", () => { const state = tableWith(); assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"), { chip: null, total: 0, chips: 0, seats: [] }); }); }); describe("the number in the warning is the number the table loses", () => { // The whole point. Multiplying the chips by their value would have been the obvious // way to work this out and it is wrong in both directions — the two tests below are // the two ways it is wrong. const dropFromRemoving = (state, chipId) => { const before = onTable(state); HoldemState.removeChipColor(state, chipId); return Money.round(before - onTable(state)); }; test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => { // computeContribution() reads amountOverride first and never reaches the counts. // Christopher is holding ten $25 chips on paper and priced at a flat $90. const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] }); const chipId = state.chipLegend[2].id; const usage = HoldemState.chipColorUsage(state, chipId); assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them"); assert.equal(dropFromRemoving(state, chipId), 0); }); test("a seat whose chips all leave falls back to the base pot, not to zero", () => { // The $25s are everything Christopher has counted, so taking them away drops him // to the table's standing ante — a loss of 50, not of 250. const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 }); const chipId = state.chipLegend[2].id; const usage = HoldemState.chipColorUsage(state, chipId); assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves"); assert.equal(dropFromRemoving(state, chipId), 50); }); test("and across a spread of tables the two always agree", () => { function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } const random = rng(20260906); let withMoney = 0; for (let table = 0; table < 2000; table++) { const counts = []; for (let seat = 0; seat < 3; seat++) { for (let chip = 0; chip < 5; chip++) { if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]); } } const overrides = []; for (let seat = 0; seat < 3; seat++) { if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]); } const basePot = random() < 0.5 ? 200 : null; const chipIndex = Math.floor(random() * 5); const stated = tableWith({ counts, overrides, basePot }); const chipId = stated.chipLegend[chipIndex].id; const claimed = HoldemState.chipColorUsage(stated, chipId).total; const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId); if (claimed !== 0) withMoney++; assert.equal(claimed, actual, `table ${table}: warned ${claimed}, table moved ${actual}\n` + JSON.stringify({ counts, overrides, basePot, chipIndex })); } // Guards the generator: shapes that never put money on a colour would make every // assertion above compare zero with zero. assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`); }); test("and it can be a gain, which is said as one", () => { // Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat // stops being priced from its chips and falls through to the ante, so the table // goes *up* by 186. Found by the randomised check above, not by hand. const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 }); const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id); assert.equal(usage.total, -186); assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186); }); }); describe("removing it", () => { test("takes the counts with it, so nothing is left priced by a colour that is gone", () => { const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] }); const chipId = state.chipLegend[2].id; HoldemState.removeChipColor(state, chipId); assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0); state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})), "a count against a colour no legend entry answers to is money nothing can show")); // The other colour on that seat is untouched. assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3); }); test("reports what it cost, measured before anything moved", () => { const state = tableWith({ counts: [[0, 2, 4]] }); const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id); assert.equal(usage.total, 100); assert.equal(usage.seats.length, 1); }); test("and a colour that isn't there changes nothing", () => { const state = tableWith({ counts: [[0, 2, 4]] }); const before = onTable(state); const usage = HoldemState.removeChipColor(state, "no-such-chip"); assert.equal(usage.chip, null); assert.equal(state.chipLegend.length, 5); assert.equal(onTable(state), before); }); });