diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index d02dc96..2e0247f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -145,8 +145,67 @@ const Render = { // ---- Seats around the table (compact pucks) ---- - seatPosition(index, count, aspect = this.feltAspect()) { - return this.positionOnRing(index, count, 44, 42, aspect); + // The widest the ring has ever been allowed to be. These are the numbers the ring + // used to be pinned at, kept as a ceiling so nothing gets looser than it was. + RING_MAX_RX: 44, + RING_MAX_RY: 42, + // A little daylight between the outermost puck and the rail. + SEAT_EDGE_MARGIN_PX: 6, + // Below this the ring has collapsed into the pot, which is worse than an overhang. + RING_MIN_R: 18, + + // How far out the ring can sit and still keep a whole seat on the table. + // + // It used to be 44% by 42%, flat, at every size — two numbers picked against a + // phone. A fixed pair can only be right for one ratio of puck to felt, and the + // felt's ratio changes with every viewport: measured, at 1280x900 nine-handed, + // five of nine pucks finished past the outer edge of the table, the worst by 49px, + // and on a phone held sideways all six did. Seats floating off the felt onto the + // page read as the table being too small for the game rather than as players at a + // rail — and it is a different amount of wrong on every screen, which is what makes + // it look like a mistake rather than a choice. + // + // So it is derived rather than declared: the widest ring whose outermost seat still + // finishes inside the table. Falls back to the old pair whenever there is nothing to + // measure, which is every call from the unit tests and the first render before the + // felt has been laid out. + seatRingRadii() { + const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY }; + if (typeof document === "undefined") return fallback; + + const box = this.ringBox(); + const table = document.getElementById("pokerTable"); + const puck = document.querySelector(".seat"); + if (!box || !box.width || !box.height || !table || !puck) return fallback; + + const tableBox = table.getBoundingClientRect(); + const puckBox = puck.getBoundingClientRect(); + if (!tableBox.width || !puckBox.width) return fallback; + + // Worked in absolute pixels rather than from half-widths, because the seat + // layer is *not* centred on the table: its insets differ top from bottom (the + // landscape rule sets a floor on the top edge and a ceiling on the bottom), so + // assuming symmetry left the shorter side still hanging over. Measured that + // way, a phone still had 12px of puck past the rail after the first attempt. + // + // The reach that matters is to the nearer of the two edges. + const reach = (layerCentre, nearEdge, farEdge, puckSide, boxSide) => { + const room = Math.min(layerCentre - nearEdge, farEdge - layerCentre); + return ((room - puckSide / 2 - this.SEAT_EDGE_MARGIN_PX) / boxSide) * 100; + }; + + return { + rx: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RX, + reach(box.left + box.width / 2, tableBox.left, tableBox.right, + puckBox.width, box.width))), + ry: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RY, + reach(box.top + box.height / 2, tableBox.top, tableBox.bottom, + puckBox.height, box.height))), + }; + }, + + seatPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) { + return this.positionOnRing(index, count, radii.rx, radii.ry, aspect); }, // How many times wider than tall the felt actually is, which is the one thing the @@ -179,8 +238,11 @@ const Render = { // The two answers are within half a degree of each other, so this is not a fix for // anything visible — it is just the honest way to say "on that player's spoke", // and it saves the second walk. - betPosition(index, count, aspect = this.feltAspect()) { - const seat = this.seatPosition(index, count, aspect); + betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) { + const seat = this.seatPosition(index, count, aspect, radii); + // Fractions of the seat's own reach, not of the felt — so when the ring pulls in + // to keep the pucks on the table, the chips come with it and stay on the same + // spoke, the same proportion of the way to the pot. return { left: 50 + (seat.left - 50) * (27 / 44), top: 50 + (seat.top - 50) * (25 / 42), @@ -279,10 +341,15 @@ const Render = { // a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim // each and sat 56px apart, because at the ends of a long oval the rim doubles back // and two seats a long walk apart are stacked one above the other. - closestSeatGapPx(count, box = this.ringBox()) { + closestSeatGapPx(count, box = this.ringBox(), radii = this.seatRingRadii()) { if (count < 2 || !box || !box.width || !box.height) return Infinity; - const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({ + // The ring the seats are actually drawn on, not the widest one they could be. + // This asked about a flat 44/42 while the renderer had pulled the ring in to + // keep the pucks on the table, so it measured gaps that did not exist: in + // landscape it reported plenty of room and the seats were overlapping each + // other's centres, which the audit caught as taps landing on the wrong player. + const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).map(seat => ({ x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height, })); @@ -300,11 +367,94 @@ const Render = { // Called on every render and again whenever the felt is resized — the action bar // growing mid-hand shortens the table, and the seats have to answer that without // waiting for the next render to come along. - updateSeatDensity() { + // Which layout the felt gets, and how wide its ring is. + // + // These two answers depend on each other, which is what made the first attempt at + // this wobble: the ring's radius is limited by the size of a puck, the puck's size + // is set by the `seatsTight` class, and that class was being decided from the gaps + // between seats — measured at a radius that hadn't been worked out yet. Fixing the + // gap measurement to use the real radius then made a nine-handed phone overhang + // that had been fine, because now the two were chasing each other. + // + // So it is settled by trying the candidates in order of preference rather than by + // solving it in one pass: the roomy layout if everything fits, the tight one if it + // doesn't, and if neither fits, the tight pucks on a ring wide enough that seats + // don't overlap — because two seats fighting over a tap is a worse failure than a + // puck sitting slightly over the rail. A dealer can see an overhang; they cannot + // see that the seat they meant to press wasn't the one that answered. + // How much space two neighbouring seats need between their centres. + // + // This was a flat 84px, which is the figure a 96px-wide puck was judged against — + // seats allowed to sit a little closer than their own width, since the pucks are + // rounded and a small overlap at the corners reads fine. Asking the *tight* layout + // to clear the same 84px is asking a 68px puck to keep a 96px puck's distance: it + // could never pass, so every landscape table fell through to the fallback and took + // an overhang it didn't need. The ratio is what carries over, not the number. + SEAT_ROOM_RATIO: 84 / 96, + + seatRoomNeededPx() { + if (typeof document === "undefined") return this.SEAT_ROOM_PX; + const puck = document.querySelector(".seat"); + if (!puck) return this.SEAT_ROOM_PX; + const width = puck.getBoundingClientRect().width; + return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX; + }, + + resolveSeatLayout(count) { const layer = document.getElementById("seatsLayer"); - if (!layer || !this.state) return; - layer.classList.toggle("seatsTight", - this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX); + if (!layer) return { radii: this.seatRingRadii() }; + + for (const tight of [false, true]) { + layer.classList.toggle("seatsTight", tight); + const radii = this.seatRingRadii(); + const roomy = this.closestSeatGapPx(count, this.ringBox(), radii) >= this.seatRoomNeededPx(); + if (roomy && !this.tooTallForTheTable()) return { radii }; + } + + // Neither fits — a phone held sideways gives the felt about 140px of height and + // there is no ring that puts a dozen readable pucks inside that. Keep the small + // pucks and widen the ring only as far as it takes to stop the seats colliding, + // rather than all the way back out: going straight to the full width bought an + // overhang of 14px where 4 would have done. + layer.classList.add("seatsTight"); + const contained = this.seatRingRadii(); + const box = this.ringBox(); + for (let step = 1; step <= 8; step++) { + const t = step / 8; + const radii = { + rx: contained.rx + (this.RING_MAX_RX - contained.rx) * t, + ry: contained.ry + (this.RING_MAX_RY - contained.ry) * t, + }; + if (this.closestSeatGapPx(count, box, radii) >= this.seatRoomNeededPx()) return { radii }; + } + return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } }; + }, + + updateSeatDensity() { + if (!this.state) return; + this.resolveSeatLayout(this.state.seatCount); + }, + + // Room is not only sideways. + // + // "Tight" used to mean one thing: seats close enough together to bump into each + // other. On a phone held sideways the felt is 141px tall and the pucks had grown to + // 88 — two-line names — so six seats had all the horizontal room in the world and + // could not fit top to bottom whatever the ring did. The gap check said everything + // was fine and 20px of every seat hung over the rail. + // + // A puck more than half the table's height is the honest test: two seats on + // opposite sides of the ring would then have to overlap the pot to fit at all, so + // the pucks are what has to give. + tooTallForTheTable() { + if (typeof document === "undefined") return false; + const table = document.getElementById("pokerTable"); + const puck = document.querySelector(".seat"); + if (!table || !puck) return false; + const tableBox = table.getBoundingClientRect(); + const puckBox = puck.getBoundingClientRect(); + if (!tableBox.height || !puckBox.height) return false; + return puckBox.height > tableBox.height * 0.5; }, // The angles that cut the rim into `count` equal lengths, starting at the top. @@ -385,18 +535,23 @@ const Render = { layer.innerHTML = ""; const anchorToSeat = window.matchMedia("(max-width: 700px)").matches; + // Measured once for the whole ring. Asked per seat, this reads the layout back + // nine times in a loop that is also writing to it, which is the read-after-write + // pattern that makes the browser recompute layout on every iteration. + const aspect = this.feltAspect(); + const radii = this.seatRingRadii(); this.state.seats.forEach((player, index) => { const bet = Betting.committed(player); if (bet <= 0) return; const chip = this.makeBetChip(bet); - const pos = this.betPosition(index, this.state.seatCount); + const pos = this.betPosition(index, this.state.seatCount, aspect, radii); if (anchorToSeat) { const seat = document.querySelector(`.seat[data-seat-index="${index}"]`); if (!seat) return; - const seatPos = this.seatPosition(index, this.state.seatCount); + const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii); chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow"); seat.appendChild(chip); return; @@ -458,7 +613,13 @@ const Render = { const layer = document.getElementById("seatsLayer"); const count = this.state.seats.length; if (layer.childElementCount !== count) this.buildSeatNodes(layer, count); - this.updateSeatDensity(); + // Settles the tight class and the ring radius together, and hands back the + // radius it settled on — so the loop below doesn't have to ask again. Both of + // these read the layout back, so they are taken once, here, before the loop + // starts writing to it: asked per seat they would force the browser to + // recompute the layout on every iteration. + const { radii } = this.resolveSeatLayout(count); + const aspect = this.feltAspect(); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); // A button on an empty chair normally means the table has drifted and wants // fixing. Once it's the dead button it means the opposite — it's exactly where @@ -470,7 +631,7 @@ const Render = { this.state.seats.forEach((player, index) => { const node = layer.children[index]; if (!node) return; - const pos = this.seatPosition(index, this.state.seatCount); + const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); node.style.left = `${pos.left}%`; node.style.top = `${pos.top}%`; diff --git a/tests/holdem/seatsOnTheTable.test.js b/tests/holdem/seatsOnTheTable.test.js new file mode 100644 index 0000000..353cae3 --- /dev/null +++ b/tests/holdem/seatsOnTheTable.test.js @@ -0,0 +1,110 @@ +// How wide the ring of seats is allowed to be. +// +// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked +// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141 +// one on a phone held sideways. A fixed pair can only be right for one ratio of puck +// to felt, so measured on the running page: +// +// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px +// 844x390, six-handed all six, worst 20px +// 430x932, six-handed two, worst 12px +// +// Seats floating off the felt onto the page read as the table being too small for the +// game rather than as players at a rail, and it was a different amount of wrong on +// every screen — which is what made it look like a mistake instead of a choice. +// +// The radius is derived from what has to fit now. Whether it *actually* fits is a +// question about a laid-out browser, and it is checked there: uiAudit's "seats and +// chips stay on the table" runs at every viewport in the sweep. What is checked here +// is the contract that function has to honour, including the two ways it is allowed +// to give up. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render } = require("./load"); + +describe("with no browser to measure", () => { + test("it falls back to the ring the tool has always used", () => { + // Every call from this suite has no DOM, and so does the very first render, + // before the felt has been laid out. Neither should throw or return nonsense. + assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY }); + }); + + test("so seatPosition still answers", () => { + const pos = Render.seatPosition(0, 9, 1); + assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top)); + }); + + test("and nothing is measured for the puck height either", () => { + assert.equal(Render.tooTallForTheTable(), false); + }); +}); + +describe("the ceiling and the floor", () => { + test("the old ring is the widest it can ever be", () => { + // Nothing gets looser than it was; this can only pull seats in. + assert.ok(Render.RING_MAX_RX <= 44); + assert.ok(Render.RING_MAX_RY <= 42); + }); + + test("and it never collapses the ring into the pot", () => { + // A puck wider than the table it sits on has no honest answer. Overhanging is + // the lesser evil — seats stacked on the pot would be unreadable, and the + // density rule shrinks the pucks before it ever gets this far. + assert.ok(Render.RING_MIN_R > 0); + assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY); + }); + + test("with a margin that keeps the outermost seat off the rail", () => { + assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0); + }); +}); + +describe("the ring the radii describe", () => { + const spread = (radii, count = 9, aspect = 1.4) => + Render.ringPositions(count, radii.rx, radii.ry, aspect); + + test("a smaller radius puts every seat nearer the middle", () => { + const wide = spread({ rx: 44, ry: 42 }); + const tight = spread({ rx: 30, ry: 28 }); + wide.forEach((seat, i) => { + const wideOut = Math.hypot(seat.left - 50, seat.top - 50); + const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50); + assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`); + }); + }); + + test("and the seats stay evenly spread however far in they are", () => { + // Pulling the ring in must not bunch them: the spacing rule is arc length, and + // shrinking an ellipse uniformly keeps every gap in proportion. + const gaps = ring => ring.map((p, i) => { + const next = ring[(i + 1) % ring.length]; + return Math.hypot(p.left - next.left, p.top - next.top); + }); + const tight = gaps(spread({ rx: 30, ry: 28 })); + const smallest = Math.min(...tight), largest = Math.max(...tight); + assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`); + }); +}); + +describe("the bets follow the seats", () => { + test("a chip sits on its own player's spoke, proportionally in", () => { + // The chip is a fraction of the seat's reach rather than of the felt, so when + // the ring pulls in the chips come with it instead of being left by the rail. + const radii = { rx: 30, ry: 28 }; + const seat = Render.seatPosition(2, 8, 1.4, radii); + const bet = Render.betPosition(2, 8, 1.4, radii); + const seatOut = Math.hypot(seat.left - 50, seat.top - 50); + const betOut = Math.hypot(bet.left - 50, bet.top - 50); + assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is"); + assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt"); + }); + + test("and it is the same spoke, not just the same distance", () => { + const radii = { rx: 30, ry: 28 }; + const seat = Render.seatPosition(5, 9, 1.2, radii); + const bet = Render.betPosition(5, 9, 1.2, radii); + const angleOf = p => Math.atan2(p.top - 50, p.left - 50); + assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index aebb402..25ff456 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -793,8 +793,51 @@ const LayoutAudit = { // on a mouse-driven browser — that is right, a 24px control is fine with a cursor // on it — but "not measured" and "measured and fine" are different answers and the // runner now prints which one it got. + // The table has to be big enough for the game around it. + // + // The seat ring sat at a flat 44% by 42% of the seat layer at every size — two + // numbers picked against a phone. A fixed pair can only be right for one ratio of + // puck to felt, and measured on the running page it was wrong nearly everywhere: + // at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the + // table, the worst by 49px; on a phone held sideways all six did. Seats floating + // off the felt onto the page read as the table being too small for the game rather + // than as players at a rail — and it was a different amount of wrong on every + // screen, which is what made it look like a mistake instead of a choice. + // + // Checked against the table's outer edge, border included: sitting *on* the rail is + // fine, hanging off the far side of it is not. The bet chips are held to the same + // rule — a chip is meant to be on the felt between its player and the pot. + seatsOnTheTable(slackPx = 1) { + const name = "seats and chips stay on the table"; + if (this.modalScope()) { + return { name, offenders: [], skipped: "a sheet is open over the table" }; + } + const table = document.getElementById("pokerTable"); + if (!table) return { name, offenders: [], skipped: "no table on this page" }; + + const felt = table.getBoundingClientRect(); + if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" }; + + const offenders = []; + const check = (el, what) => { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) return; + const over = Math.max( + felt.left - r.left, r.right - felt.right, + felt.top - r.top, r.bottom - felt.bottom); + if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`); + }; + + [...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`)); + [...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) => + check(chip, `bet chip ${i + 1}`)); + + return { name, offenders }; + }, + runAll(options = {}) { const checks = [ + this.seatsOnTheTable(options.seatSlack), this.touchAction(), this.tapHighlight(), this.textSelectable(),