Stop a wrapped label costing twelve seats their room
The sweep had carried the same residual for rounds: on the two smallest phones with twelve players, seats painting over each other's names and stacks. It read like a geometry problem. It was a wrapped label. "ALL IN" is the only thing that ever lands on the stack line with a space in it. A stack figure has no spaces, so it can only overflow, and the puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line does when it can break. Measured at 360x640 with twelve players all-in, it wanted 36px and had 34, broke onto a second line, and an 11px line became 22. That is not a typography complaint, which is why it survived: it looks fine. The puck went from 55px tall to 66, and puck height is what the ring is short of -- at the ends of an ellipse consecutive seats are separated vertically, so a puck 11px taller is a puck 11px further into its neighbour. The layout search then did exactly what it is written to do: tried every width it has, found none that cleared, and fell back to the narrowest, which is also the one that wraps hardest. So the rule every design system gives a badge applies here too: one line, always. Not wrapping is only half of it -- a line that cannot wrap and still does not fit paints over the seat next door instead -- so on the tight tier the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the warm colour and the bold weight that do the same job. Halving the overlap did not clear it, and the rest of the shortage was not on the felt. The sweep had been saying so in a warning rather than a failure: at 360x640 the bar was 267px of a 640px window, 42%, against its own "persistent chrome under 30%". Two things in it were being shown when they could not be used. The side-pot list is decision support, and during a run-out there is no decision to support -- it collapses to the main pot and one line for the rest. The status row said the player count twice in the same breath, as a chip and again inside the run-out sentence beside it. Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt 243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and the iPhone SE's seat-text overlap gone entirely. No new failures. 894 tests pass, 9 of them new. Seven fault injections are each caught. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -621,6 +621,88 @@ still names whoever didn't turn over; closing it leaves **Showdown — resolve p
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## Two pixels, and every seat on the felt paid for them
|
||||
|
||||
The sweep had carried the same residual for several rounds: on the two smallest phones
|
||||
with twelve players, seats painted over each other's names and stacks. It read like a
|
||||
geometry problem — twelve pucks, not enough rim — and the layout code had already been
|
||||
round that loop twice, deriving the ring from the felt and searching for a puck width
|
||||
that clears.
|
||||
|
||||
It was a wrapped label.
|
||||
|
||||
`"ALL IN"` is the only thing that ever lands on the stack line with a space in it. A
|
||||
stack figure has no spaces, so it can only overflow, and the `puckAmountLong` and
|
||||
`puckAmountHuge` sizes exist to stop it doing that. Nothing had ever asked what the line
|
||||
does when it *can* break. Measured at 360x640 with twelve players all-in: it wanted 36px
|
||||
and had 34, so it broke onto a second line, and an 11px line became 22.
|
||||
|
||||
That is not a typography complaint, which is why it survived so long — it looks fine.
|
||||
The puck went from 55px tall to 66, and puck height is precisely what the ring is short
|
||||
of. At the ends of an ellipse consecutive seats are separated *vertically*, so a puck
|
||||
11px taller is a puck 11px further into its neighbour. The layout search then did exactly
|
||||
what it is written to do: tried every width it has, found none that cleared, and fell
|
||||
back to the narrowest — which, being the narrowest, is also the one that wraps hardest.
|
||||
|
||||
So the fix is the rule every design system already gives a badge: one line, always.
|
||||
|
||||
### Not wrapping is only half an answer
|
||||
|
||||
A line that cannot wrap and still does not fit paints over the seat next door instead —
|
||||
the same failure, arriving sideways. So the mark has to actually *fit* 34px, and it
|
||||
wanted 36.
|
||||
|
||||
Of the three things that separate "ALL IN" from a grey money figure at a glance — a warm
|
||||
colour, a bold weight, and wider letters — the letter-spacing is the one that costs
|
||||
pixels: 3.6px of the 36. On the tight tier it goes, and the other two stay. Measured
|
||||
after: 33px of ink in a 34px box on a Galaxy S8, and the full spacing everywhere else,
|
||||
because no other puck is short of the room.
|
||||
|
||||
The three of them are pinned in `allInMarker.test.js` against the stylesheet itself —
|
||||
including the assertion that the *ordinary* spacing still doesn't fit, so that if someone
|
||||
later widens the puck and makes the whole problem moot, the test says it is measuring the
|
||||
wrong thing rather than passing quietly.
|
||||
|
||||
### And the bar was standing on the felt while nobody could act
|
||||
|
||||
Halving the overlap was not clearing it, and the rest of the shortage was not on the
|
||||
felt at all. The sweep had been saying so for rounds, in a warning rather than a failure:
|
||||
at 360x640 with twelve players all-in the action bar was **267px of a 640px window — 42%**,
|
||||
against its own "persistent chrome under 30%".
|
||||
|
||||
Two things in it were being shown at a moment when they could not be used.
|
||||
|
||||
The **side-pot list** is decision support. A player working out whether to call is owed
|
||||
the side pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
Once everyone is all-in there is no decision left to support — the only thing that
|
||||
happens for the rest of the hand is the dealer turning cards — and the list went on
|
||||
standing there. During a run-out it now collapses to the main pot, which is the number a
|
||||
dealer says out loud, and one line for everything else, which is what the pot wizard is
|
||||
for and what that row has always said in as many words.
|
||||
|
||||
The **status row** said the player count twice in the same breath: a `12 players` chip,
|
||||
and then `All-in — running out the board (12 players)` immediately to its right. That
|
||||
reads like two different figures being quoted, and on a 360px phone it cost the sentence
|
||||
a second line.
|
||||
|
||||
Measured across the three, on the table shape with the most seats to fit:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| Bar, Galaxy S8 | 267px (42%) | 249px (39%) |
|
||||
| Bar, iPhone SE | 267px (40%) | 227px (34%) |
|
||||
| Bar, Pixel 7 | 267px (32%) | 227px (25%) |
|
||||
| Felt height, Galaxy S8 | 192px | 210px |
|
||||
| Felt height, iPhone SE | 243px | 283px |
|
||||
| Worst seat overlap, iPhone SE | 34px | 4px |
|
||||
| Worst seat overlap, Galaxy S8 | 34px | 17px |
|
||||
|
||||
The summary row is held to the same rule as the elision above it, and it is the rule that
|
||||
matters most for anything that hides a number: **it must not lose a chip.** A dealer
|
||||
counting the announcement against the chips in the middle of the table must not find a
|
||||
shortfall the tool invented, so every row count is checked to add up to the pots it
|
||||
stands for, at every table size from three players to twelve.
|
||||
|
||||
## The failures that only happen at the table
|
||||
|
||||
Everything above this line is about a tool being easy to read and quick to press. This
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// The declarations inside one rule, by its exact selector.
|
||||
const ruleBody = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
if (at < 0) return null;
|
||||
const open = CSS.indexOf("{", at);
|
||||
return CSS.slice(open + 1, CSS.indexOf("}", open));
|
||||
};
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
@@ -96,3 +110,64 @@ test("marking nobody doesn't move a chip", () => {
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
// ---- And the shape of the mark, which is the felt's problem ------------------
|
||||
//
|
||||
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
|
||||
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
|
||||
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
|
||||
//
|
||||
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
|
||||
// height is what the seat ring is short of — at the ends of an ellipse the seats are
|
||||
// separated vertically, so 11px taller is 11px further into the seat next door. The
|
||||
// layout search then tried every width it has, found none that cleared, and fell back
|
||||
// to the narrowest, which is also the one that wraps hardest.
|
||||
//
|
||||
// So the rule every design system gives a badge applies here too: one line, always.
|
||||
describe("the mark is one line, because the ring cannot afford two", () => {
|
||||
test("a stack line can never wrap", () => {
|
||||
const body = ruleBody(".seatPuckStack");
|
||||
assert.ok(body, "the .seatPuckStack rule should exist");
|
||||
assert.match(body, /white-space:\s*nowrap/,
|
||||
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
|
||||
});
|
||||
|
||||
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
|
||||
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
|
||||
// and still doesn't fit paints over the seat beside it instead. The spacing is
|
||||
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
|
||||
// can go — the warm colour and the bold weight stay.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
|
||||
assert.ok(base, "the base all-in rule should exist");
|
||||
assert.ok(tight, "the tight-tier all-in rule should exist");
|
||||
|
||||
const spacingOf = body => {
|
||||
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const wide = spacingOf(base);
|
||||
const narrow = spacingOf(tight);
|
||||
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
|
||||
assert.ok(narrow < wide,
|
||||
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
|
||||
|
||||
// The arithmetic that has to hold for it to fit at all: six characters at 10px
|
||||
// with 4px of padding either side and a 1px border, inside a 44px puck.
|
||||
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
|
||||
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
|
||||
assert.ok(inkAt(narrow) <= CONTENT_PX,
|
||||
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
|
||||
assert.ok(inkAt(wide) > CONTENT_PX,
|
||||
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
|
||||
});
|
||||
|
||||
test("the two signals that survive are the ones doing the work", () => {
|
||||
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
|
||||
// on the narrow pucks is only safe while the colour and the weight remain, so
|
||||
// they are pinned here rather than left to be tidied away later.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
|
||||
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,3 +159,71 @@ describe("the wizard, which has room", () => {
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And smaller still once nobody can act -----------------------------------
|
||||
//
|
||||
// The list is decision support. A player working out whether to call is owed the side
|
||||
// pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
//
|
||||
// Once everyone is all-in there is no decision left to support: the only thing that
|
||||
// happens for the rest of the hand is the dealer turning cards. The list went on
|
||||
// standing there anyway, and it is not free — measured at 360x640 with twelve players
|
||||
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
|
||||
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
|
||||
// the most seats to fit into what is left.
|
||||
//
|
||||
// So during a run-out it collapses to the main pot and one line for everything else.
|
||||
// The main pot stays because it is the number a dealer says out loud; the layering is
|
||||
// what the pot wizard is for, and the summary row has always said so in as many words.
|
||||
describe("the list during a run-out, when nobody has a decision left", () => {
|
||||
test("is two rows however many people are all-in", () => {
|
||||
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
|
||||
assert.deepEqual(rows, [2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
test("and that is fewer than the same table gets while the betting is live", () => {
|
||||
// The point of the change, stated as a comparison rather than a constant, so it
|
||||
// still means something if the ordinary cap is ever retuned.
|
||||
const live = build(laddered(12), { maxLayers: 4 }).length;
|
||||
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
|
||||
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
|
||||
});
|
||||
|
||||
test("still names the main pot, which is the one said out loud", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4, summarise: true });
|
||||
assert.equal(rows[0].label, pots[0].label);
|
||||
assert.equal(rows[0].amount, Money.format(pots[0].amount));
|
||||
});
|
||||
|
||||
test("and still accounts for every chip", () => {
|
||||
// The same rule as the elision above, and the one a summary row must not break:
|
||||
// a dealer counting the announcement against the chips in the middle must not
|
||||
// find a shortfall the tool invented.
|
||||
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, summarise: true })
|
||||
.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("says how many side pots it stands for, and counts them properly at one", () => {
|
||||
const many = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
|
||||
|
||||
// Three players ladder into two pots: a main and exactly one side.
|
||||
const one = build(laddered(3), { maxLayers: 4, summarise: true });
|
||||
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
|
||||
});
|
||||
|
||||
test("and points at where the full list actually is", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.match(rows[1].who, /resolve/,
|
||||
"the row that hides the layers has to say where they went");
|
||||
assert.match(rows[1].title, /Side pot/,
|
||||
"and a pointer still gets the full list without resolving anything");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user