A puck can say four things about where a seat sits in the hand: small blind, big blind, straddle, button. Three of them were pills hanging off the top-left edge. The fourth was a 22px disc inside the top-right corner, on a layer above the name — so the dealer's eye had two places to look for one class of fact, and the corner one was charging the name 25px of its line to be there. What that cost, measured across the sweep's tables with the twelve packed names: Galaxy S8 9-handed name box 56px 11 of 12 fit — 7 on the button seat Galaxy S8 12-handed name box 34px 7 of 12 fit — 1 on the button seat iPhone 14 9-handed name box 68px 11 of 12 fit — 7 on the button seat desktop 9-handed name box 105px 12 of 12 fit — 11 on the button seat Four names in twelve, on a phone, cut on one seat and whole on the other eight. And the button is the one marker on the felt that moves every hand, so that is not four unlucky players — it is every player in turn, once a round, all night. The chip now hangs off the same edge as the badges, at the same 5px, at the other end of it. That distance is not free — SEAT_BADGE_OVERHANG_PX charges every chair on the ring for it — but it is already spent, and it is charged once per seat rather than once per marker, which stays right precisely because the two markers share an edge and sit at opposite ends of it. So the ring does not move at all. 12px is what fits between -5px and the top of the name on the tight ring, and it is enough: a finger never reaches this chip anyway — on touch the ghosts are pointer-events: none and the live one is inert, so giving the button is the seat's long-press menu, and what is left is a mouse on a ghost that only appears under the pointer. Round where the badges are pills, and on the right where they are on the left, because the button's gold and the big blind's now share one edge: what tells them apart is the shape and the side, not the colour. -- The other half of this was a name that could not be trusted to be one line. The label was a `-webkit-box` clamped to one line, which hides the lines past the first — but not the part of the second one that the row's own min-height leaves uncovered. The row is a px token, 15px, "12px of name at 1.2", while .puckNameTight steps the name down to 10.2px, whose line box is 12.24. That leaves 2.76px. Measured on a 390px phone: "Christopher" on the button seat drew as "Christ…" with the ascenders of "opher" showing underneath it as a stray mark, which reads as a rendering fault on the first seat a dealer looks at. `white-space: nowrap` with `text-overflow: ellipsis` has no second line to leak. It also makes the box measure honestly, which the clamp did not: a name that wrapped reported scrollWidth == clientWidth, so fitSeatNames() had only ever been seeing the overflow as height — the right answer to the wrong question, and only right while the row and the line agreed. 1046 tests pass; the 418-audit sweep is clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
// Whether two seats can end up wanting the same pixels.
|
|
//
|
|
// The felt used to decide this from one number: the straight-line distance between the
|
|
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
|
|
// halves of that were wrong, and the running page showed it. Measured across nine
|
|
// viewports and six table sizes:
|
|
//
|
|
// seventeen of fifty-four tables had one seat's name or stack painted under another
|
|
// ten of them had the collision between two seats that are not neighbours
|
|
//
|
|
// The second number is the interesting one. On a phone held sideways the felt is 528px
|
|
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
|
|
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
|
|
// every neighbouring pair was fine and the check said the table was clean.
|
|
//
|
|
// The first is what a distance test can't fix even looking at every pair: distance has
|
|
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
|
|
// is generous side by side and nowhere near enough on the diagonal.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { Render } = require("./load");
|
|
|
|
const CSS = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
|
|
|
describe("two boxes on a ring", () => {
|
|
// A phone held sideways, once the action bar has taken its share: a felt far wider
|
|
// than it is tall, on which the ring is very nearly a straight line.
|
|
const felt = { width: 400, height: 70 };
|
|
const flat = { rx: 44, ry: 42 };
|
|
|
|
test("a pair that is not neighbours still counts", () => {
|
|
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
|
|
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
|
|
// you here is the one across the table, because the rim doubles back at the ends
|
|
// of a shape this flat and a long walk apart is no distance at all.
|
|
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
|
|
|
|
const neighbours = (count, w, h) => {
|
|
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
|
|
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
|
|
return points.some((point, i) => {
|
|
const next = points[(i + 1) % points.length];
|
|
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
|
|
});
|
|
};
|
|
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
|
|
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
|
|
});
|
|
|
|
test("and the answer is the box, not a radius", () => {
|
|
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
|
|
// between them is 69px, so the test this replaces — "are these two further apart
|
|
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
|
|
const boxes = { width: 200, height: 100 };
|
|
const twoSeats = (dx, dy) => {
|
|
const original = Render.ringPositions;
|
|
Render.ringPositions = () => [
|
|
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
|
|
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
|
|
];
|
|
try {
|
|
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
|
|
} finally {
|
|
Render.ringPositions = original;
|
|
}
|
|
};
|
|
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
|
|
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
|
|
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
|
|
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
|
|
});
|
|
|
|
test("with daylight, so rounded corners don't read as touching", () => {
|
|
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
|
|
});
|
|
|
|
test("a table nobody is sitting at can't collide with itself", () => {
|
|
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
|
|
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
|
|
});
|
|
});
|
|
|
|
describe("the widths the felt may choose from", () => {
|
|
test("run widest first, because the widest that fits is the one wanted", () => {
|
|
const widths = Render.seatWidthCandidates();
|
|
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
|
|
});
|
|
|
|
test("and span the whole declared range", () => {
|
|
const widths = Render.seatWidthCandidates();
|
|
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
|
|
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
|
});
|
|
|
|
test("the floor is still a name, not a smudge", () => {
|
|
// At 44px the tight layout shows about five characters and an ellipsis. Below
|
|
// that a puck stops telling the dealer who is sitting there, which is the one
|
|
// thing on the felt it exists to say.
|
|
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
|
|
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
|
|
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
|
|
});
|
|
|
|
test("and there are few enough of them to measure after every tap", () => {
|
|
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
|
|
assert.ok(Render.seatWidthCandidates().length <= 12);
|
|
});
|
|
});
|
|
|
|
describe("what the stylesheet has to keep holding up", () => {
|
|
test("the puck's width comes from the property, not from a breakpoint", () => {
|
|
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
|
|
// at a number that depends on how many people are sitting down, which no media
|
|
// query can see. The same 390px phone has room for 90px at four seats and 61 at
|
|
// nine.
|
|
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
|
|
.map(m => m[3]);
|
|
const hardWidths = seatRules
|
|
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
|
|
.filter(decl => !decl.includes("var(--seatPuckW"));
|
|
assert.deepEqual(hardWidths, []);
|
|
});
|
|
|
|
test("the puck measures what it says it measures", () => {
|
|
// As content-box a 64px puck came out 74 across once its padding and border were
|
|
// counted, and the search compares that number against distances on the felt.
|
|
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
|
|
assert.ok(rule, ".seat rule not found");
|
|
assert.match(rule[0], /box-sizing:\s*border-box/);
|
|
});
|
|
|
|
test("and a blind badge never reaches sideways off its own puck", () => {
|
|
// This is what makes the footprint the puck and nothing more. The badge's
|
|
// remaining overhang is vertical, and it points away from the table at both ends
|
|
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
|
|
// other across the felt wear their badges back to back.
|
|
//
|
|
// It was not always so: at `left: -6px` the small blind hung off the puck's left
|
|
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
|
|
// "SB" was painted across the next player's $1,137.
|
|
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
|
|
assert.equal(badges.length, 3, "expected all three blind badges");
|
|
badges.forEach(([, which, body]) => {
|
|
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
|
|
assert.ok(left, `.${which}Badge should place itself horizontally`);
|
|
assert.ok(Number(left[1]) >= 0,
|
|
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
|
|
});
|
|
});
|
|
|
|
test("and all three sit in the same place, so there is one spot to look at", () => {
|
|
// Two badges in two positions means the dealer's eye has to find which of them
|
|
// this seat is using before it can read which blind it is, forty times a night.
|
|
// A seat only ever wears one: the blinds are different players by construction,
|
|
// and the straddle chain starts left of the big blind and stops short of the
|
|
// small one.
|
|
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
|
|
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
|
|
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
|
|
});
|
|
});
|
|
|
|
describe("the blind badge counts", () => {
|
|
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
|
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
|
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
|
// them back to back and it costs nothing. That much is true.
|
|
//
|
|
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
|
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
|
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
|
// pointing away from the table on one of them points at the corner of the other.
|
|
// Measured on the running page, three tables in the sweep had a player's stack
|
|
// painted under the next player's SB or BB — and the badge outranks everything else
|
|
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
|
test("a seat needs room for the badge it might be wearing", () => {
|
|
const puck = { width: 64, height: 40 };
|
|
assert.deepEqual(Render.seatFootprint(puck),
|
|
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
|
});
|
|
|
|
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
|
// It sits inside the puck's own width — see "all three sit in the same place"
|
|
// above, which holds the stylesheet to it.
|
|
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
|
});
|
|
|
|
test("the number here is the number the stylesheet draws", () => {
|
|
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
|
// puck by it, render.js charges every chair in the ring for it. They are wired
|
|
// together by nothing but this test. Drift either way is silent — too small and
|
|
// badges land on neighbours again, too large and every puck on a nine-handed
|
|
// phone gives up a size step for clearance nothing needs.
|
|
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
|
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
|
assert.ok(top && bottom, "the badge should hang off both arcs");
|
|
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
|
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
|
"the same distance at both ends, or one arc is charged for the other's badge");
|
|
});
|
|
|
|
test("and the dealer chip shares that edge on exactly the same terms", () => {
|
|
// It moved out of the puck's top-right corner and onto the rim beside the badges,
|
|
// which is only free while it hangs off by the same 5px they do. A pixel more and
|
|
// the ring is charging every chair for a distance it isn't measuring.
|
|
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
|
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
|
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
|
|
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
|
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
|
});
|
|
|
|
test("and it stays inside the puck's own width, where the badges are", () => {
|
|
// Sideways is the direction that lands on the seat next door. This chip has been
|
|
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
|
|
// 9. `right` is a positive inset for the same reason `left: 2px` is.
|
|
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
|
|
assert.ok(inset, "the chip should be inset from the right edge");
|
|
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
|
|
});
|
|
|
|
test("and it is short enough to clear the name it sits above", () => {
|
|
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
|
|
// margin put the name's top 8px below the puck's, and the chip reaches
|
|
// height - overhang below it.
|
|
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
|
|
assert.ok(height, "the chip should have a height to check");
|
|
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
|
|
"the chip dips into the name row on the tight ring");
|
|
});
|
|
|
|
test("and it is counted once, not once per seat", () => {
|
|
// seatsCollide is given one box and compares centres against it, so the
|
|
// separation it asks for is one puck plus one badge. That is exactly right for
|
|
// two seats on the same arc each wearing one, and generous for the far commoner
|
|
// case where only one of the two is in the blinds at all. Counting it on both
|
|
// sides is what made a landscape table unsatisfiable the first time this was
|
|
// tried, back when that felt was 87px tall.
|
|
const puck = { width: 64, height: 40 };
|
|
const grew = Render.seatFootprint(puck).height - puck.height;
|
|
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
|
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
|
});
|
|
});
|
|
|
|
describe("with no browser to measure", () => {
|
|
test("nothing here throws", () => {
|
|
assert.equal(Render.measuredSeatBox(), null);
|
|
assert.equal(Render.seatFootprint(null), null);
|
|
});
|
|
});
|