// Where a player's bet sits on the felt. // // The chips were the last thing on the table nobody had measured. They were laid out on // a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the // seat ring was itself fixed — and the choice between that ring and clinging to the // puck was made by asking how wide the *window* was. Neither of those is a question // about the felt. A phone held sideways is 844px across and gives the felt 117px of // height: it passed the width test, took the ring, and every chip on it was painted // behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a // chip under a puck is not dimmed or half-hidden — it is gone, and with it the only // number saying what that player has in. // // Everything below is geometry, so it runs without a browser. The felts are the ones // measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { Render } = require("./load"); const RADII = { rx: 44, ry: 42 }; const PUCK = { width: 96, height: 64 }; const CHIP = { width: 44, height: 22 }; const felt = (width, height) => ({ width, height }); const spacingOn = (box, puck = PUCK, chip = CHIP) => ({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX }); // A chip's centre in pixels on a given felt. const chipPixels = (index, count, box, spacing, radii = RADII) => { const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing); return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height }; }; const seatPixels = (index, count, box, radii = RADII) => { const pos = Render.seatPosition(index, count, box.width / box.height, radii); return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height }; }; // Two axis-aligned boxes, by their centres. The same test the seats settle among // themselves — see seatFootprint.test.js — because it is the same question. const boxesOverlap = (a, b, sizeA, sizeB) => Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 && Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5; describe("reaching the edge of a box", () => { test("straight out the side is half the width, straight up half the height", () => { assert.equal(Render.edgeReach(96, 64, 1, 0), 48); assert.equal(Render.edgeReach(96, 64, 0, -1), 32); assert.equal(Render.edgeReach(96, 64, -1, 0), 48); }); test("a corner is whichever edge the ray meets first", () => { // 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the // ray leaves through one of those. A single radius — half the diagonal, 57.7 — // would put a chip 25px further out than it needs to be, which on a 117px felt // is the difference between clear of the puck and off the table. const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2); assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`); assert.ok(diagonal < 48, "and never further than the wider half-edge"); }); test("a square is the same in every direction it is asked about", () => { const straight = Render.edgeReach(50, 50, 0, 1); assert.equal(Render.edgeReach(50, 50, 1, 0), straight); }); }); describe("getting one box clear of another", () => { const PUCK96 = { width: 96, height: 88 }; const CHIP50 = { width: 50, height: 21 }; // Travel `t` along `u` from the puck's centre, then ask whether the two boxes are // apart. This is the question clearanceReach is supposed to answer, asked the long // way round so the answer can be checked rather than restated. const apart = (t, ux, uy) => Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 || Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9; test("straight up and down it is what adding the two half-heights gives", () => { assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2); assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2); }); test("on a diagonal it is more than the two reaches added together", () => { // The bug this replaced. Two axis-aligned boxes are clear when they are apart on // one axis, and the travel that achieves that is the reach of the box you get by // adding their widths and their heights — which is never less, and on a diagonal // is more, than adding the two boxes' own reaches. At 768x1024 the difference was // 11px, and 11px is how far inside its own player's puck the chip came to rest. const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]]; let shortfalls = 0; dirs.forEach(([ux, uy]) => { const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) + Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy); const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy); assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`); if (proper <= summed + 1e-9) return; // Where they differ, the old answer was not merely smaller — it was wrong: // travelling that far leaves the two boxes still on top of each other. shortfalls++; assert.ok(!apart(summed, ux, uy), `${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`); }); assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these"); }); test("and it is exactly enough — a hair less is not", () => { const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]]; dirs.forEach(([ux, uy]) => { const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy); assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`); assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`); }); }); }); describe("a chip on the ring", () => { test("with nothing measured it keeps the proportions the ring always used", () => { // The unit suite and the very first render both call it this way. 27/44 and // 25/42 of the seat's own reach, so the chips move with the ring rather than // with the felt. const seat = Render.seatPosition(3, 9, 1.4, RADII); const chip = Render.betPosition(3, 9, 1.4, RADII, null); assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9); assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9); }); test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => { const box = felt(560, 400); const spacing = spacingOn(box); for (let i = 0; i < 9; i++) { const seat = seatPixels(i, 9, box); const chip = chipPixels(i, 9, box, spacing); assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP), `seat ${i + 1}'s own chip is under its own puck`); } }); test("and never wanders into the pot badge in the middle", () => { // The floor: 38% of the way out. Without it a chip walked in past a puck that // is 96px wide on a felt whose reach is 90px would land on the pot total, which // is the same fault at the other end of the spoke. const box = felt(300, 216); const spacing = spacingOn(box, { width: 200, height: 150 }); for (let i = 0; i < 6; i++) { const seat = seatPixels(i, 6, box); const chip = chipPixels(i, 6, box, spacing); const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2); const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2); assert.ok(chipReach >= seatReach * 0.38 - 0.5, `chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`); } }); test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => { const box = felt(400, 300); const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box)); assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos)); }); }); describe("deciding whether a ring of chips fits at all", () => { // The shape the layout works in: one entry per chip on the felt, carrying its own // measured size, because a chip is as wide as the number printed on it. const tableOf = (count, chip = CHIP) => { Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) }; return Array.from({ length: count }, (_, i) => ({ index: i, width: chip.width, height: chip.height })); }; test("a desktop felt has room for one", () => { const box = felt(700, 500); const all = tableOf(9); assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true); }); test("a phone on its side has not, whatever the window's width says about it", () => { // 692x117, measured on an 844px-wide phone in landscape — a viewport that sails // past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip // on the seat across the table from it. const box = felt(692, 117); const all = tableOf(9); assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false, "this is the felt where every chip was painted behind a puck"); }); test("its answer is the one the geometry gives", () => { // Not a restatement of the two cases above: whatever it returns, it has to agree // with laying the chips out and measuring them against every puck on the felt. const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)]; shapes.forEach(box => { const all = tableOf(9); const spacing = spacingOn(box); const aspect = box.width / box.height; const clear = all.every(({ index: i }) => { const chip = chipPixels(i, 9, box, spacing); return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK)); }); assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear, `${box.width}x${box.height}`); }); }); test("each chip is asked about at its own size", () => { // A chip is as wide as the number on it: at 768x1024, four players' chips were // 40px and one holding a four-figure bet was 47. Answering the question once, at // the widest, told the whole felt it had no room for a ring — and dropped all // five chips onto their own players' stacks to solve a problem four of them // did not have. // The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88. const box = felt(672, 672); const narrow = { width: 40, height: 20 }; const huge = { width: 420, height: 380 }; const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX }; const aspect = box.width / box.height; assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true, "a felt this size has room for a ring of ordinary chips"); assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false, "and none at all for a ring of chips that size"); // Now one of each. The answer has to come from the sizes in the list, not from // one size standing in for all of them: with one huge chip the ring is off, and // taking that chip out of the question puts it back. const mixed = tableOf(9, narrow); mixed[0] = { index: 0, ...huge }; assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false); assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true, "the eight ordinary ones were never the problem"); }); test("and at the chips against each other", () => { // The ring is a smaller circle than the seats' one and holds the same number of // things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve // players, two $10s on the ring sat on top of each other while every puck on the // felt was clear — so a check that only looks at the pucks says yes to a layout // that hides one player's bet behind another's. // The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84, // pucks 44x55, a pot badge 64x53, and the seven players who had put chips in. const box = felt(300, 336); const radii = { rx: 44, ry: 40.84 }; const puck = { width: 44, height: 55 }; const pot = { x: 150, y: 168, width: 64, height: 53 }; const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX }; const aspect = box.width / box.height; Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) }; const bettors = [2, 3, 4, 5, 6, 7, 8]; const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 })); const chips = all.map(entry => ({ ...entry, ...chipPixels(entry.index, 12, box, { ...spacing, chip: { width: entry.width, height: entry.height } }, radii), })); const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii)); assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false, "this table is chosen so the pucks are not the problem"); assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false, "nor the pot"); assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j && boxesOverlap(chip, other, chip, other))), "and so the chips are"); assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false); }); test("it looks at every puck, not just the chip's own", () => { // The rule this replaced walked each chip in until it was clear of its *own* // seat and stopped there, which says nothing about the eight other pucks on the // felt. 560x190 is a felt where that rule is satisfied and the ring is still no // good: every chip clears the puck it belongs to, and one of them is sitting on // a stranger's. const box = felt(560, 190); const all = tableOf(9); const spacing = spacingOn(box); const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing)); all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK), `chip ${i + 1} is under its own puck, so this felt does not make the point`)); assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j && boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))), "expected a chip on somebody else's puck — what the old rule could not see"); assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false); }); }); describe("a chip clinging to its puck", () => { // The fallback when there is no room for a ring: the chip hangs off the puck on the // side facing the empty middle of the felt. // // The felts the app actually draws, with the puck size each one settles on — read // off the running site at each viewport rather than guessed here, because the tight // felts shrink the puck too, and a test that holds the desktop's 96x64 puck against // a 528x83 felt is describing a table the app never draws. const REAL = [ ["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }], ["iPhone on its side", felt(692, 113), { width: 72, height: 55 }], ["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }], ["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }], ]; // Chips are 21x17 everywhere they are anchored — the amount is the only thing on // one at that size. const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } }; // What each chip is told to do, worked out for the whole table at once. const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => { Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) }; const indices = [...Array(count).keys()]; const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height })); const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX }; const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing); return indices.map(i => { const seat = Render.seatPosition(i, count, box.width / box.height, radii); const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height }; const off = offs[i]; return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck }; }); }; test("on its own, a chip asks to sit just clear of its puck", () => { const box = felt(692, 113); const puck = { width: 72, height: 55 }; const seat = Render.seatPosition(0, 9, box.width / box.height, RADII); const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII); const travel = Math.hypot(off.x, off.y); const ux = off.x / travel, uy = off.y / travel; assert.ok(Math.abs(travel - (Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`); }); test("and gets it, where nothing else is in the way", () => { // 692x113 is the roomier of the two landscape felts. Every chip clears its own // puck and none of them has to give ground. placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => { assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip), `seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`); }); }); test("it goes off the rim, not down the spoke", () => { // On a felt 692x113 the two are nowhere near the same direction. A seat on the // top rail has a spoke pointing diagonally inward — which along a slot this flat // runs sideways into the next seat — while off the rim is very nearly straight // down, into the empty middle. This is the seam that put two chips on top of // each other on a Galaxy S8. const box = felt(692, 113); const rows = placeAll(9, box, { width: 72, height: 55 }); const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7); assert.ok(top, "expected a seat up on the right of the top rail"); assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2, `chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`); assert.ok(top.off.y > 0, "and downward, toward the middle of the felt"); }); test("no two chips on a full table land on each other", () => { // Four sides gave three seams. There are no sides now, and this is what that is // worth — on every felt the app actually draws, at every table size. REAL.forEach(([name, box, puck]) => { [6, 9, 12].forEach(count => { const rows = placeAll(count, box, puck); for (let a = 0; a < rows.length; a++) { for (let b = a + 1; b < rows.length; b++) { assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip), `${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`); } } }); }); }); test("a chip only gives ground when it is standing on something", () => { // The point of pulling chips back one at a time rather than by a rule that // applies to all of them. Stopping every chip at the felt's midline, or scaling // them all by one factor, takes travel from the seats at the ends of a long // rail — measured at 692x113, that parked two chips on their own player's stack // to solve a collision that did not exist. // // So: every chip either got everything it asked for, or putting the last step // back reintroduces a collision it is part of. The step goes back for all of // them at once, because a facing pair gives ground together and the pass that // finally separates such a pair moves both ends of it. const step = Render.CHIP_PULLBACK_STEP_PX; REAL.forEach(([name, box, puck]) => { [6, 9, 12].forEach(count => { const rows = placeAll(count, box, puck).map(row => { const travel = Math.hypot(row.off.x, row.off.y); const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel }; const asked = travel < 1e-6 ? 0 : Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) + Render.BET_CLEARANCE_PX; const back = Math.min(asked, travel + step); return { ...row, travel, asked, undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } }; }); rows.forEach((row, i) => { if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return; const onSomething = rows.some((other, j) => j !== i && ( boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) || boxesOverlap(row.undone, other.seat, TIGHT.chip, puck))); assert.ok(onSomething, `${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`); }); }); }); }); test("nobody is pushed further out than they asked to go", () => { // The pull-back only ever takes travel away. A chip pushed past its clearance // would be floating out on the felt with nothing to say whose it is. REAL.forEach(([name, box, puck]) => { placeAll(12, box, puck).forEach((row, i) => { const travel = Math.hypot(row.off.x, row.off.y); if (travel < 1e-6) return; const ux = row.off.x / travel, uy = row.off.y / travel; const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX; assert.ok(travel <= asked + 0.5, `${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`); }); }); }); test("the direction turns smoothly all the way round the ring", () => { // The property a set of discrete sides cannot have, and the reason there are // none: neighbouring seats get neighbouring directions, with no step anywhere. const box = felt(560, 400); const angles = Array.from({ length: 24 }, (_, i) => { const seat = Render.seatPosition(i, 24, box.width / box.height, RADII); const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII); return Math.atan2(off.y, off.x); }); angles.forEach((angle, i) => { const next = angles[(i + 1) % angles.length]; let turn = Math.abs(next - angle); if (turn > Math.PI) turn = 2 * Math.PI - turn; assert.ok(turn < Math.PI / 4, `between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`); }); }); test("it stops short of the pot total as well", () => { // The other thing in the middle of the felt, and the one number on the table // that belongs to everybody. A seat on the middle of a rail sends its chip // straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on // "Pot $65". The badge is measured rather than assumed to be at the centre — // it lives on the table, not on either overlay, and the two are not concentric. const box = felt(528, 83); const pot = { x: 264, y: 40, width: 96, height: 34 }; Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) }; const indices = [...Array(12).keys()]; const entries = indices.map(i => ({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height })); const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot }; const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing); indices.forEach(i => { const seat = Render.seatPosition(i, 12, box.width / box.height, RADII); const chip = { x: (seat.left / 100) * box.width + offs[i].x, y: (seat.top / 100) * box.height + offs[i].y }; assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot), `chip ${i + 1} is on the pot total`); }); }); test("a seat with no rim to be off still gets an answer", () => { const box = felt(400, 300); const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII); assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off)); assert.ok(off.y < 0, "upward, which is where a puck's own writing is not"); }); });