Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user