Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal #120
@@ -1358,6 +1358,17 @@ button,
|
||||
font-size: var(--fs-2xs);
|
||||
}
|
||||
|
||||
/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit
|
||||
paints over the seat next door instead. So on the tightest pucks the mark gives up
|
||||
most of its letter-spacing, which is 3.6px of the 36 it wanted.
|
||||
|
||||
The spacing is there to stop "ALL IN" reading as money at a glance, and it keeps the
|
||||
two signals that actually do that job — the warm colour and the bold weight. What it
|
||||
gives up is the third one, on the only pucks too narrow to afford it. */
|
||||
.seatsTight .seatPuckStack.seatPuckAllIn {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* The other end of the same idea. A puck past the ordinary 96px ceiling has room the
|
||||
type was never given: the felt grows with the window and the text did not, so a
|
||||
dealer on a 2560px monitor read their stacks at the same 10px as one on a 360px
|
||||
@@ -3439,11 +3450,27 @@ body.hasActionBar #holdemApp {
|
||||
}
|
||||
}
|
||||
|
||||
/* One line, always — the same rule every design system gives a badge, and the one
|
||||
thing on this puck that was never told it.
|
||||
|
||||
A stack figure has no spaces in it, so it could never wrap; it overflows instead, and
|
||||
the puckAmountLong/Huge sizes below exist to stop it. "ALL IN" has a space, and that
|
||||
space cost the two smallest phones their seat layout.
|
||||
|
||||
Measured at 360x640 with twelve players all-in: "ALL IN" wanted 36px and had 34. It
|
||||
broke onto a second line, an 11px line became 22, and the puck went from 55px tall to
|
||||
66. Height is exactly what is scarce at the ends of the ring — the seats there are
|
||||
separated vertically, so a puck 11px taller is a puck 11px further into its
|
||||
neighbour. The layout search then walked its candidate widths, found none that
|
||||
cleared, and settled for the narrowest, which is also the one that wraps hardest.
|
||||
|
||||
Two pixels, and every seat on the felt paid for them. */
|
||||
.seatPuckStack {
|
||||
font-size: var(--fs-2xs);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: var(--sp-0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
|
||||
|
||||
@@ -308,8 +308,12 @@ const ActionBar = {
|
||||
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
||||
const runout = document.createElement("span");
|
||||
runout.className = "actionRunout";
|
||||
const stillIn = Betting.liveSeats(this.state).length;
|
||||
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
||||
// It used to end "(12 players)", which is the number the chip immediately to
|
||||
// its left already says, in the same row, at the same moment. Saying it
|
||||
// twice in one breath reads like two different figures being quoted, and on
|
||||
// a 360px phone it cost the sentence a second line — which the felt paid
|
||||
// for, on the one table shape that has twelve seats to fit.
|
||||
runout.textContent = "All-in — running out the board";
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
@@ -609,12 +613,17 @@ const ActionBar = {
|
||||
// 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.
|
||||
//
|
||||
// Two, once the run-out has started, for the reason spelled out over there: the
|
||||
// list is decision support, and during a run-out there is no decision left to
|
||||
// support. The felt gets the difference, on the one table shape that has the
|
||||
// most seats to fit into it.
|
||||
return Render.buildPotBreakdown(
|
||||
PotCalculator.layers(contributions),
|
||||
PotCalculator.uncalledReturns(contributions),
|
||||
id => nameById.get(id),
|
||||
null,
|
||||
{ maxLayers: 4 }
|
||||
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -2387,7 +2387,7 @@ const Render = {
|
||||
// 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 } = {}) {
|
||||
buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers, summarise } = {}) {
|
||||
if (pots.length < 2 && returns.length === 0) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
@@ -2427,6 +2427,36 @@ const Render = {
|
||||
wrap.appendChild(row);
|
||||
};
|
||||
|
||||
// While the board is being run out, one line instead of three.
|
||||
//
|
||||
// The list earns its height whenever somebody still has a decision: a player
|
||||
// working out whether to call is owed the side pot they are calling into. Once
|
||||
// everyone is all-in nobody has a decision at all — the only thing left in the
|
||||
// hand is the dealer turning cards — and the ledger goes on standing there.
|
||||
//
|
||||
// It is not free standing there. Measured at 360x640 with twelve players all-in,
|
||||
// the bar was 267px of a 640px window: 42%, against the sweep's own "persistent
|
||||
// chrome under 30%", and it is the felt that pays, on the exact table that has
|
||||
// the most seats to fit. The main pot is the number a dealer says out loud, so
|
||||
// it stays; the layering is what the pot wizard is for, and the row has always
|
||||
// said so in as many words.
|
||||
if (summarise && pots.length > 1) {
|
||||
const sides = pots.slice(1);
|
||||
addRow(
|
||||
pots[0].label, pots[0].amount,
|
||||
this.describeEligible(pots[0].eligible, pots[0].eligible, nameFor),
|
||||
"",
|
||||
pots[0].eligible.map(nameFor).join(", "));
|
||||
addRow(
|
||||
`${sides.length} side pot${sides.length === 1 ? "" : "s"}`,
|
||||
sides.reduce((sum, layer) => sum + layer.amount, 0),
|
||||
"listed when you resolve",
|
||||
"potLayerMore",
|
||||
sides.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", "));
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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