diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index be0ddf2..4f58cd3 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3073,6 +3073,17 @@ body.hasActionBar #holdemApp { font-weight: 500; } +/* The layers between the main pot and the newest side pot, collapsed onto one line so + the bar's height stops depending on how many people are all-in. Set back rather than + picked out: it is the one row in the list that is not a pot to announce, and reading + as a pot is exactly the mistake it could cause. */ +.potLayerMore .potLayerLabel, +.potLayerMore .potLayerAmount { + color: var(--text-muted); + font-weight: 500; + font-style: italic; +} + .actionButtons { display: flex; gap: var(--sp-2); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index d4a0792..9d9a7b8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -606,10 +606,15 @@ const ActionBar = { this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)]) ); + // Four is what the bar can hold without taking the felt with it — see + // buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check + // for the ceiling this is keeping the bar under. return Render.buildPotBreakdown( PotCalculator.layers(contributions), PotCalculator.uncalledReturns(contributions), - id => nameById.get(id) + id => nameById.get(id), + null, + { maxLayers: 4 } ); }, diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 488f02a..ef4f6a1 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -2096,7 +2096,15 @@ const Render = { // Shared by the action bar (live, mid-hand) and the pot wizard (before winners are // picked), because both are showing the dealer the same announcement. - buildPotBreakdown(pots, returns, nameFor, heading) { + // + // `maxLayers` caps how many pots get a line of their own, and only the bar passes + // it. The bar is fixed over the felt, so nothing in it may grow with the table — + // and this list did, one row per all-in, with no ceiling. Measured on a 360x640 + // phone: five players idle put the bar at 151px, six all-in at 303px, and twelve + // all-in at 411px, which is 64% of the screen and leaves 96px of felt for twelve + // seats. The wizard passes no cap: it is a dialog with room, opened at the point + // the whole list is what you are working from. + buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers } = {}) { if (pots.length < 2 && returns.length === 0) return null; const wrap = document.createElement("div"); @@ -2136,11 +2144,37 @@ const Render = { wrap.appendChild(row); }; - pots.forEach(pot => addRow( - pot.label, pot.amount, - this.describeEligible(pot.eligible, pots[0].eligible, nameFor), - "", - pot.eligible.map(nameFor).join(", "))); + // The two ends of the list are the live ones, and they are live for the same + // reason: pots are paid from the newest side pot back down to the main pot, so + // the top layer is the next thing that happens and the main pot is the last. + // Those are also the two a dealer says out loud while the chips are being + // stacked — "main pot 7,200, side pot 4,500". The layers in between are the + // middle of that count-down, read at showdown rather than mid-hand. + // + // Elide only when it buys something: at the cap itself the middle is one row + // and replacing one row with one row is pure indirection, so the whole list + // stands until there are strictly more pots than the cap. + const middle = maxLayers && pots.length > maxLayers ? pots.slice(1, -1) : []; + const shown = new Set(middle.length ? [pots[0], pots[pots.length - 1]] : pots); + + pots.forEach(pot => { + if (!shown.has(pot)) return; + addRow( + pot.label, pot.amount, + this.describeEligible(pot.eligible, pots[0].eligible, nameFor), + "", + pot.eligible.map(nameFor).join(", ")); + // Kept in place rather than appended, so the amounts still read in the + // order the pots were made and the gap sits where the gap actually is. + if (middle.length && pot === pots[0]) { + addRow( + `${middle.length} more side pots`, + middle.reduce((sum, layer) => sum + layer.amount, 0), + "listed when you resolve", + "potLayerMore", + middle.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", ")); + } + }); returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn")); return wrap; diff --git a/tests/holdem/README.md b/tests/holdem/README.md index a8b25d6..089f934 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -330,6 +330,56 @@ and, when the page has no room left to scroll (26px available against 94px wante well would have fixed it by making the page grow and shrink by 64px every time anything was announced, which is worse than the fault. +## A sweep only measures the states it reaches + +The action bar is fixed over the felt, so its height is the felt's budget — and the bar +carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured +on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**, +and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw +twelve seats in. + +Nothing here could see it, and the reason is worth writing down. Every check in +`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked +how much room the covering element had taken. But the deeper miss was the sweep itself: +all 294 audits drove their table with `Call` and `Check`, so **no table in the history of +this file ever had a player all-in**, and the one thing on the bar whose height depends +on the table was empty in every single measurement. `runUiAudit.js` now has a fourth +shape, twelve players shoved in for twelve different amounts — there is no one-tap shove, +so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who +cannot cover. + +- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing + above **50%**, which is the line that needs no taste to defend: furniture larger than + the content it serves is a shape fault whatever it is showing. Warning above **30%**, + the published guidance for persistent chrome, which the shortest phone does not meet at + rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is + always red stops being read. + +### Cutting the middle out of a list, and which end to keep + +The cap is four layers, and past it the bar draws the main pot, one line for everything +between, and the newest side pot. The two ends are not an arbitrary choice: side pots are +paid **from the newest one back down to the main pot**, so the top layer is the next thing +that happens and the main pot is the last, and those are exactly the two a live dealer +says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The +layers between are the middle of that count-down, wanted at showdown, which is the pot +wizard — the same builder, passing no cap, in a dialog that scrolls. + +`potBreakdownRoom.test.js` checks the parts that could go wrong quietly: + +- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and + twelve all-in all draw three lines +- the drawn amounts still add up to every pot, so a dealer counting the announcement + against the chips in the middle finds no shortfall the tool caused +- the elision sits **where the gap is**, not appended after the list, so the amounts still + read in the order the pots were made +- it never fires when it would not buy anything: at exactly the cap the middle is one row, + and swapping one row for one row loses an amount and gains a sentence + +Three injected faults — the cap ignored, the summary carrying only the first elided pot's +money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests +respectively, and the audit check reports the original 64% when the cap is taken out. + ## Why the hand evaluator is tested the way it is `handRank.js` is the only module here that can pay the wrong person without anybody diff --git a/tests/holdem/potBreakdownRoom.test.js b/tests/holdem/potBreakdownRoom.test.js new file mode 100644 index 0000000..956131b --- /dev/null +++ b/tests/holdem/potBreakdownRoom.test.js @@ -0,0 +1,161 @@ +// The bar's pot list is not allowed to grow with the table. +// +// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table +// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640 +// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which +// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay +// whose height depends on the state is a fault whatever it is showing, so the cap is +// checked here rather than left to whoever next adds an all-in to the audit sweep. +// +// The two ends survive because they are the two that are live. Side pots are paid from +// the newest one back down to the main pot, so the top layer is the next thing that +// happens and the main pot is the last, and those are the two a dealer says out loud +// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between +// are the middle of that count-down, wanted at showdown, which is the pot wizard, which +// passes no cap and still lists all of them. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render, Money, PotCalculator } = require("./load"); + +const nameFor = id => `P${id}`; + +// The same stub potBreakdown.test.js uses — enough of an element to say what the row +// is made of, and no jsdom in a suite that runs in a second. +const fakeDom = () => { + const make = () => ({ + className: "", textContent: "", title: "", children: [], + classList: { add() {} }, + appendChild(child) { this.children.push(child); return child; }, + }); + global.document = { createElement: make }; + return () => { delete global.document; }; +}; + +// A table where everyone is all-in for a different amount, which is the shape that +// makes the most pot layers — the worst case and the one that was measured. `count` +// players ladder into `count - 1` layers: two of them share a single undivided pot and +// the breakdown declines to draw at all, which is why nothing below three appears here. +const laddered = count => PotCalculator.layers( + Array.from({ length: count }, (_, i) => ({ + id: `s${i}`, contribution: 100 + i * 130, folded: false, + }))); + +const build = (pots, options) => { + const undo = fakeDom(); + try { + const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options); + return wrap.children.map(row => ({ + className: row.className, + label: row.children[0].textContent, + amount: row.children[1].textContent, + who: row.children[2].textContent, + title: row.children[2].title, + })); + } finally { undo(); } +}; + +describe("the bar's copy of the pot list", () => { + test("stops growing once there are more pots than it has room for", () => { + // The property that matters: rows are a function of the cap, not of the table. + // Twelve-handed and six-handed have to render the same number of lines. + for (let players = 3; players <= 12; players++) { + const rows = build(laddered(players), { maxLayers: 4 }); + assert.ok(rows.length <= 4, + `${players} all-in drew ${rows.length} rows; the bar is sized for 4`); + } + }); + + test("and the number of rows never depends on how many people are all-in", () => { + const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length); + assert.deepEqual(wide, [3, 3, 3, 3, 3], + "past the cap every table should draw main, the elision and the top pot"); + }); + + test("keeps the two pots a dealer actually announces", () => { + const pots = laddered(12); + const rows = build(pots, { maxLayers: 4 }); + assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid"); + assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label, + "the newest side pot is the first one paid"); + }); + + test("and leaves them in the order the pots were made", () => { + const rows = build(laddered(12), { maxLayers: 4 }); + assert.match(rows[1].className, /potLayerMore/, + "the elision belongs where the gap is, not appended after the list"); + }); +}); + +describe("the money in the rows that were elided", () => { + test("is still on the screen, added up", () => { + // The one thing a summary row must not do is lose chips. Whatever is drawn has + // to account for every pot, or a dealer counting the announcement against the + // chips in the middle finds a shortfall the tool caused. + for (let players = 3; players <= 12; players++) { + const pots = laddered(players); + const total = pots.reduce((sum, pot) => sum + pot.amount, 0); + const drawn = build(pots, { maxLayers: 4 }) + .reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0); + assert.equal(drawn, total, + `${players} all-in: rows add to ${drawn}, the pots add to ${total}`); + } + }); + + test("and says how many it stands for", () => { + const rows = build(laddered(12), { maxLayers: 4 }); + const pots = laddered(12); + assert.equal(rows[1].label, `${pots.length - 2} more side pots`); + }); + + test("with the list itself in a title, for anyone with a pointer", () => { + const pots = laddered(12); + const rows = build(pots, { maxLayers: 4 }); + pots.slice(1, -1).forEach(pot => { + assert.ok(rows[1].title.includes(pot.label), + `${pot.label} vanished without trace`); + assert.ok(rows[1].title.includes(Money.format(pot.amount)), + `${pot.label}'s amount vanished without trace`); + }); + }); + + test("and points at where the whole list is", () => { + // The title is a pointer affordance, so it cannot be the answer on a phone. The + // visible text has to say where the rest lives. + assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i); + }); +}); + +describe("when eliding would not buy anything", () => { + test("a list that fits is left alone", () => { + for (let players = 3; players <= 5; players++) { + const pots = laddered(players); + assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes"); + const rows = build(pots, { maxLayers: 4 }); + assert.equal(rows.length, pots.length); + assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label)); + } + }); + + test("and a single middle layer is not replaced by a line saying there is one", () => { + // At exactly the cap the middle is one row, and swapping one row for one row is + // pure indirection: the dealer loses an amount and gains a sentence. + const pots = laddered(5); + assert.equal(pots.length, 4, "five all-in ladder into exactly the cap"); + const rows = build(pots, { maxLayers: 4 }); + assert.equal(rows.length, 4); + assert.ok(!rows.some(r => /potLayerMore/.test(r.className))); + }); +}); + +describe("the wizard, which has room", () => { + test("still gets every layer", () => { + // Same builder, no cap — this is the announcement read out before anybody shows + // a hand, and it is in a dialog that scrolls. Capping it there would hide the + // pots at the moment they are being paid. + const pots = laddered(12); + const rows = build(pots, undefined); + assert.equal(rows.length, pots.length); + assert.ok(!rows.some(r => /potLayerMore/.test(r.className))); + }); +}); diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index 063a981..1157a9f 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -40,12 +40,21 @@ const OVERLAYS = [ // sitting down more than on anything a viewport can tell you. A full table is where the // seats crowd, and it was never once looked at: measured when it finally was, seventeen // of fifty-four tables had one player's name or stack painted under another. +const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], + ["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480], + ["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]; + const TABLES = [ ["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]], false], - ["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], - ["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480], - ["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]], + ["packed", 12, PACKED], + // The same twelve, every one of them all-in for a different amount. This is the + // shape that makes a pot layer per player, and the sweep had never reached it: + // every table here was driven with Call and Check, so the side-pot breakdown — the + // one thing on the bar whose height depends on the table — was empty at all 294 + // audits. Measured when it finally was: the bar took 411px of a 640px phone and + // left 96px of felt for twelve seats. + ["all in", 12, PACKED, false, 12], // The half-full table again, with the board turned on and five cards on it. The // board is furniture in the middle of the felt that nothing else was ever measured // against — and where the middle cannot hold it, it moves to a strip above the @@ -116,7 +125,7 @@ async function main() { for (const [width, height, rawLabel] of VIEWPORTS) { await page.setViewportSize({ width, height }); - for (const [shape, seats, players, board] of TABLES) { + for (const [shape, seats, players, board, shoves] of TABLES) { const label = `${rawLabel} · ${shape} · ${hand}`; await page.goto(`${BASE}/Casino/TexasHoldem`); await page.evaluate(seedTable, [seats, players, board]); @@ -133,7 +142,36 @@ async function main() { if (deal) deal.click(); }); await page.waitForTimeout(220); - for (let i = 0; i < 3; i++) { + // Everyone's whole stack in, which is what stacks up the side pots. + // There is no one-tap shove: a player who can't cover the bet gets + // "Call all-in", and anyone else has to open the raise pad, take its + // All-in preset and confirm. + for (let i = 0; i < (shoves || 0); i++) { + const step = await page.evaluate(() => { + const find = re => [...document.querySelectorAll("#actionBar button")] + .find(b => !b.disabled && re.test(b.textContent.trim())); + const short = find(/^Call all-in/); + if (short) { short.click(); return "done"; } + const open = find(/^Raise|^Bet/); + if (!open) return null; + open.click(); + return "pad"; + }); + if (!step) break; + await page.waitForTimeout(120); + if (step === "pad") { + await page.evaluate(() => { + const find = re => [...document.querySelectorAll("#actionBar button")] + .find(b => !b.disabled && re.test(b.textContent.trim())); + const max = find(/^All-in/); + if (max) max.click(); + const go = find(/^Raise to|^Bet/); + if (go) go.click(); + }); + await page.waitForTimeout(120); + } + } + for (let i = 0; i < (shoves ? 0 : 3); i++) { const acted = await page.evaluate(() => { const btn = [...document.querySelectorAll("#actionBar button")] .find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 9110cde..ef8d72d 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -162,6 +162,49 @@ const LayoutAudit = { return { name: "focus has room under the fixed bar", offenders }; }, + // ---- The furniture is not allowed to eat the table ---- + + // How much of the window the fixed bottom band is taking. + // + // The action bar's height is not a constant: it carries the pot breakdown, which had + // one row per all-in and no ceiling. Measured on a 360x640 phone — five players + // idle, 151px; six all-in, 303px; twelve all-in, 411px, which is 64% of the screen, + // leaving 96px of felt to draw twelve seats in. Nothing in the sweep could see it, + // because every check here asks whether a thing is covered or clipped and none of + // them asked how much room the covering thing had taken. + // + // Two lines, because there are two different claims. Half the window is the one + // worth failing on: furniture bigger than the content it serves is indefensible + // whatever it is showing, and it is a shape fault rather than a matter of taste. + // Thirty percent is the published guidance for persistent chrome and it is a warning + // here, because the shortest phone in the list does not meet it at rest — the bar is + // 151px of a 640px screen before anything happens — and a sweep that is always red + // stops being read. + furnitureBudget() { + const name = "the fixed bar leaves the table its room"; + const furniture = this.bottomFurniture(); + if (furniture.length === 0) { + return { name, offenders: [], skipped: "nothing is fixed to the bottom here" }; + } + + const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top)); + const tall = Math.round(innerHeight - highest); + const share = Math.round((100 * tall) / innerHeight); + const what = furniture.map(n => this.describe(n)).join(" + "); + + return { + name, + offenders: share > 50 + ? [`${what} stands ${tall}px of a ${innerHeight}px window — ${share}%, ` + + `leaving the felt less room than the furniture`] + : [], + warnings: share > 30 + ? [`${name}: ${what} is ${share}% of the window (${tall}px of ${innerHeight}px); ` + + `persistent chrome is usually kept under 30%`] + : [], + }; + }, + // The other half: a control that scrolling can never reach at all. // // The seat editor is a bottom sheet on a phone, and it was put away with @@ -1238,6 +1281,7 @@ const LayoutAudit = { this.controlNames(), this.focusHasRoom(), this.focusablesReachable(), + this.furnitureBudget(), this.touchAction(), this.tapHighlight(), this.textSelectable(),