The last two rounds left one thing failing and said so: a landscape phone with six or twelve seats stacked two of them 25px apart at each end of the felt, with pucks 64px tall. It has been there since seats were first spaced by distance rather than by angle. Three things were wrong at once, and it needed all three. The rim is the wrong shape when the table is very flat. An ellipse 616px wide and 96px tall has nowhere to put the seats near its ends: the curve doubles back on itself, so two seats a long walk apart come out one above the other. Past two and a half times wider than tall the rim is now a stadium — two straight rails with a half-round cap at each end, which is how a real poker table is built and exactly why it is built that way. A cap is a half-circle of the felt's own height, so an end takes one seat and the rest is rail. Only past that ratio: measured, the cut-off lands between a landscape phone at 4.4 and every other layout the tool has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape. The ring had less height than the felt did. The seat layer is inset by half a puck to keep the outermost seats on screen, and 26px off the top and bottom of a 96px felt left 44px to work with — less than half the table, which is why no rim shape could have fixed it alone. The inset is a percentage where the felt is short. The landscape branch never had that rule at all, because it is matched on height while the inset lives in a width query, so an 844px-wide phone on its side drew its ring against the felt's whole box; with a flat-topped stadium rail that put a dealer button over Resolve Pot in the header. And the puck was a line too tall. In the tight state the hole cards give up their row: of the four things on a puck, the cards are the one the dealer is holding in their other hand, and the seat editor still shows them. That took the puck from 64px to 55px, which is what finally cleared the ends. Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the closest two seats at 640x360 go from 25px apart to 55px, and every combination is clean. Then 216 audits across thirteen viewports, three seat counts and both a mouse and a thumb: no failures, no console errors. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
// Where the seats sit on the rim.
|
|
//
|
|
// The ring used to space seats by angle, which is only the same as spacing them evenly
|
|
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
|
|
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
|
|
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const { Render } = require("./load");
|
|
|
|
// The seats as a dealer sees them: pixels on a felt of a given size.
|
|
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
|
|
const aspect = width / height;
|
|
return Array.from({ length: count }, (_, i) => {
|
|
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
|
|
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
|
|
});
|
|
};
|
|
|
|
const gaps = (points) => points.map((p, i) => {
|
|
const next = points[(i + 1) % points.length];
|
|
return Math.hypot(next.x - p.x, next.y - p.y);
|
|
});
|
|
|
|
// What the ring did before: one turn cut into equal angles.
|
|
const byAngle = (index, count, rx, ry) => {
|
|
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
|
|
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
|
|
};
|
|
|
|
describe("a round table", () => {
|
|
test("still spaces seats by angle, because on a circle that is the same thing", () => {
|
|
for (let seat = 0; seat < 9; seat++) {
|
|
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
|
|
const before = byAngle(seat, 9, 40, 40);
|
|
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
|
|
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
|
|
}
|
|
});
|
|
|
|
test("seat one is at the top, whatever shape the felt is", () => {
|
|
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
|
|
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
|
|
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
|
|
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("a felt wider than it is tall", () => {
|
|
// 640x360 landscape: the size the audit was failing at.
|
|
const wide = () => ringPixels(10, 601, 126);
|
|
|
|
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
|
|
const pos = byAngle(i, 10, 44, 42);
|
|
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
|
|
});
|
|
|
|
// The two seats at the right-hand end, which is where they crowd.
|
|
const endPairApart = (seats) => {
|
|
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
|
|
return Math.abs(rightmost[0].y - rightmost[1].y);
|
|
};
|
|
|
|
test("pulls the pair at each end apart", () => {
|
|
// They still share the end — ten seats on a rim this flat have nowhere else to
|
|
// be — but they stop sitting on top of each other.
|
|
const now = endPairApart(wide());
|
|
const before = endPairApart(byAnglePixels());
|
|
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
|
});
|
|
|
|
test("nearly doubles the room between the closest two neighbours", () => {
|
|
// Not equal spacing in a straight line — at the ends the rim turns back on
|
|
// itself, so the same walk along it covers less ground across. Even there the
|
|
// gap goes from a third of a puck to most of one.
|
|
const now = Math.min(...gaps(wide()));
|
|
const before = Math.min(...gaps(byAnglePixels()));
|
|
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
|
assert.ok(now > 55, `${now.toFixed(1)}px`);
|
|
});
|
|
|
|
test("never sits two seats on the same spot", () => {
|
|
const seats = wide();
|
|
seats.forEach((seat, i) => {
|
|
seats.slice(i + 1).forEach((other, j) => {
|
|
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
|
|
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("how close the closest two seats come", () => {
|
|
// What decides whether the pucks shrink. Rim length per seat was the first answer
|
|
// and it is the wrong question: it measures the walk between two seats, not the
|
|
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
|
|
// apart end up stacked one above the other.
|
|
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
|
|
|
test("a flat table no longer stacks two seats at each end", () => {
|
|
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
|
|
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
|
|
// is a stadium at this shape, so an end holds one seat.
|
|
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
|
|
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
|
|
});
|
|
|
|
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
|
|
// Same felt, forced through the ellipse the tool uses for rounder tables.
|
|
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
|
|
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
|
|
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
|
|
}));
|
|
const closest = asEllipse.reduce((least, point, i) => {
|
|
const next = asEllipse[(i + 1) % asEllipse.length];
|
|
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
|
|
}, Infinity);
|
|
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
|
|
assert.ok(gap(6, 616, 96) > closest * 1.3,
|
|
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
|
|
});
|
|
|
|
test("a round table's neighbours are about as far apart as the rim says", () => {
|
|
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
|
|
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
|
|
});
|
|
|
|
test("more seats, less room, in that order", () => {
|
|
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
|
|
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
|
|
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
|
|
});
|
|
|
|
test("nothing to measure between fewer than two seats", () => {
|
|
assert.equal(gap(1, 400, 400), Infinity);
|
|
assert.equal(gap(9, 0, 0), Infinity);
|
|
});
|
|
});
|
|
|
|
describe("which rim a table gets", () => {
|
|
// An ellipse until the table is much wider than tall, then a stadium: two rails
|
|
// and a half-round cap at each end, which is how a real poker table is built.
|
|
const shape = (width, height) => {
|
|
const aspect = width / height;
|
|
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
|
|
};
|
|
|
|
test("every layout the tool had keeps the ellipse", () => {
|
|
assert.equal(shape(844, 608), "ellipse", "desktop");
|
|
assert.equal(shape(600, 600), "ellipse", "tablet");
|
|
assert.equal(shape(278, 314), "ellipse", "phone upright");
|
|
assert.equal(shape(336, 240), "ellipse", "short phone upright");
|
|
});
|
|
|
|
test("a phone on its side gets the stadium", () => {
|
|
assert.equal(shape(528, 73), "stadium");
|
|
assert.equal(shape(780, 113), "stadium");
|
|
});
|
|
|
|
test("the cap holds one seat instead of stacking two", () => {
|
|
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
|
|
// the cap, or two of them end up on the same half-circle.
|
|
const halfWidth = 44 * (616 / 96), halfHeight = 42;
|
|
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
|
|
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
|
|
});
|
|
|
|
test("seat one is still at the top centre, and the ring still mirrors", () => {
|
|
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
|
|
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
|
|
assert.ok(seats[0].top < 50);
|
|
for (let seat = 1; seat < 4; seat++) {
|
|
const mirrored = seats[8 - seat];
|
|
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
|
|
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
|
|
}
|
|
});
|
|
|
|
test("nobody is drawn off the felt", () => {
|
|
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
|
|
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
|
|
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the shape of the ring", () => {
|
|
test("is a mirror image left to right", () => {
|
|
const seats = ringPixels(10, 601, 126);
|
|
for (let seat = 1; seat < 5; seat++) {
|
|
const mirrored = seats[10 - seat];
|
|
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
|
|
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
|
|
}
|
|
});
|
|
|
|
test("holds for the smallest and largest tables the seat count allows", () => {
|
|
[2, 3, 4, 9, 12, 24].forEach(count => {
|
|
const seats = ringPixels(count, 601, 126);
|
|
assert.equal(seats.length, count);
|
|
seats.forEach((seat, i) => {
|
|
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
|
|
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
|
|
});
|
|
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
|
|
});
|
|
});
|
|
|
|
test("shares the rim out equally on a round table at any size", () => {
|
|
[3, 6, 9, 10, 24].forEach(count => {
|
|
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
|
|
const shortest = Math.min(...spacing);
|
|
const longest = Math.max(...spacing);
|
|
assert.ok(longest - shortest < 0.5,
|
|
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
|
|
});
|
|
});
|
|
|
|
test("asked for no seats, offers no positions", () => {
|
|
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
|
|
});
|
|
|
|
test("puts each bet between its own player and the pot", () => {
|
|
// Where a dealer pushes the chips: on that player's line to the middle, short
|
|
// of the seat.
|
|
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
|
|
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
|
|
|
|
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
|
|
for (let seat = 0; seat < 9; seat++) {
|
|
const player = Render.seatPosition(seat, 9, w / h);
|
|
const bet = Render.betPosition(seat, 9, w / h);
|
|
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
|
|
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
|
|
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
|
|
}
|
|
});
|
|
});
|
|
});
|