diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js index 7894936..5f73421 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js @@ -24,6 +24,32 @@ const PotCalculator = { return entries.filter(e => !e.folded && e.remaining > this.EPSILON); }, + // The most this player can be handed if they win the round outright. + // + // resolveRound caps the pool at the winner's own remaining contribution and then + // takes min(remaining, cap) from every entry, dead money included — so the ceiling + // is that sum. It is not the player's own contribution, which is what the wizard + // used to print beside each name under the words "up to": with three players in + // for $250 each the rows all read "up to $250" while the prompt above them said + // $765 was in play, and $765 is what the winner was actually paid. A dealer who + // trusted the row would have short-paid by two thirds. + ceilingFor(entries, id) { + const winner = entries.find(e => e.id === id); + if (!winner || winner.remaining <= this.EPSILON) return 0; + return Money.round(entries.reduce( + (sum, e) => sum + Math.min(e.remaining, winner.remaining), 0)); + }, + + // The same number, but only when it is worth saying: a ceiling equal to everything + // on the table is the prompt's own figure repeated once per row, which is ink that + // carries no data. It appears exactly when somebody is in for less than the rest — + // which is the one time a dealer needs to be told, because it is the hand where + // picking that winner leaves a side pot still to award. + bindingCeilingFor(entries, id) { + const ceiling = this.ceilingFor(entries, id); + return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null; + }, + // Splits the money on the table into the main pot and any side pots, with who can // win each. Read-only — this is what a dealer says out loud before the showdown // ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index 27feab9..7200a8e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -218,10 +218,18 @@ const PotWizard = { } label.appendChild(text); - const upTo = document.createElement("span"); - upTo.className = "potWizardChoiceUpTo"; - upTo.textContent = `up to ${Money.format(c.remaining)}`; - label.appendChild(upTo); + // What this row costs if it is the one ticked — but only on the rows where + // that is less than the figure in the prompt above. On a pot everybody + // covered it was the same number three times, and it was the wrong number: + // each player's own stake rather than what winning pays them. See + // PotCalculator.bindingCeilingFor. + const ceiling = PotCalculator.bindingCeilingFor(this.entries, c.id); + if (ceiling !== null) { + const upTo = document.createElement("span"); + upTo.className = "potWizardChoiceUpTo"; + upTo.textContent = `up to ${Money.format(ceiling)}`; + label.appendChild(upTo); + } // Cards sit apart from the name rather than inside it, so a dealer reading // a row across the table sees the hand, not a sentence. diff --git a/tests/holdem/payoutCeiling.test.js b/tests/holdem/payoutCeiling.test.js new file mode 100644 index 0000000..57d58dd --- /dev/null +++ b/tests/holdem/payoutCeiling.test.js @@ -0,0 +1,154 @@ +// The number the pot wizard prints beside a name under the words "up to". +// +// It was the player's own remaining contribution. That is not what winning pays them, +// and on the screen that moves the money the gap was large and in the wrong direction: +// three players in for $250 each showed "up to $250" on all three rows while the prompt +// directly above said "$765 is in play" — and $765 is what the winner was actually +// handed. A dealer who trusted the row would have short-paid by two thirds. With a real +// side pot it was worse in a more confusing way: the breakdown box at the top of the +// same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it +// said "up to $250". +// +// The ceiling is now what resolveRound will actually pay, and the test that matters is +// that it stays that: every case below checks the label against the function that moves +// the chips rather than against a number written out by hand. +// +// It is also printed only where it binds. A ceiling equal to everything on the table is +// the prompt's own figure repeated once per row — redundant data-ink, in Tufte's sense +// — and the rows it does appear on are exactly the hands where picking that winner +// leaves a side pot still to award, which is the one time a dealer needs telling. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const { PotCalculator, Money } = require("./load"); + +const p = (id, contribution, folded = false) => ({ id, contribution, folded }); +const entriesOf = (...players) => PotCalculator.buildEntries(players); + +// What the wizard would hand this player if their row were the one ticked. +const paysOut = (entries, id) => PotCalculator.resolveRound(entries, [id]).payoutPool; + +// --------------------------------------------------------------------------- + +describe("what a row promises is what the round pays", () => { + test("a pot everybody covered pays the whole thing, dead blinds included", () => { + // Three in for $250, two blinds folded before the raise. + const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250), + p("sb", 5, true), p("bb", 10, true)); + for (const id of ["chris", "bob", "dee"]) { + assert.equal(PotCalculator.ceilingFor(entries, id), 765); + assert.equal(PotCalculator.ceilingFor(entries, id), paysOut(entries, id)); + } + }); + + test("the short all-in's ceiling is the main pot, not their own stake", () => { + // Bob is all in for 250; Christopher and Dee go on to 600. + const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600), + p("sb", 5, true), p("bb", 10, true)); + assert.equal(PotCalculator.ceilingFor(entries, "bob"), 765); + assert.equal(PotCalculator.ceilingFor(entries, "chris"), 1465); + assert.equal(PotCalculator.ceilingFor(entries, "dee"), 1465); + }); + + // The property, over every shape the wizard can reach: a ceiling that disagreed + // with the payout would be the same defect again under a different number. + test("across every shape of pot, the ceiling is the payout", () => { + const shapes = [ + [p("a", 10), p("b", 10)], + [p("a", 10), p("b", 25), p("c", 25)], + [p("a", 5), p("b", 50), p("c", 120), p("d", 120)], + [p("a", 33.33), p("b", 66.67), p("c", 100)], + [p("a", 20), p("b", 20), p("folder", 20, true)], + [p("a", 1), p("b", 2), p("c", 3), p("d", 4), p("e", 5)], + [p("short", 0.05), p("deep", 1000)], + ]; + shapes.forEach((shape, n) => { + const entries = entriesOf(...shape); + PotCalculator.getContenders(entries).forEach(c => { + assert.equal(PotCalculator.ceilingFor(entries, c.id), paysOut(entries, c.id), + `shape ${n}: ${c.id}`); + }); + }); + }); + + test("a second round asks about what is left, not what was there", () => { + const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600)); + const after = PotCalculator.resolveRound(entries, ["bob"]).updatedEntries; + // Bob took $750 of the $1,450; $700 is left between the two deep stacks. + assert.equal(PotCalculator.totalPot(after), 700); + assert.equal(PotCalculator.ceilingFor(after, "chris"), 700); + assert.equal(PotCalculator.ceilingFor(after, "dee"), 700); + }); + + test("somebody already paid out has nothing left to be owed", () => { + const entries = entriesOf(p("a", 100), p("b", 100)); + const after = PotCalculator.resolveRound(entries, ["a"]).updatedEntries; + assert.equal(PotCalculator.ceilingFor(after, "a"), 0); + }); + + test("a name nobody put chips in for is worth nothing rather than an error", () => { + assert.equal(PotCalculator.ceilingFor(entriesOf(p("a", 10)), "ghost"), 0); + }); +}); + +describe("and it is only said where it binds", () => { + test("a pot everybody covered says its figure once, in the prompt", () => { + const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250), + p("sb", 5, true), p("bb", 10, true)); + ["chris", "bob", "dee"].forEach(id => + assert.equal(PotCalculator.bindingCeilingFor(entries, id), null)); + }); + + test("only the player who is in for less carries a number", () => { + const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600), + p("sb", 5, true), p("bb", 10, true)); + assert.equal(PotCalculator.bindingCeilingFor(entries, "bob"), 765); + assert.equal(PotCalculator.bindingCeilingFor(entries, "chris"), null); + assert.equal(PotCalculator.bindingCeilingFor(entries, "dee"), null); + }); + + // Two short stacks at different heights: each is capped by the one above, so both + // rows have something to say and they do not say the same thing. + test("two all-ins at different heights each carry their own", () => { + const entries = entriesOf(p("small", 50), p("middle", 120), p("big", 400)); + assert.equal(PotCalculator.bindingCeilingFor(entries, "small"), 150); + assert.equal(PotCalculator.bindingCeilingFor(entries, "middle"), 290); + assert.equal(PotCalculator.bindingCeilingFor(entries, "big"), null); + }); + + test("the deepest player is never the one capped", () => { + const entries = entriesOf(p("a", 10), p("b", 50), p("c", 900)); + assert.equal(PotCalculator.bindingCeilingFor(entries, "c"), null); + assert.equal(PotCalculator.ceilingFor(entries, "c"), PotCalculator.totalPot(entries)); + }); + + // Dead money is owed to whoever wins the layer it fell in, so it lifts every + // ceiling — including the short stack's, which is the whole reason Bob's row reads + // $765 rather than $750. + test("a folded player's chips lift the ceilings rather than sitting outside them", () => { + const live = entriesOf(p("a", 100), p("b", 100)); + const withDead = entriesOf(p("a", 100), p("b", 100), p("folder", 40, true)); + assert.equal(PotCalculator.ceilingFor(live, "a"), 200); + assert.equal(PotCalculator.ceilingFor(withDead, "a"), 240); + }); + + test("money in cents does not drift", () => { + const entries = entriesOf(p("a", 0.05), p("b", 0.05), p("c", 0.05)); + assert.equal(PotCalculator.ceilingFor(entries, "a"), Money.round(0.15)); + }); +}); + +describe("the wizard asks the calculator rather than reaching for the stake", () => { + const SRC = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "potWizard.js"), "utf8"); + + test("the row's number comes from bindingCeilingFor", () => { + assert.match(SRC, /PotCalculator\.bindingCeilingFor\(this\.entries, c\.id\)/); + }); + + test("and nothing prints a contender's own stake under those words any more", () => { + assert.ok(!/up to \$\{Money\.format\(c\.remaining\)\}/.test(SRC), + "the wizard still prints the player's own contribution as their ceiling"); + }); +});