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)); }); });