Files
JoshHeaps.Net/tests/holdem/potCalculator.test.js
T
jheapsandClaude Opus 5 bd8ee11f77 Say who can win each pot in the fewest names that answer it
The pot breakdown listed every eligible player for every pot. Pot
eligibility is nested — the main pot is everyone still live and each side
pot above it drops whoever was all-in below — so the main pot's line was
every name at the table and each side pot repeated it minus one.

The lines too long to fit were exactly the ones nobody needed spelled
out, and the short ones at the bottom were the side pots a dealer
actually announces by name. Measured on an iPhone 14: a single all-in
five-handed already clipped the main pot, and twelve-handed the first row
wanted 521px of a 254px slot. There was no count and no title, so a
dealer reading "Alexandra…" could not tell whether three people or eleven
were eligible — the number the showdown turns on.

Each line now names whichever of "who is in" and "who is out" is shorter,
which nesting guarantees is never more than half the table, and leads
with the count because the line is right-aligned and it is the tail that
gets cut off. "all 9", "8 in, not Mike from work". On a phone this takes
one, two and three all-ins from every main-pot line clipped to nothing
clipped at all; twelve-handed goes 6 clipped rows to 5, and even a cut
row still opens with "9 in".

The wizard's copy of the same announcement wraps instead — it is a dialog
that scrolls, and it is what the dealer reads out before anybody shows a
hand — so nothing there is ever cut off. The full list is also on the
title for anyone with a pointer, which is the extra rather than the
answer, since a tooltip is not a mechanism a keyboard reaches.

potCalculator.test.js now proves the nesting across 20,000 random tables
and asserts on its own generator, so the loop cannot go vacuous. Audit
unchanged at 2 failing configurations and 3 offenders, both pre-existing.
787 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:23:19 -06:00

185 lines
8.9 KiB
JavaScript

const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { PotCalculator } = require("./load");
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
describe("PotCalculator.layers", () => {
test("one pot when everyone put in the same", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150);
assert.equal(layers[0].label, "Main pot");
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
});
test("a short all-in opens a side pot only the deeper players can win", () => {
// a is all-in for 20, b and c keep betting to 60.
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
assert.equal(layers.length, 2);
assert.equal(layers[0].amount, 60); // 20 x 3
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
assert.equal(layers[1].amount, 80); // 40 x 2
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
assert.equal(layers[1].label, "Side pot 1");
});
test("folded money stays in the pot as dead money but wins nothing", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
assert.ok(!layers[0].eligible.includes("folder"));
});
test("layers with the same eligible players are announced as one pot", () => {
// Two folders at different heights would each open a band, but a dealer
// announces one pot between the two players who can actually win it.
const layers = PotCalculator.layers([
p("a", 100),
p("b", 100),
p("f1", 20, true),
p("f2", 40, true),
]);
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
assert.equal(layers[0].amount, 260);
});
test("layer amounts always sum to the contested money", () => {
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
const total = players.reduce((s, x) => s + x.contribution, 0);
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
});
test("nobody contributed means no pots, not an empty main pot", () => {
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
});
});
describe("PotCalculator.uncalledReturns", () => {
test("an overbet nobody called goes back to whoever bet it", () => {
// b shoves 200, a calls only 50 and everyone else folds.
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
});
test("a called bet leaves nothing to return", () => {
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
});
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
});
test("with nobody live it stays quiet rather than refunding the table", () => {
// Every fold flag set is a dealer mistake to correct, not a signal that
// every chip on the felt was uncalled.
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
});
});
describe("PotCalculator.resolveRound", () => {
test("a single winner takes the layer they were matched for", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
const result = PotCalculator.resolveRound(entries, ["a"]);
assert.equal(result.payoutPool, 100);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
assert.equal(result.remainingContenders.length, 0);
});
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
const result = PotCalculator.resolveRound(entries, ["short"]);
assert.equal(result.capApplied, 20);
assert.equal(result.payoutPool, 60, "20 from each of three players");
// b and c still have 40 each left to play for.
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
});
test("a chopped pot splits evenly", () => {
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
});
test("odd cents go to the winners in selection order and nothing is lost", () => {
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
});
test("selecting nobody who can win is an error, not a silent no-op", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
});
});
// The pots come out of layers() in one order and with one relationship between them,
// and the action bar's wording now depends on both. If eligibility were not nested,
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
// pot who was never eligible for the main one, and naming the complement would hide
// them. It is a property of how the layers are built rather than an accident of the
// examples above, so it is checked as one.
describe("pot eligibility is nested", () => {
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
test("every side pot's players are a strict subset of the pot below it", () => {
const random = rng(20260905);
let multiPotTables = 0;
let pairs = 0;
for (let table = 0; table < 20000; table++) {
const n = 2 + Math.floor(random() * 11);
const players = Array.from({ length: n }, (_, i) => p(
`p${i}`,
// A sixth of the table never puts anything in, which is a table mid-deal.
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
random() < 0.2));
const layers = PotCalculator.layers(players);
if (layers.length > 1) multiPotTables++;
for (let i = 1; i < layers.length; i++) {
const below = layers[i - 1].eligible;
const belowSet = new Set(below);
const here = layers[i].eligible;
pairs++;
assert.ok(here.every(id => belowSet.has(id)),
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
JSON.stringify(players));
assert.ok(here.length < below.length,
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
`layers with the same eligibility are supposed to be merged into one pot\n` +
JSON.stringify(players));
}
}
// Guards the generator rather than the code: a shape that stopped producing side
// pots would make every assertion above vacuous and the test would still pass.
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
});
test("so the main pot is everyone still live, and it is the widest", () => {
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
layers.slice(1).forEach(layer =>
assert.ok(layer.eligible.length < layers[0].eligible.length));
});
});