From f93e0935ef2900dc0cc9748143871896990d2c58 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 13:23:31 -0600 Subject: [PATCH 1/3] Give every seat its own pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six players at a nine-handed table is the most ordinary table this tool draws, and on every phone in the sweep one of them had their stack painted under the player next to them. Measured across nine viewports and six table sizes: seventeen of fifty-four tables hid a name or a stack behind something belonging to another seat. Two causes, and the layout only knew about a third of one of them. The felt decided how big a puck could be from a single number — the straight-line distance between the closest two *neighbouring* seats, judged against a fraction of a puck's width. Distance has one number in it and a puck is half again as wide as it is tall, so the same 84px was generous side by side and nowhere near enough on the diagonal. And neighbours are the wrong pairs to ask about: on a phone held sideways the felt is 528x87, the ring is very nearly a straight line, and the seat that lands on top of you is the one across the table. Ten of the tables in the sweep had a collision that a neighbours-only check cannot see — seats 1 and 7, overlapping by 68px, while every adjacent pair had room. seatsCollide() asks the real question instead: do any two of these boxes want the same pixels. Two equal axis-aligned boxes overlap exactly when their centres are closer than one box in both axes, so it is an answer rather than a proxy, and it looks at every pair. The second cause was the small blind badge, sitting at `left: -6px` — hanging off its own puck's left corner onto whatever was beside it. On an iPhone Pro Max at six seats, with the pucks not touching at all, "SB" was painted across the next player's $1,137. All three badges now sit inside the puck's width, and in the same place as each other: a seat only ever wears one, so two positions bought nothing except making the dealer's eye find which of two spots this seat was using before it could read which blind it is. Then the layout had to be able to act on the answer, and it couldn't. It chose between two widths — 96px or 58px — and if neither fitted it widened the ring and let the seats overlap, on the reasoning that a dealer can see an overhang but cannot see a tap landing on the wrong player. That trade is real; it was also a false choice, because the tables that overlapped did not want either number. At nine seats a Galaxy S8 had room for a 61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. Three media queries declared three more of these numbers — 78px, 64px, 62px — at three breakpoints, guessing at something a media query cannot see: the same 390px phone has room for a 90px puck at four seats and 61 at nine. So the width is searched for now, not chosen. render.js walks candidates from the top, applies each one, measures what the browser actually drew, and takes the widest that leaves every seat its own box. There was a width that worked on every table in the sweep; the code was never asking for it. Ten candidates is ten forced layouts, so the result is cached against the felt's size, the seat count and the names — measured 12.5ms cold, 0.8ms warm, and renderSeats runs after every tap. Three things this got wrong on the way, all caught by measuring rather than reading: The badge overhang was folded into the space between two seats. That collapsed the ring on a phone held sideways — 87px of height, and 18px of badge is most of the room the ring has to move in — until a six-handed landscape table was unsatisfiable at any width. It didn't belong there: the badge points *away* from the table at both ends, so two seats facing each other across the felt wear them back to back. box-sizing: border-box, which the puck needed so the searched number means the width it is drawn at, quietly turned every min-height from a content height into a drawn one. An empty chair went from a 52px tap target to a 42px one on every phone in the sweep. The audit found it; the four min-heights have each gained the padding and border they used to be quoted without. The new audit check reported a name under a neighbour on a table whose seats were 9px apart. A puck's name is a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every line the text *would* have taken, including the ones the clamp throws away — so the probe was hit-testing 30px below the puck, where nothing is painted. It clips to what is painted now. uiAudit gains "no seat covers another seat's writing", the complement of the check already there for a puck's own decoration on its own name — which is why nothing caught this: seatLabelsClear() only ever looked inside one seat. Fault-injected against the previous version: eighteen configurations, naming both causes. And the sweep itself now runs two table shapes. It had run one for its whole life — five players at a nine-handed table — and how crowded the felt is depends on how many people are sitting down far more than on anything a viewport can report. A full table was never once looked at. 632 tests, 0 failures. 190 audits across thirteen viewports at two table shapes, with a mouse and with a thumb. Every table from two seats to twelve now keeps its own text on every viewport in the sweep. One configuration still reports: a dozen players on a 640x360 phone, where the felt is 87px tall and two rows of 55px pucks cannot both fit and stay on the rail — the ring grows 8px past the edge to keep the seats off each other, which is the trade the old code named and got right. Left reported rather than tuned away. Names on a crowded phone table truncate harder than they did, because the puck is now sized to fit instead of allowed to overlap: "Christopher" is "Christ…" at nine seats on an iPhone 14. The full name is in the DOM, in the seat's form and in the sidebar. The next lever on that is the seat layer's inset, which is still a fixed 44px of "half a puck" for a puck that is no longer 96px wide — worth 54px to 60px where it was measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 91 ++++-- .../wwwroot/js/casino/holdem/render.js | 264 ++++++++++++++---- tests/holdem/README.md | 16 +- tests/holdem/designTokens.test.js | 2 +- tests/holdem/runUiAudit.js | 89 +++--- tests/holdem/seatFootprint.test.js | 174 ++++++++++++ tests/holdem/uiAudit.js | 72 +++++ 7 files changed, 592 insertions(+), 116 deletions(-) create mode 100644 tests/holdem/seatFootprint.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index aca2131..e417dea 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -619,8 +619,22 @@ button, .seat { position: absolute; transform: translate(-50%, -50%); - width: 96px; - min-height: 54px; + /* Set by render.js, which measures the widest puck that lets every seat keep its + own box on this particular felt. The 96px is what a table gets before the first + measurement lands, and what it keeps whenever there is room for it. + + border-box so the number means the width the puck is drawn at. As content-box it + did not: a 64px puck measured 74 across once its padding and border were counted, + and the search that sets this compares it against distances on the felt. */ + box-sizing: border-box; + width: var(--seatPuckW, 96px); + /* Every min-height on a seat is a drawn height now, border and padding included, + because of the box-sizing above. They were content heights before, so each one + here and in the three blocks that override it has gained the 8px of padding and + 2px of border it used to be quoted without — the puck is the size it always was. + Caught by the audit rather than by reading: an empty chair went from a 52px tap + target to a 42px one on every phone in the sweep, which is under the floor. */ + min-height: 64px; background: linear-gradient(160deg, var(--surface-3), #1b1b1b); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--r-lg); @@ -972,14 +986,29 @@ button, is marked anyway and has the side benefit of making the two badges far easier to tell apart at speed — light-on-blue against dark-on-gold, rather than two pale labels differing only in hue. */ +/* All three sit in the same place, and inside the puck's own width. + + They used to be spread along the top edge — the small blind at -6px, so hanging off + the left corner, the big blind at 22px — and that cost twice over. The overhang put + the badge onto whatever was to the left of the seat: measured on an iPhone Pro Max at + six seats, where the pucks were not touching at all, "SB" was painted across the next + player's $1,137. And two badges in two different places means the dealer's eye has to + find which of two spots this seat is using before it can read which blind this is, + forty times a night. A seat only ever wears one of these — the small and the big + blind are different players by construction, and the straddle chain starts left of + the big blind and stops short of the small one — so there was never anything for the + second position to avoid. + + Left rather than centred because the dealer button's chip owns the top-right corner, + and the two are read together: who has the button, and who is posting. */ .sbBadge { - left: -6px; + left: 2px; background: linear-gradient(180deg, #2f6cb5, #22508c); color: #eaf3ff; } .bbBadge { - left: 22px; + left: 2px; background: linear-gradient(180deg, #e6ad4d, #c98a2a); color: #241a05; } @@ -992,7 +1021,7 @@ button, looking for mid-hand is the one telling them where the action comes back to. Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */ .strBadge { - left: 22px; + left: 2px; background: linear-gradient(180deg, #cbaaf0, #a97fd6); color: #1b1030; } @@ -1004,22 +1033,22 @@ button, outline-offset: 1px; } -/* Seats close enough together that full-size pucks would overlap. The class is set - from a measurement render.js takes off the ring itself — the closest two neighbours, - straight line — because no media query can see it: the same 390px phone is roomy at - six seats and crowded at nine, and a wide landscape felt crowds at its ends while - its rim says there is space. Not scoped to a screen size for the same reason. */ +/* Seats close enough together that a puck this narrow can't carry the full layout. The + class is set from a measurement render.js takes off the ring itself, because no media + query can see it: the same 390px phone is roomy at six seats and crowded at nine, and + a wide landscape felt crowds at its ends while its rim says there is space. Not scoped + to a screen size for the same reason. + + It used to carry the width too — 58px, against the roomy layout's 96 — and those two + numbers were the whole vocabulary the felt had. A table that needed 68 got 96 and + overlapped, or got 58 and threw away ten pixels of name it could have kept. The width + is now searched for and set as a custom property; what's left here is the typography, + which does still want to change in one step rather than slide. */ .seatsTight .seat { - /* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46 - cleared every overlap at nine seats where 58 left two — but in the state that - actually matters, a hand with a side pot where the bar has grown and the felt has - shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that - density is the felt being small, not the puck being wide, and paying for it with - an ellipsis on every long name buys nothing. What has to hold is that each seat - keeps its own centre, so a tap lands on the player it was aimed at, and that is - measured at every size. */ - width: 58px; - min-height: 38px; + /* 38px of content plus 6 of padding and 2 of border, which is what this drew before + the box became a border-box — and 46 clears the 44px a chair has to answer a thumb + with. A seat is a control: tapping it opens that player's form. */ + min-height: 46px; padding: 3px 4px; } @@ -3507,9 +3536,12 @@ body.hasActionBar #toastHost { --feltAspect: 1; } + /* No width here any more. This was 78px, and two blocks below it were 64 and 62 — + three guesses, at three breakpoints, at the one number that depends on something + a media query cannot see: how many people are sitting down. The same 390px phone + has room for a 90px puck at four seats and 61px at nine. render.js measures it. */ .seat { - width: 78px; - min-height: 44px; + min-height: 54px; } } @@ -3566,12 +3598,11 @@ body.hasActionBar #toastHost { inset: min(26px, 12%) 44px; } - /* Narrower than the desktop puck: at a full ten-handed table the ring is - flattest at the top and bottom, and wide pucks are what made neighbours - overlap there. The puck is only a summary — the real form is the sheet. */ + /* The puck is only a summary — the real form is the sheet — so a phone gives it + tighter padding and a shorter floor. How *wide* it gets is measured, not declared + here: see the note on the 900px block above. */ .seat { - width: 64px; - min-height: 42px; + min-height: 52px; padding: var(--sp-1) var(--sp-1); } @@ -3930,10 +3961,10 @@ body.hasActionBar #toastHost { display: none; } - /* Nine seats around a table this small overlap each other, so they shrink to suit. */ + /* Seats around a table this small overlap each other, so they shrink to suit — by + measurement now rather than by this block's old guess of 62px. */ .seat { - width: 62px; - min-height: 40px; + min-height: 50px; } /* The same inset the phone layout uses, which this branch never had because it is diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 2e0247f..b0a084e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -169,7 +169,10 @@ const Render = { // 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() { + // `puckSize` is the footprint the caller has just measured, badges included. Without + // one this falls back to the first chair on the felt, which is what it always did — + // and which is wrong whenever some other chair is the taller one. + seatRingRadii(puckSize = null) { const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY }; if (typeof document === "undefined") return fallback; @@ -179,7 +182,7 @@ const Render = { if (!box || !box.width || !box.height || !table || !puck) return fallback; const tableBox = table.getBoundingClientRect(); - const puckBox = puck.getBoundingClientRect(); + const puckBox = puckSize || puck.getBoundingClientRect(); if (!tableBox.width || !puckBox.width) return fallback; // Worked in absolute pixels rather than from half-widths, because the seat @@ -364,32 +367,35 @@ const Render = { // come down a size rather than the table pretending it has room it hasn't. SEAT_ROOM_PX: 84, - // 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. - // 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. + // The range a puck may be drawn at. The bottom of it is where a name stops being a + // name: at 44px the tight layout shows about five characters and an ellipsis, which + // is still "Chris…" rather than nothing. + SEAT_WIDTH_MAX: 96, + SEAT_WIDTH_MIN: 44, + // The step the search walks down in. Six pixels is finer than the difference between + // two font sizes and coarse enough to settle in ten tries. + SEAT_WIDTH_STEP: 6, + // Below this a puck cannot hold two lines of name and a row of hole cards, so the + // typography drops to the tight set in one go. Width scales continuously; type + // changes deliberately, which is the split the modern guidance actually draws — + // clamp for the continuum, a query for the things meant to be discrete. + SEAT_TIGHT_BELOW_PX: 76, + // Daylight between two pucks. They are rounded, so touching reads as overlapping. + SEAT_CLEARANCE_PX: 4, + // How far a blind badge hangs off the puck it belongs to — the `top: -9px` and + // `bottom: -9px` in holdem.css. It points away from the table at both ends, so it + // takes no room between two seats; what it must not do is reach *sideways*, which is + // what seatFootprint.test.js holds the stylesheet to. + SEAT_BADGE_OVERHANG_PX: 9, + // 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. + // Kept for closestSeatGapPx's sake and no longer what decides the layout. It was a + // proxy — a single distance standing in for "do these two boxes want the same + // pixels" — and the answer it gave was wrong in both directions: a 96px puck was + // allowed within 84px of its neighbour on the theory that rounded corners forgive a + // little overlap, and the overlap it forgave turned out to be 20px of a stack + // figure. seatsCollide asks the real question instead. SEAT_ROOM_RATIO: 84 / 96, seatRoomNeededPx() { @@ -400,34 +406,198 @@ const Render = { return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX; }, + // Does any pair of seats want the same pixels? + // + // Two equal axis-aligned boxes overlap exactly when their centres are closer than + // one box in *both* axes, so this answers the question rather than standing in for + // it. The distance test it replaces could not: distance has one number in it and + // the pucks are half again as wide as they are tall, so the same 84px was generous + // sideways and nowhere near enough on the diagonal. + // + // Every pair, not only neighbours — this is the half that was actually missing. + // On a felt 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 rather than the one + // beside you. Measured on the running page before this went in: ten of the tables + // in the sweep had a collision between two seats that a neighbours-only check + // cannot see, five of them at 640x360, where seats 1 and 7 overlapped by 68px. + seatsCollide(count, box, radii, width, height, clearance = this.SEAT_CLEARANCE_PX) { + if (count < 2 || !box || !box.width || !box.height) return false; + const room = { x: width + clearance, y: height + clearance }; + 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, + })); + for (let a = 0; a < points.length; a++) { + for (let b = a + 1; b < points.length; b++) { + if (Math.abs(points[a].x - points[b].x) < room.x && + Math.abs(points[a].y - points[b].y) < room.y) return true; + } + } + return false; + }, + + // The box a seat actually takes up, at whatever width is applied right now. + // + // Widest and tallest are taken separately and across every chair, because a + // two-line name makes one puck taller than its neighbours and the ring has to have + // room for the worst of them, not the first one it measures. + measuredSeatBox() { + if (typeof document === "undefined") return null; + const seats = document.querySelectorAll("#seatsLayer .seat"); + if (!seats.length) return null; + let width = 0, height = 0; + seats.forEach(seat => { + const box = seat.getBoundingClientRect(); + width = Math.max(width, box.width); + height = Math.max(height, box.height); + }); + return width && height ? { width, height } : null; + }, + + // What two seats have to clear each other by, which is the puck and nothing else. + // + // The blind badge was the reason to think otherwise, and it was worth chasing: "SB" + // was painted across the next player's $1,137 on an iPhone Pro Max whose pucks were + // not touching at all, so something was reaching past a puck's own box. + // + // It was reaching sideways. The badge sat at `left: -6px`, hanging off the puck's + // left corner onto whatever was beside it, and that is fixed where it was caused — + // in the stylesheet, by moving the badges inside the puck's own width. + // + // Its remaining overhang is vertical and costs nothing, because it always points + // *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower + // one, so two seats facing each other across the felt wear their badges back to + // back. Two seats on the same arc wear them at the same height, offset by exactly + // as much as the pucks are. + // + // Adding it anyway was tried first, and it broke the case it was least expected to: + // a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave + // 18px between them, and demanding 18px more for badges that point outward made a + // six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three + // phones went the same way. The clearance below is what keeps rounded corners from + // reading as a touch. + seatFootprint(puckBox) { + return puckBox && { width: puckBox.width, height: puckBox.height }; + }, + + applySeatWidth(layer, width) { + layer.style.setProperty("--seatPuckW", `${width}px`); + layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX); + }, + + // Widest first, because the widest that fits is the one we want. + seatWidthCandidates() { + const widths = []; + for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) { + widths.push(w); + } + widths.push(this.SEAT_WIDTH_MIN); + return widths; + }, + + // What the table has changed about the shape of its pucks. Anything that can make a + // name wrap to a second line belongs here, because the height that comes back from + // measuring is what the ring is sized against. + seatShapeSignature() { + if (!this.state) return ""; + return `${this.state.seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}`; + }, + + // Which width the pucks get, and how wide a ring they sit on. + // + // This used to choose between two numbers — 96px or 58px — and when neither fitted + // it widened the ring and let the seats overlap, on the reasoning that a dealer can + // see an overhang but cannot see a tap landing on the wrong player. That trade was + // real and it was also a false choice, because the tables that overlapped did not + // want either number. Measured across the sweep at nine seats: a Galaxy S8 had room + // for a 61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. + // Handing all four the same 58 or the same 96 is how a puck ends up twenty pixels + // inside its neighbour with a stack figure underneath it — which is what the running + // page did, on every phone in the list, at the most ordinary table there is. + // + // So the width is searched for rather than chosen. There is a width that works on + // every table in the sweep, down to a dozen seats on the smallest phone: the code + // was never asking for it. + // + // Walked from the top rather than bisected. Feasibility is *nearly* monotone — a + // narrower puck sits on a wider ring and has a smaller box — but not quite, because + // a narrower puck can wrap a name onto a second line and come back taller. Walking + // down takes the widest that actually measures clean, which is the honest answer; + // bisection would take the widest that a model of the puck says is clean. + // + // Measuring is the expensive half — each width has to be applied and read back, and + // that is a forced layout apiece — so it happens once per width, up front. Which + // ring each width then sits on is arithmetic on numbers already in hand. + // + // The ring is allowed to grow past the one that keeps every seat on the felt, but + // only after every width has been tried on the contained one. That order is the + // trade the old code named and got right: a dealer can see a puck over the rail; + // they cannot see that the seat they pressed wasn't the one that answered. It only + // ever comes up on a phone held sideways, where the felt is a 692x117 slot. + RING_GROWTH_STEPS: 8, + resolveSeatLayout(count) { const layer = document.getElementById("seatsLayer"); 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 }; + const box = this.ringBox(); + const key = box && + `${Math.round(box.width)}x${Math.round(box.height)}:${count}:${this.seatShapeSignature()}`; + // Ten candidate widths mean ten forced layouts, and renderSeats runs after every + // tap a dealer makes. Nothing in the key changes between one tap and the next + // unless the table itself has, so within a hand this is measured once and read + // back afterwards. + if (key && this.seatLayoutCache && this.seatLayoutCache.key === key) { + this.applySeatWidth(layer, this.seatLayoutCache.width); + return this.seatLayoutCache; } - // 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 }; + const candidates = []; + for (const width of this.seatWidthCandidates()) { + this.applySeatWidth(layer, width); + const puck = this.measuredSeatBox(); + if (!puck) { + // Nothing laid out yet — the very first render. Take the widest and let + // the resize that follows do this properly. + const radii = this.seatRingRadii(); + if (key) this.seatLayoutCache = { key, width, radii }; + return { width, radii }; + } + candidates.push({ + width, + contained: this.seatRingRadii(puck), + footprint: this.seatFootprint(puck), + tooTall: this.tooTallForTheTable(), + }); } - return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } }; + + const fits = (candidate, radii) => !candidate.tooTall && + !this.seatsCollide(count, box, radii, candidate.footprint.width, candidate.footprint.height); + + let chosen = null; + for (let step = 0; step <= this.RING_GROWTH_STEPS && !chosen; step++) { + const t = step / this.RING_GROWTH_STEPS; + for (const candidate of candidates) { + const radii = { + rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t, + ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t, + }; + if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; } + } + } + + // Nothing cleared at any width on any ring. Keep the narrowest puck on the + // widest ring, and let the audit's "no seat covers another seat's writing" say + // so out loud rather than the layout quietly papering over it. + if (!chosen) { + const last = candidates[candidates.length - 1]; + chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } }; + } + + this.applySeatWidth(layer, chosen.width); + if (key) this.seatLayoutCache = { key, ...chosen }; + return chosen; }, updateSeatDensity() { diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 9b2a211..c78bb30 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -33,10 +33,18 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large enough not to trigger iOS zoom, that nothing important is covered or off-screen with -no way to scroll to it, that no grid column has collapsed to nothing, and that no -text is painted outside its box. It runs over the table, all five overlays and Quick -Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same -phones on their side — each viewport twice, once with a mouse and once with a thumb. +no way to scroll to it, that no grid column has collapsed to nothing, that no +text is painted outside its box, and that no seat covers another seat's writing. It +runs over the table, all five overlays and Quick Split, at thirteen viewports from a +360px phone to a 1600px desktop and the same phones on their side — each viewport +twice, once with a mouse and once with a thumb, and at two table shapes. + +Two table shapes is also newer than it sounds. The sweep ran on one — five players at +a nine-handed table — for its whole life, and how much room the felt has depends on +how many people are sitting down far more than on anything a viewport can report. A +full table is where the seats crowd and it had never been looked at: the first sweep +that did found one player's name or stack painted under another on eighteen of the +configurations, on every phone in the list. That last part, and two of those checks, are newer than this paragraph claiming them. `runAll()` did not include the contrast check at all, and touch targets sat behind an diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index 6dacae8..205ea46 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -104,7 +104,7 @@ describe("the tokens themselves", () => { test("every var() the stylesheet reaches for exists", () => { const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4))); // Published from JS at runtime rather than declared here — see actionBar.js. - const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]); + const fromScript = new Set(["--actionBarHeight", "--feltRoomPx", "--seatPuckW"]); // Defined by the site's own stylesheet, not this one. const fromSite = new Set(["--font-mono"]); const missing = [...used].filter(t => diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index 08090bd..bb8fae0 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -34,23 +34,37 @@ const OVERLAYS = [ ]; // A table part-way through a real night: long names, uneven stacks, a hand log. -function seedTable() { +// +// Two of them. The sweep ran on one table shape for its whole life — five players at a +// nine-handed table — and how much room the felt has depends on how many people are +// sitting down more than on anything a viewport can tell you. A full table is where the +// seats crowd, and it was never once looked at: measured when it finally was, seventeen +// of fifty-four tables had one player's name or stack painted under another. +const TABLES = [ + ["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], + ["Bob", 250], ["Dee", 600]]], + ["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], + ["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480], + ["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]], +]; + +function seedTable([seats, players]) { const state = HoldemState.createDefault(); - HoldemState.resizeSeats(state, 9); - [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]] - .forEach(([name, cash], i) => { - state.seats[i].name = name; - state.seats[i].inHand = true; - state.seats[i].buyIns = cash; - state.seats[i].stack = cash; - }); + HoldemState.resizeSeats(state, seats); + players.forEach(([name, cash], i) => { + state.seats[i].name = name; + state.seats[i].inHand = true; + state.seats[i].buyIns = cash; + state.seats[i].stack = cash; + }); state.smallBlind = 5; state.bigBlind = 10; for (let hand = 1; hand <= 4; hand++) { state.handHistory.push({ handNumber: hand, timestamp: Date.now(), pot: 120, - payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }], - ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })), + payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }], + ledger: state.seats.slice(0, players.length) + .map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })), }); } state.handNumber = 5; @@ -85,33 +99,40 @@ async function main() { const hand = touch ? "touch" : "mouse"; for (const [width, height, rawLabel] of VIEWPORTS) { - const label = `${rawLabel} · ${hand}`; await page.setViewportSize({ width, height }); - await page.goto(`${BASE}/Casino/TexasHoldem`); - await page.evaluate(seedTable); - await page.goto(`${BASE}/Casino/TexasHoldem`); - await page.click("#nextHandBtn"); - await page.waitForTimeout(300); - await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; })); - await page.addScriptTag({ path: AUDIT }); + for (const [shape, seats, players] of TABLES) { + const label = `${rawLabel} · ${shape} · ${hand}`; + await page.goto(`${BASE}/Casino/TexasHoldem`); + await page.evaluate(seedTable, [seats, players]); + await page.goto(`${BASE}/Casino/TexasHoldem`); + await page.click("#nextHandBtn"); + await page.waitForTimeout(300); + await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; })); + await page.addScriptTag({ path: AUDIT }); - const table = await page.evaluate(() => LayoutAudit.runAll()); - checks++; - if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); - table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); - - for (const [name, open] of OVERLAYS) { - await page.evaluate(open); - await page.waitForTimeout(180); + const table = await page.evaluate(() => LayoutAudit.runAll()); checks++; - const result = await page.evaluate(() => { - const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden); - if (!overlay) return { offenders: [] }; - return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`); - }); - await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; })); - if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders }); + if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); + table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); + + // The overlays don't change with the table's shape, so they are only + // opened over the half-full one — twice the viewports is enough time + // spent already. + if (shape !== TABLES[0][0]) continue; + + for (const [name, open] of OVERLAYS) { + await page.evaluate(open); + await page.waitForTimeout(180); + checks++; + const result = await page.evaluate(() => { + const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden); + if (!overlay) return { offenders: [] }; + return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`); + }); + await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; })); + if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders }); + } } } diff --git a/tests/holdem/seatFootprint.test.js b/tests/holdem/seatFootprint.test.js new file mode 100644 index 0000000..beeec27 --- /dev/null +++ b/tests/holdem/seatFootprint.test.js @@ -0,0 +1,174 @@ +// 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(/(? !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(/(?= 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(/(? { + test("nothing here throws", () => { + assert.equal(Render.measuredSeatBox(), null); + assert.equal(Render.seatFootprint(null), null); + assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 }); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 8a80799..0f9e8e6 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -777,6 +777,77 @@ const LayoutAudit = { return { name: "seat names aren't painted under their own badges", offenders }; }, + // A seat's writing, covered by something belonging to a different seat. + // + // seatLabelsClear() above asks whether a puck's own decoration sits on its own name. + // This asks the other half, and the other half was where the tool was actually + // failing: measured across nine viewports and six table sizes before this went in, + // seventeen of fifty-four tables had one seat's name or stack painted under a + // neighbour. On a Galaxy S8 at the most ordinary table there is — six players at a + // nine-handed table — seat 2's stack was under seat 3's puck, and five pairs of + // seats overlapped, the worst by 61px. + // + // Two ways in, and it takes both: + // - the pucks themselves overlapping, which the layout used to permit on the + // reasoning that rounded corners forgive a little; what they were forgiving was + // a four-figure stack. + // - a blind badge hanging off its puck onto the seat next door, which no amount + // of space between the pucks fixes on its own. + // + // Hit-tested against the glyph run rather than the element box. A name is centred, + // so its box is wider than its ink and testing the box would fail on empty air. + // + // The glyph run is then clipped back to what is actually painted, which is not the + // same thing and cost this check its first honest-looking failure. A puck's name is + // a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every + // line the text *would* have taken — including the two that the clamp throws away. + // Measured at 640x360 with twelve players: "Mike from work" reported a line 30px + // below its own puck, which duly hit-tested onto the seat underneath, and the seats + // were not touching at all. Nothing is painted there, so nothing can cover it. + seatsKeepTheirText() { + const name = "no seat covers another seat's writing"; + if (this.modalScope()) { + return { name, offenders: [], skipped: "a sheet is open over the table" }; + } + const seats = [...document.querySelectorAll(".seat")]; + if (!seats.length) return { name, offenders: [], skipped: "no seats on this page" }; + + const offenders = []; + seats.forEach((seat, index) => { + seat.querySelectorAll(".seatPuckName, .seatPuckStack, .seatPuckTotal").forEach(label => { + const text = label.textContent.trim(); + if (!text) return; + const painted = label.getBoundingClientRect(); + if (!painted.width || !painted.height) return; + const range = document.createRange(); + range.selectNodeContents(label); + [...range.getClientRects()].forEach(raw => { + const line = { + left: Math.max(raw.left, painted.left), + right: Math.min(raw.right, painted.right), + top: Math.max(raw.top, painted.top), + bottom: Math.min(raw.bottom, painted.bottom), + }; + if (line.right - line.left <= 1 || line.bottom - line.top <= 1) return; + // Along the line rather than at its middle: a badge takes the start + // of it and a neighbouring puck the end, and a single centre probe + // walks past both. + for (let across = 0.08; across <= 0.92; across += 0.14) { + const x = line.left + (line.right - line.left) * across; + const y = (line.top + line.bottom) / 2; + const over = document.elementFromPoint(x, y); + if (!over || seat.contains(over) || over.contains(seat)) continue; + offenders.push( + `seat ${index + 1} "${text}" is under ${this.describe(over)}`); + return; + } + }); + }); + }); + + return { name, offenders }; + }, + // ---- Runner ---- // Every check this file knows how to make, every time. @@ -897,6 +968,7 @@ const LayoutAudit = { this.oneDealerButton(), this.hiddenButPainted(), this.seatLabelsClear(), + this.seatsKeepTheirText(), this.contrast(options.contrast), this.touchTargets(options.minimumTarget, options.touchTargets), ]; -- 2.43.0 From 2a080eab6ace2e92beb162da7e0c69e6e44a4eb0 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 17:05:47 -0600 Subject: [PATCH 2/3] Give every bet on the felt somewhere to be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chips were the last thing on the table nobody had measured, and the sweep had never seen one: it audited a table that had never had a bet placed on it, so a third of what is on the felt during a hand went unlooked at for the tool's whole life. It posts blinds, deals and calls three times now before it measures anything. What that found, and what this fixes: - Chips accumulated. Seats are kept and updated in place rather than rebuilt, so clearing the bets layer stopped clearing the ones anchored inside a seat and nothing else ever did. An iPhone 14 had one chip per seat after the deal and five by the third render; a dealer's every tap left another disc on the felt. - The ring-or-cling decision asked how wide the window was. A phone held sideways is 844px across and gives the felt 117px: it passed the width test, took the ring, and every chip on it was painted under a puck — the seats carry z-index 1 and the bets layer carries none, so a chip that lands under one is gone, and with it the only number saying what that player has in. Measured now, and only measured. - Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only straight up, down or sideways. Two axis-aligned boxes are clear when they are apart on one axis, so the travel is the reach of the box you get by adding their widths and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and got 77, and came to rest 11px inside its own player's puck — every seat off the four compass points, which is most of them. - Every chip was laid out at the first chip's size. A chip is as wide as the number on it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were 6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the whole table give up a ring the other four had room for. - Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck is 55 of those pixels tall: clearing it takes 41px, the players facing each other are 70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping pairs on a twelve-handed table. Each chip now gives ground only to something it is actually standing on, a pixel at a time — a midline rule or a single scale factor both punish the seats at the ends of a rail, which were never going to hit anything. - Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65". Two things off the felt, from the same sweep: - The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while the hand was dealt and three players called: an offer to move the button, wired to a snapshot that predates the hand, so taking it would have thrown the hand away without saying so. It was also in the way of thirty small screens' seats and controls. The bar drops it as it takes the next action; the bar's own Undo names what it would actually take back. - The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows visible, the third below a hairline that reads as the end of the list — a dealer ticking the two they could see would have paid two thirds of a three-way pot. The 85vh budget was still subtracting the action bar's height from a dialog that has sat above the bar since its z-index was fixed, and a player's ceiling moves beside their name on a short screen. All three fit now, and where a list does run on, it runs on under a fade rather than stopping dead on a row boundary. The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the action bar prints three inches below it, standing in the only band a bet chip has. The audit stopped calling one thing another. An element clipped away by a panel that scrolls is not covered by whatever is painted at that height — the wizard's third winner came back for four viewports as "fully covered by button Confirm Winner(s)". 190 audits: 25 failing configurations to 7, and every one of those seven is the seat ring hanging past the rail on a landscape phone, which is the known residual from the last round. 663 unit tests, 44 of them new. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 94 +++- .../wwwroot/js/casino/holdem/actionBar.js | 13 + .../wwwroot/js/casino/holdem/potWizard.js | 33 ++ .../wwwroot/js/casino/holdem/render.js | 371 +++++++++++++- tests/holdem/README.md | 14 +- tests/holdem/betChips.test.js | 473 ++++++++++++++++++ tests/holdem/designTokens.test.js | 8 +- tests/holdem/runUiAudit.js | 21 + tests/holdem/staleToast.test.js | 106 ++++ tests/holdem/uiAudit.js | 94 ++++ 10 files changed, 1192 insertions(+), 35 deletions(-) create mode 100644 tests/holdem/betChips.test.js create mode 100644 tests/holdem/staleToast.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index e417dea..a19d583 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -597,23 +597,39 @@ button, font-variant-numeric: tabular-nums; } -/* Clinging to a puck rather than sitting on its own ring — the phone layout. */ +/* Clinging to a puck rather than sitting on its own ring — the phone layout. + + It has to fit the puck it is clinging to, which it did not: measured on a Galaxy S8, + the puck was 48px across and every one of the five chips on the felt was 50, so each + one hung over both edges of its own seat and three of them reached onto the seat next + door. The same fault the small blind badge had, on the other thing that hangs off a + puck — and the one that carries a number rather than two letters. + + The disc is what gives way. It is 13px of decoration plus a 4px gap next to a figure + the dealer has to read, and dropping it takes the chip from 50px to 33 — inside the + narrowest puck the layout will ever draw. Its colour moves to the chip's own rim, so + the denomination the legend assigns is still there at no width at all. Same trade the + tight puck already makes with the hole cards. */ .betChipAnchored { left: 50%; z-index: 3; + max-width: 100%; + padding: var(--sp-0) var(--sp-1); + border-color: var(--betChipColour, rgba(255, 255, 255, 0.14)); } -.betChipAbove { +.betChipAnchored .betChipDisc { + display: none; +} + +/* Where along the puck's edge it lands is set by render.js, which walks the seat's own + spoke out to just past the puck — see anchoredChipOffset(). The `top` here is only what + it wears before that lands, and on the one path where there is nothing to measure. */ +.betChipAnchored { top: -11px; transform: translate(-50%, -50%); } -.betChipBelow { - top: auto; - bottom: -11px; - transform: translate(-50%, 50%); -} - /* ---------- Seats (compact pucks on the table) ---------- */ .seat { @@ -3207,13 +3223,19 @@ body.hasActionBar #toastHost { background: var(--surface-1); border: 1px solid var(--line); border-radius: var(--r-md); + /* So the fade over the bottom of the list has something to hang off. */ + position: relative; width: 100%; max-width: 480px; /* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over the bottom of it — so the last of the wizard sat underneath the bar and its controls couldn't be tapped. Leaving the bar's own height out of the budget - keeps the whole dialog above it. */ - max-height: calc(85vh - var(--actionBarHeight, 0px)); + keeps the whole dialog above it. + That was true while this overlay sat at z-index 50, below the bar's 55. It sits + at 95 now and the bar cannot draw on it, so the subtraction buys nothing and + costs 110px of a 640px screen — which on a Galaxy S8 is the difference between + a three-way pot showing all three names and showing two of them. */ + max-height: 85vh; /* A column of three: the title, the part that scrolls, and the buttons. The modal itself no longer scrolls — only its middle does — which is what keeps Confirm on screen without asking a sticky footer to do something it cannot. */ @@ -3239,8 +3261,29 @@ body.hasActionBar #toastHost { the same full-width treatment as one that appends a pair — the winner step used to put a lone Confirm in the left half of the dialog, looking like one option out of several that hadn't drawn yet. */ +/* There is more list above the buttons than the dialog can show. + + Measured on a Galaxy S8: three players are in the pot, the list holds three rows, and + two of them fit. The third was cut off exactly on a row boundary, immediately under a + hairline that reads as the end of the list — so the dialog that pays the table looked + finished with a name missing from it, and a dealer ticking the two they could see + would have paid two thirds of a three-way pot. The list does scroll; nothing said so. + Now the cut runs through a row rather than between them, under a fade, which is what + a list that continues looks like. */ +#potWizardModal.potWizardMoreBelow #potWizardFooter::before { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 100%; + height: 2.25rem; + background: linear-gradient(rgba(30, 30, 30, 0), var(--surface-1) 85%); + pointer-events: none; +} + #potWizardFooter { flex: none; + position: relative; display: flex; gap: var(--sp-2); margin-top: var(--sp-4); @@ -3961,6 +4004,20 @@ body.hasActionBar #toastHost { display: none; } + /* The same trade, for the pot total. Measured on a phone held sideways, the badge is + 86x66 on a felt that is 692x113 — it takes 58% of the table's height for a number + the action bar is already printing three inches below it, in the line that starts + "PRE-FLOP Pot $65". Two seat pucks are 55px tall and the felt has to hold two rows + of them, so the badge is not overlapping some empty middle: it is standing in the + only band a player's bet chip has to go in, and the bottom-middle seat's chip had + nowhere left to be put but on top of its own player's name. + + Upright it stays. There the felt is 248x280, the badge is a fifth of it, and it is + the only thing in the middle of the table. */ + #potBadge { + display: none; + } + /* Seats around a table this small overlap each other, so they shrink to suit — by measurement now rather than by this block's old guess of 62px. */ .seat { @@ -4120,6 +4177,23 @@ textarea:focus-visible { here so the note costs a line rather than a paragraph — the bar is a transient input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */ @media (max-height: 700px) { + /* And the same economy for the pot dialog's list of winners, which is the other + place on a short screen where a line costs a name. The player's ceiling moves + from under their name to beside it: two lines become one, a row goes from 78px + to 56, and a three-way pot fits without scrolling on a 640px phone. The row is + still 56px, so it is still a target a thumb can hit — this is where the money is + decided and that floor does not move. */ + .potWizardChoiceText { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-2); + } + + .potWizardChoiceUpTo { + flex: none; + } + .raiseNote { margin-top: var(--sp-1); font-size: var(--fs-xs); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 0c3a31f..8978efe 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -611,6 +611,19 @@ const ActionBar = { // recorded so it can be taken back. A misclick on Fold is the one mistake in this // tool a dealer can't talk their way out of. commit(label, mutate) { + // A toast is news about the last thing that happened, and this is the next + // thing: whatever it says is now out of date. Two reasons it goes rather than + // waiting out its eight seconds. It lies — "Next hand — button on seat 2. + // [Undo]" reads as an offer to move the button back, but the snapshot behind + // that Undo predates the hand you have since dealt, so taking it would throw + // the hand away without saying so. And it is in the way: measured across the + // sweep, a toast left up over a dealt table covered seats, the clock button + // or the bar's own Undo on thirty of the small screens it was checked at, + // because there is nowhere on a 360x640 phone for a 560px strip to go. The + // bar's own Undo control takes over from here, and it names the step it + // would actually take back. + if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); + this.hooks.beginAction(label); mutate(); this.hooks.save(this.state); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index b1c182b..bd5107b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -76,6 +76,39 @@ const PotWizard = { }, renderStep() { + this.renderStepInto(); + this.markOverflow(); + }, + + // Say when the list runs on past the bottom of the dialog. + // + // The body scrolls and the footer stays put, which is the right shape and was as far + // as it went: nothing on screen said there was anything below. Measured on a Galaxy + // S8, a three-way pot showed two names and cut the third off on a row boundary, just + // under a hairline that reads as the end of the list. A dealer would have ticked the + // two they could see. On the dialog that pays the table, a list that silently + // continues is not a small thing. + markOverflow() { + const body = document.getElementById("potWizardBody"); + const modal = document.getElementById("potWizardModal"); + if (!body || !modal) return; + + const update = () => { + const below = body.scrollHeight - body.clientHeight - body.scrollTop; + modal.classList.toggle("potWizardMoreBelow", below > 1); + }; + if (!this.watchingOverflow) { + body.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + this.watchingOverflow = true; + } + update(); + // Again once the browser has laid the step out — on the first open the body has + // no height yet, and an answer taken then is always "nothing below". + requestAnimationFrame(update); + }, + + renderStepInto() { // Auto-resolve any round where only one player is left standing — nothing to ask. let contenders = PotCalculator.getContenders(this.entries); while (contenders.length === 1) { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index b0a084e..b8f9ae8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -241,14 +241,42 @@ 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(), radii = this.seatRingRadii()) { + betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii(), spacing = null) { 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. + if (!spacing || !spacing.box || !spacing.box.width || !spacing.box.height) { + // 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. This is what the + // ring always did, and it is still the answer when there is nothing to + // measure: every call from the unit suite, and the first render. + return { + left: 50 + (seat.left - 50) * (27 / 44), + top: 50 + (seat.top - 50) * (25 / 42), + }; + } + + // With measurements to hand, the chip is placed by clearance rather than by + // proportion. A fixed fraction is a guess about how much room there is between a + // puck's inner edge and the pot, and the answer changes with the shape of the + // felt: on a phone held sideways the ring pulls right in, 27/44 of a small reach + // is inside the puck, and every one of the five chips on the felt was painted + // behind a seat — the seats carry `z-index: 1` and the bets layer carries none, + // so a chip that lands under a puck is simply gone. + const { box, puck, chip, clearance } = spacing; + const dx = ((seat.left - 50) / 100) * box.width; + const dy = ((seat.top - 50) / 100) * box.height; + const reach = Math.hypot(dx, dy); + if (reach < 1) return { left: seat.left, top: seat.top }; + + const ux = dx / reach, uy = dy / reach; + const inset = this.clearanceReach(puck, chip, ux, uy) + clearance; + // Never nearer the middle than this: the pot badge lives there, and a chip on + // top of the pot total is the same fault at the other end of the spoke. + const pulled = Math.max(reach * 0.38, reach - inset); + const scale = pulled / reach; return { - left: 50 + (seat.left - 50) * (27 / 44), - top: 50 + (seat.top - 50) * (25 / 42), + left: 50 + ((dx * scale) / box.width) * 100, + top: 50 + ((dy * scale) / box.height) * 100, }; }, @@ -382,6 +410,8 @@ const Render = { SEAT_TIGHT_BELOW_PX: 76, // Daylight between two pucks. They are rounded, so touching reads as overlapping. SEAT_CLEARANCE_PX: 4, + // And between a puck's inner edge and the chip sitting in front of it. + BET_CLEARANCE_PX: 5, // How far a blind badge hangs off the puck it belongs to — the `top: -9px` and // `bottom: -9px` in holdem.css. It points away from the table at both ends, so it // takes no room between two seats; what it must not do is reach *sideways*, which is @@ -681,6 +711,9 @@ const Render = { makeBetChip(bet) { const chip = document.createElement("div"); chip.className = "betChip"; + // The denomination's colour, published for the anchored layout to put on the + // chip's rim — there the disc itself is 17px the puck hasn't got. + chip.style.setProperty("--betChipColour", this.chipColorFor(bet)); const disc = document.createElement("span"); disc.className = "betChipDisc"; @@ -695,41 +728,337 @@ const Render = { return chip; }, + // How far it is from the centre of an axis-aligned box to its edge, in a given + // direction. Exact, and it has to be: a puck is half again as wide as it is tall, so + // a single radius would be wrong on one axis or the other. + edgeReach(width, height, ux, uy) { + const ax = Math.abs(ux), ay = Math.abs(uy); + return Math.min(ax > 1e-6 ? (width / 2) / ax : Infinity, + ay > 1e-6 ? (height / 2) / ay : Infinity); + }, + + // How far one box has to travel from the centre of another, along a given direction, + // before the two are clear of each other. + // + // Not `edgeReach(puck) + edgeReach(chip)`, which is what this used to be and is only + // right when the direction is straight up, down or sideways. Two axis-aligned boxes + // are clear when they are apart on *one* axis, so the distance is the reach of the + // one box you get by adding their widths and adding their heights — and adding the + // two reaches is always less than that. Measured at 768x1024, a $10 leaving a seat on + // the diagonal wanted 88px and was given 77, and the chip came to rest 11px inside + // its own player's puck. Every seat off the four compass points had the same error, + // which is most of them on any table with more than four players. + clearanceReach(a, b, ux, uy) { + return this.edgeReach(a.width + b.width, a.height + b.height, ux, uy); + }, + + // Where an anchored chip sits relative to its own puck's centre: on the spoke, + // just clear of the puck's edge, on the side facing the pot. + // + // This started as four choices — above, below, and the two sides — which is better + // than the one it replaced (always above or below, so a seat at the side of the ring + // wore its bet over its own name) and still wrong, because four choices have three + // seams. Measured on a Galaxy S8: seat 4 is on the right arc and took the left edge, + // seat 5 is on the bottom arc and took the top edge, and at the corner between the + // two arcs their chips overlapped by 11px. The seam is the bug, so there are no + // sides now — the direction is the spoke itself, which is also where a chip actually + // goes. A player on the right of the table pushes their bet to their left. + // Off the rim rather than down the spoke, which are the same direction only on a + // round table. A phone held sideways gives the felt 692x117: the ring is a slot, the + // spoke to a seat on the top arc points diagonally inward, and "inward" there runs + // along the rim straight into the next seat. Off the rim it is very nearly straight + // down, which is where the empty felt is. The inward normal of the ellipse the ring + // is drawn on gives both answers from one expression. + anchoredChipOffset(seatPos, puck, chip, box, radii, clearance = this.BET_CLEARANCE_PX) { + const { x: ux, y: uy } = this.rimNormal(seatPos, box, radii); + const travel = this.clearanceReach(puck, chip, ux, uy) + clearance; + return { x: ux * travel, y: uy * travel }; + }, + + // Straight off the rim, inward: the unit normal of the ellipse the seat ring is + // drawn on, at that seat. + rimNormal(seatPos, box, radii) { + const dx = ((seatPos.left - 50) / 100) * box.width; + const dy = ((seatPos.top - 50) / 100) * box.height; + const a = Math.max(1, (radii.rx / 100) * box.width); + const b = Math.max(1, (radii.ry / 100) * box.height); + const nx = -dx / (a * a); + const ny = -dy / (b * b); + const length = Math.hypot(nx, ny); + // A seat sitting on the middle of the felt has no rim to be off. Send its chip + // upward rather than dividing by nothing. + if (length < 1e-9) return { x: 0, y: -1 }; + return { x: nx / length, y: ny / length }; + }, + + // The pot total's box, in the bets layer's own pixels. It lives on `#pokerTable` + // rather than on either overlay, and the two are not concentric — the layers are + // inset by different amounts top and bottom — so it is measured rather than assumed + // to be at 50%, 50%. + potBadgeBox(box) { + const badge = document.getElementById("potBadge"); + if (!badge) return null; + const at = badge.getBoundingClientRect(); + if (!at.width || !at.height) return null; + return { + x: at.left + at.width / 2 - box.left, + y: at.top + at.height / 2 - box.top, + width: at.width, height: at.height, + }; + }, + + // How far each anchored chip actually gets to travel, once the others are taken + // into account. + // + // Off the rim is only somewhere to go if there is felt out there. At 640x360 the + // table is 528x83 and a seat puck is 55 of those 83 pixels tall: clearing it takes + // 41px, the players facing each other across the table are 70px apart, and both + // chips set off 41px into a gap that is 70 wide. Measured on the running site, a + // twelve-handed table had three pairs of chips on top of each other and a six-handed + // one had two — every pair a number about one player's money hiding a number about + // another's. + // + // Each chip asks for the room it wants and gives ground only to a chip it is + // actually on top of, a pixel at a time until the pair is clear. Two rules that + // looked simpler were tried first and both cost more than they saved. Stopping every + // chip at the felt's midline punishes the seats at the ends of a long rail, which + // are 70px from their nearest neighbour and were never going to hit anything — + // measured at 692x113, it parked two chips on top of their own player's stack to + // solve a collision that did not exist. Scaling every chip's travel by one factor + // has the same fault in the other direction: the end seats give up the same + // proportion as the crowded ones and end up inside their own pucks. + // + // Where a chip is pulled all the way back it rests on its own puck's inner edge. + // Overlapping the edge of your own puck costs a corner of your own stack; + // overlapping the chip opposite costs both amounts outright. + CHIP_PULLBACK_STEP_PX: 1, + CHIP_PULLBACK_PASSES: 80, + + anchoredChipTravel(chips, count, aspect, radii, spacing) { + const { box, puck, clearance, pot } = spacing; + const centreOf = pos => ({ x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height }); + const pucks = Array.from({ length: count }, (_, i) => + centreOf(this.seatPosition(i, count, aspect, radii))); + + const rows = chips.map(({ index, width, height }) => { + const seatPos = this.seatPosition(index, count, aspect, radii); + const dir = this.rimNormal(seatPos, box, radii); + const want = this.clearanceReach(puck, { width, height }, dir.x, dir.y) + clearance; + return { index, width, height, dir, travel: want, seat: centreOf(seatPos) }; + }); + const at = row => ({ x: row.seat.x + row.dir.x * row.travel, + y: row.seat.y + row.dir.y * row.travel }); + // Touching counts as clear. Two boxes that share an edge to within half a pixel + // are not hiding anything from anybody, and treating that as a collision costs a + // whole step of travel: on a felt where the seats are symmetric about the middle, + // a facing pair lands exactly edge to edge and every one of them was then pulled + // back another pixel for nothing. + const TOUCHING = 0.5; + const hits = (a, b, w, h) => + Math.abs(a.x - b.x) < w - TOUCHING && Math.abs(a.y - b.y) < h - TOUCHING; + + for (let pass = 0; pass < this.CHIP_PULLBACK_PASSES; pass++) { + let moved = false; + const back = row => { + if (row.travel <= 0) return; + row.travel = Math.max(0, row.travel - this.CHIP_PULLBACK_STEP_PX); + moved = true; + }; + + // Somebody else's puck. It cannot move, so the chip does all the giving — + // and it is the neighbour's puck that matters, not its own, which the chip + // is anchored to and is allowed to rest on. Off the rim on a felt 83px tall + // is straight into the row of seats opposite: measured at 640x360, six of + // the seven chips on a ten-handed table were painted under a stranger's + // puck, which on this layer means painted out of existence. + rows.forEach(row => { + const p = at(row); + if (pucks.some((seat, i) => i !== row.index && + hits(p, seat, (row.width + puck.width) / 2, + (row.height + puck.height) / 2))) back(row); + }); + + // And the pot total, which is what is 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 top of "Pot $65". + if (pot) { + rows.forEach(row => { + if (hits(at(row), pot, (row.width + pot.width) / 2, + (row.height + pot.height) / 2)) back(row); + }); + } + + for (let a = 0; a < rows.length; a++) { + for (let b = a + 1; b < rows.length; b++) { + if (!hits(at(rows[a]), at(rows[b]), + (rows[a].width + rows[b].width) / 2, + (rows[a].height + rows[b].height) / 2)) continue; + // The one that has come further gives way; if they are level, both + // do, which is what a facing pair across a narrow table always is. + const lead = Math.max(rows[a].travel, rows[b].travel); + [rows[a], rows[b]].forEach(row => { + if (row.travel < lead - 0.01) return; + back(row); + }); + } + } + if (!moved) break; + } + + const travels = {}; + rows.forEach(row => { + travels[row.index] = { x: row.dir.x * row.travel, y: row.dir.y * row.travel }; + }); + return travels; + }, + // A phone's felt has no room for a third ring: measured every radius between the // seats and the pot badge and each one collides with one or the other. So on a // narrow screen the bet clings to its own puck instead — same chip, no ring — // sitting on whichever side faces the middle of the table. - renderBets() { + // + // `radii` is the ring the seats were actually drawn on, handed down by renderSeats. + // Asking for it again here was wrong once the ring stopped being a fixed pair: the + // width search settles on a radius that can be wider than the one seatRingRadii() + // derives on its own, so the chips were being laid out on a ring the seats had left. + renderBets(aspect = this.feltAspect(), radii = this.seatRingRadii()) { const layer = document.getElementById("betsLayer"); if (!layer) return; layer.innerHTML = ""; + // The phone layout puts the chip *inside* its seat, and seats are no longer + // thrown away and rebuilt on every render — they are kept and updated in place, + // which is what made renderAll fast enough to run after every tap. Clearing the + // layer therefore stopped clearing these, and nothing else ever did: measured on + // an iPhone 14, a seat had one chip after the deal, four after three more + // renders, and five by the third call. A dealer's every tap left another disc on + // the felt, and the pile only ever grew. + document.querySelectorAll(".seat .betChip").forEach(chip => chip.remove()); - 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(); - + // Put them on the felt first, position them second. A chip has to be in the + // document before it can say how wide it is, and how wide it is decides both + // where it goes and whether there is room for a ring at all — so this is one + // write, one read, one write, rather than a read for every chip in a loop that + // is also writing to the same layout. + const placed = []; 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, aspect, radii); + layer.appendChild(chip); + placed.push({ chip, index }); + }); + if (!placed.length) return; + const box = this.ringBox(); + const puck = this.measuredSeatBox(); + // Each chip's own size, not the first one's standing in for all of them. A chip + // is as wide as the number on it: measured on a phone held sideways, "$5" is + // 21px and "$10" is 27px, so taking the first chip's width laid out every $10 on + // the felt as if it were 6px narrower than it is — six pixels of somebody else's + // puck. Taking the *widest* instead is wrong the other way: at 768x1024 a single + // 47px chip made the whole table give up a ring that the other four had room for. + const sizes = placed.map(({ chip, index }) => { + const at = chip.getBoundingClientRect(); + return { index, width: at.width, height: at.height }; + }); + if (!box || !puck || !sizes.some(s => s.width)) return; + const spacing = { box, puck, clearance: this.BET_CLEARANCE_PX, pot: this.potBadgeBox(box) }; + + // Whether the chips get a ring of their own is a question about how much felt + // there is between the seats and the pot, and it was being answered by asking + // how wide the *window* is. A phone held sideways is 844px across and has 117px + // of felt to work with: it took the ring, and every chip on it was painted behind + // a puck — the seats carry `z-index: 1` and the bets layer carries none, so a + // chip that lands under a seat is simply gone. + // + // Measured instead, and only measured. The width test was left in front of the + // measurement as a belt-and-braces `||`, which meant every phone still took the + // anchored layout whatever the felt said — including the ones with room for a + // ring, where clinging to the puck is the worse of the two: a chip that has been + // pulled back onto its own puck sits on that player's stack. + const anchorToSeat = !this.chipsClearTheSeats(sizes, aspect, radii, spacing); + // Worked out across all of them at once, because how far one chip can go off the + // rim depends on where the others have got to. + const offsets = anchorToSeat + ? this.anchoredChipTravel(sizes, this.state.seatCount, aspect, radii, spacing) + : null; + const sizeOf = index => sizes.find(s => s.index === index); + + placed.forEach(({ chip, index }) => { if (anchorToSeat) { const seat = document.querySelector(`.seat[data-seat-index="${index}"]`); if (!seat) return; - const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii); - chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow"); + chip.classList.add("betChipAnchored"); seat.appendChild(chip); + const offset = offsets[index]; + chip.style.left = `calc(50% + ${offset.x.toFixed(1)}px)`; + chip.style.top = `calc(50% + ${offset.y.toFixed(1)}px)`; return; } - + const size = sizeOf(index); + const pos = this.betPosition(index, this.state.seatCount, aspect, radii, + { ...spacing, chip: { width: size.width, height: size.height } }); chip.style.left = `${pos.left}%`; chip.style.top = `${pos.top}%`; - layer.appendChild(chip); + }); + }, + + // Would a ring of chips keep clear of every puck on the felt, of the pot, and of + // each other? + // + // The same box-against-box question the seats settle among themselves, asked across + // the two layers. betPosition() walks a chip in along its own spoke until it is clear + // of its own puck, but it will not walk past 38% of the way to the middle, because + // the pot badge is there — and on a felt 117px tall that floor leaves the chip + // sitting on the seat opposite. + // + // That 38% is a guess at where the pot badge ends. Where the badge has actually been + // measured, the guess gives way to it: a ring that lands a chip on the pot total is + // no more use than one that lands it on a puck. + chipsClearTheSeats(chips, aspect, radii, spacing) { + const { box, puck, pot } = spacing; + const count = this.state.seatCount; + const seats = this.state.seats.map((_, i) => { + const pos = this.seatPosition(i, count, aspect, radii); + return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height }; + }); + // Each chip is as wide as the number on it, and asking the question with one + // size for all of them answers it for a table that isn't there: at 768x1024 a + // single 47px "$1,200" made the whole felt give up a ring the other four 40px + // chips had room for, and dropped them all onto their own players' stacks. + const at = ({ index, width, height }) => { + const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } }); + return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height, width, height }; + }; + const placed = chips.map(at); + + // Chips against each other, which this never asked. A 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: 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. The anchored layout + // resolves its own collisions one chip at a time; the ring has no such recourse, + // so a ring that collides is a ring this table cannot have. + for (let a = 0; a < placed.length; a++) { + for (let b = a + 1; b < placed.length; b++) { + if (Math.abs(placed[a].x - placed[b].x) < (placed[a].width + placed[b].width) / 2 && + Math.abs(placed[a].y - placed[b].y) < (placed[a].height + placed[b].height) / 2) { + return false; + } + } + } + + return chips.every(({ index, width, height }) => { + const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } }); + const x = (pos.left / 100) * box.width; + const y = (pos.top / 100) * box.height; + if (pot && Math.abs(x - pot.x) < (width + pot.width) / 2 && + Math.abs(y - pot.y) < (height + pot.height) / 2) { + return false; + } + return seats.every(seat => + Math.abs(x - seat.x) >= (width + puck.width) / 2 || + Math.abs(y - seat.y) >= (height + puck.height) / 2); }); }, @@ -866,7 +1195,7 @@ const Render = { node.classList.toggle("seatSelected", index === this.selectedSeatIndex); }); - this.renderBets(); + this.renderBets(aspect, radii); }, isRedSuit(card) { diff --git a/tests/holdem/README.md b/tests/holdem/README.md index c78bb30..dfebdcc 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -34,10 +34,20 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large enough not to trigger iOS zoom, that nothing important is covered or off-screen with no way to scroll to it, that no grid column has collapsed to nothing, that no -text is painted outside its box, and that no seat covers another seat's writing. It +text is painted outside its box, that no seat covers another seat's writing, and that +no bet chip is hidden behind a seat or another chip. It runs over the table, all five overlays and Quick Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same phones on their side — each viewport -twice, once with a mouse and once with a thumb, and at two table shapes. +twice, once with a mouse and once with a thumb, and at two table shapes, mid-hand: +the sweep posts blinds, deals and calls three times before it measures anything, so +the bets layer has chips on it. It used to audit a table nobody had ever bet at, +which is a third of what is on the felt during a hand left unlooked at. + +"Covered" and "out of view" are told apart before anything is hit-tested. An element +clipped away by a panel that scrolls is not covered by whatever happens to be painted +at that height, and reporting it that way is a diagnosis of something that isn't +happening: the pot wizard's third winner, below the fold in a list, came back for four +viewports running as "fully covered by button Confirm Winner(s)". Two table shapes is also newer than it sounds. The sweep ran on one — five players at a nine-handed table — for its whole life, and how much room the felt has depends on diff --git a/tests/holdem/betChips.test.js b/tests/holdem/betChips.test.js new file mode 100644 index 0000000..e97a28d --- /dev/null +++ b/tests/holdem/betChips.test.js @@ -0,0 +1,473 @@ +// 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"); + }); +}); diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index 205ea46..d0b182f 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -103,8 +103,12 @@ describe("the corner scale", () => { describe("the tokens themselves", () => { test("every var() the stylesheet reaches for exists", () => { const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4))); - // Published from JS at runtime rather than declared here — see actionBar.js. - const fromScript = new Set(["--actionBarHeight", "--feltRoomPx", "--seatPuckW"]); + // Published from JS at runtime rather than declared here — see actionBar.js for + // the first two, render.js for the seat width and the chip's denomination colour, + // which is picked per chip from the amount on it. + const fromScript = new Set([ + "--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour", + ]); // Defined by the site's own stylesheet, not this one. const fromSite = new Set(["--font-mono"]); const missing = [...used].filter(t => diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index bb8fae0..3366ba1 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -108,6 +108,27 @@ async function main() { await page.goto(`${BASE}/Casino/TexasHoldem`); await page.click("#nextHandBtn"); await page.waitForTimeout(300); + // Deal it, and get chips in front of a few players. The sweep used to + // audit a table that had never had a bet on it, so the bets layer was + // empty at every viewport it visited and nothing it drew was ever looked + // at — which is a third of what is on the felt during a hand. + await page.evaluate(() => { + const deal = [...document.querySelectorAll("#actionBar button")] + .find(b => /post blinds/i.test(b.textContent)); + if (deal) deal.click(); + }); + await page.waitForTimeout(220); + for (let i = 0; i < 3; i++) { + const acted = await page.evaluate(() => { + const btn = [...document.querySelectorAll("#actionBar button")] + .find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled); + if (!btn) return false; + btn.click(); + return true; + }); + if (!acted) break; + await page.waitForTimeout(120); + } await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; })); await page.addScriptTag({ path: AUDIT }); diff --git a/tests/holdem/staleToast.test.js b/tests/holdem/staleToast.test.js new file mode 100644 index 0000000..0c90b74 --- /dev/null +++ b/tests/holdem/staleToast.test.js @@ -0,0 +1,106 @@ +// The toast that outlived the thing it was talking about. +// +// Toasts are news about the last action, and they carry an Undo. Take another action +// and both of those go wrong at once: the sentence describes a step that is now two +// steps back, and the Undo behind it restores a snapshot from before everything since. +// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three +// players call is an offer to move the button, wired to a button that deletes the hand. +// +// It is also simply in the way. The toast is a fixed strip near the bottom of the +// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the +// felt and the action bar, so a stale one covers seats and controls for eight seconds. +// +// So the bar drops it as it takes the next action. These tests hold that funnel shut: +// every action a dealer can take from the bar goes through `commit`, and `commit` +// clears the toast before it does anything else. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { ActionBar, Betting, makeTable } = require("./load"); + +const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem"); +const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8"); + +// A bar sat at a real table, with the toast replaced by something that keeps a log +// and the re-render stubbed out — none of these rules need the DOM back. +function onTable() { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 }); + const log = []; + globalThis.Toast = { + onScreen: null, + show(message) { this.onScreen = message; log.push(`show ${message}`); }, + dismiss() { this.onScreen = null; log.push("dismiss"); }, + }; + ActionBar.state = state; + ActionBar.render = () => {}; + ActionBar.hooks = { + beginAction: label => log.push(`begin ${label}`), + save: () => log.push("save"), + rerender: () => log.push("rerender"), + }; + return { bar: ActionBar, state, log }; +} + +test.afterEach(() => { delete globalThis.Toast; }); + +describe("a toast does not outlive its own news", () => { + test("the next action from the bar clears it", () => { + const { bar, state, log } = onTable(); + Toast.show("Next hand — button on seat 2."); + + bar.commit("dealing the hand", () => Betting.beginHand(state)); + + assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more"); + assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"), + `the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`); + }); + + test("a toast raised by the action itself survives", () => { + const { bar, log } = onTable(); + bar.commit("something that talks back", () => Toast.show("Bob is all in.")); + + assert.equal(Toast.onScreen, "Bob is all in.", + "commit clears what was there when it started, not what its own work puts up"); + assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]); + }); + + test("with no toast on screen it is still just one dismiss", () => { + const { bar, log } = onTable(); + bar.commit("a fold", () => {}); + assert.equal(log.filter(l => l === "dismiss").length, 1); + }); + + test("a page with no Toast at all does not throw", () => { + const { bar, state } = onTable(); + delete globalThis.Toast; + assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state))); + }); +}); + +describe("every bar action really does go through that funnel", () => { + // The dismiss is worth nothing if some button reaches past `commit` to mutate the + // table itself. Actions are written as `this.commit(...)`; a `Betting.` call made + // straight from a click handler would be one that skips the clean-up — and the + // undo history with it, which is the older reason this funnel exists. + test("no click handler calls the betting engine directly", () => { + const strays = barSource + .split("\n") + .map((line, i) => [i + 1, line]) + .filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line)) + .filter(([, line]) => !/this\.commit\(/.test(line)) + .map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`); + + assert.deepEqual(strays, [], "these change the table without going through commit"); + }); + + test("commit is what clears the toast, not each button", () => { + const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length; + // Two: this one, and the raise pad — which opens without committing anything, + // and needs the strip clear because the pad makes the bar tall enough to push + // a toast up over the seats. + assert.equal(dismissals, 2, + "a third dismissal means a button is doing by hand what commit already does"); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 0f9e8e6..6d0c633 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -93,6 +93,20 @@ const LayoutAudit = { return false; }, + // The scrolling ancestor whose box this element has fallen outside of, if any. + // Wholly outside — an element straddling the edge is still half on show, and the + // half that is showing is worth hit-testing. + clippedOutBy(el, rect) { + for (let node = el.parentElement; node; node = node.parentElement) { + const style = getComputedStyle(node); + if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue; + const box = node.getBoundingClientRect(); + if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 || + rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node; + } + return null; + }, + // The modal that is currently on top, if any. // // Every check below asks "is this thing reachable", and while a sheet or dialog is @@ -143,6 +157,22 @@ const LayoutAudit = { return; } + // Still on screen, but clipped away by a panel that scrolls. Hit-testing it + // is meaningless: the point lands on whatever the panel's own edge has next + // to it, and the answer comes back as a diagnosis of something that isn't + // happening. The pot wizard's third winner, below the fold in a list that + // scrolls, was reported for four viewports running as "fully covered by + // button Confirm Winner(s)" — the button is simply what is painted at that + // height. Whether it is a fault is the same question as for anything else + // out of view: can it be scrolled to. + const clip = this.clippedOutBy(el, rect); + if (clip) { + if (!this.reachableByScrolling(el)) { + offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`); + } + return; + } + const points = [ [rect.left + rect.width / 2, rect.top + rect.height / 2], [rect.left + rect.width * 0.25, rect.top + rect.height * 0.3], @@ -848,6 +878,69 @@ const LayoutAudit = { return { name, offenders }; }, + // The other thing on the felt that carries a number. + // + // A chip says what a player has put in this street, which is what a call costs. Two + // of them on top of each other is two figures a dealer can't read, and one behind a + // puck is a figure that isn't there at all. + // + // Geometry rather than hit-testing, which is the opposite of how every other check + // here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint + // never returns a chip — it returns whatever is behind it, for every chip, always. + // A first version of this probe hit-tested and duly reported all forty-five + // configurations as broken, including the ones that were fine. Paint order is known + // and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat + // carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind. + chipsKeepTheirAmounts() { + const name = "no bet chip is hidden behind a seat or another chip"; + if (this.modalScope()) { + return { name, offenders: [], skipped: "a sheet is open over the table" }; + } + const chips = [...document.querySelectorAll(".betChip")] + .filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); + if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" }; + + const overlap = (A, B) => + Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 && + Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5; + const offenders = []; + const seats = [...document.querySelectorAll(".seat")]; + + chips.forEach(chip => { + const label = chip.textContent.trim() || "a chip"; + const box = chip.getBoundingClientRect(); + seats.forEach((seat, index) => { + // Its own puck is the one thing it is allowed to touch: on a narrow felt + // the chip clings to the seat rather than taking a ring of its own. + if (seat.contains(chip)) return; + if (overlap(box, seat.getBoundingClientRect())) { + offenders.push(`chip ${label} is behind seat ${index + 1}`); + } + }); + // A chip wider than the puck it is clinging to is on its neighbours by + // construction, whatever the ring does. + const host = chip.closest(".seat"); + if (host && chip.classList.contains("betChipAnchored")) { + const puck = host.getBoundingClientRect(); + if (box.width > puck.width + 0.5) { + offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` + + `wider than its own puck`); + } + } + }); + + for (let a = 0; a < chips.length; a++) { + for (let b = a + 1; b < chips.length; b++) { + if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) { + offenders.push(`chip ${chips[a].textContent.trim()} and chip ` + + `${chips[b].textContent.trim()} are on top of each other`); + } + } + } + + return { name, offenders: [...new Set(offenders)] }; + }, + // ---- Runner ---- // Every check this file knows how to make, every time. @@ -969,6 +1062,7 @@ const LayoutAudit = { this.hiddenButPainted(), this.seatLabelsClear(), this.seatsKeepTheirText(), + this.chipsKeepTheirAmounts(), this.contrast(options.contrast), this.touchTargets(options.minimumTarget, options.touchTargets), ]; -- 2.43.0 From cd57c16050d088b10c53fccc2b15fed4386d0168 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Fri, 4 Sep 2026 11:18:33 -0600 Subject: [PATCH 3/3] Let the table tell the dealer who won MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An option — off by default, and off means the tool is exactly what it was. Turn it on and the community cards go on the felt. Each street asks for the cards as it is dealt: the picker opens on the flop the moment the flop button is pressed, and again on the turn and the river, because the moment a dealer is holding those cards is the only moment they know what they are without turning the board round. Three taps, then one, then one. The picker is the same sheet as the hole cards, which now knows about the middle of the table too — the seven of clubs cannot be on the flop and in somebody's hand, and until now it could. Turning the players' cards over is unchanged. That already worked the way it should. What is new is what happens next. Resolve Pot stops asking who won and says so: Alexandra wins $50 with three of a kind, nines. with every hand at the table read out under its player's name, the winner already ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer still confirms — the tool reads the cards, a person still says the pot is good, and a hand entered wrong is corrected by unticking rather than by starting over. It declines rather than guesses. A board that isn't five cards, a player who hasn't turned over, the same card entered in two places: each says which of those it is and hands the question back, and the wizard is the one it always was. Who is *ahead* and who *won* are different claims and only the second one can pay somebody. Side pots are read per round, not once. The best hand takes the main pot and is then out of the question; the side pot goes to the best of who is left, which is a different answer and the one the tool has to ask for again. handRank.js is the only module here that can pay the wrong person without anybody noticing, so it is built to be checkable rather than clever: rank five cards, then take the best of the twenty-one five-card hands in seven. Beside the written cases — every category against every other, the wheel and the steel wheel, three kickers deep, the counterfeited two pair, the board that plays — it is checked against a second evaluator written a different way, on twenty thousand random seven-card hands and four thousand random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair kicker and a flush read only by its top card were each introduced on purpose and each caught. The board takes the middle of the felt, sized from the room the seat ring actually leaves — measured against every seat rather than the ring's widest point, which is only the answer for a seat sitting exactly on the horizontal axis and had two pucks on top of the board while claiming 77px of clearance either side. Where there is no middle to take, it moves to a strip above the table: a phone on its side has a felt 528x83 with 55px pucks, so the ring closes over the centre entirely and a board in it is a board with players painted on top. That strip found an older bug. It costs the felt height, and a felt that changes size had only ever been re-measured, never laid out again: the seats follow it on their own, being placed in percentages, but a bet chip anchored inside a seat is placed in pixels off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a side pot appearing is 55px off the felt. Both fixed. The sweep grew a third table — the same five players in the same nine chairs as the second, with the board on, so the only thing that differs between those two rows is the board itself. It earned its place on the first run. 728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a landscape phone, the known residual, plus five or six pixels more of it on the layouts where the strip takes the felt's height, and a blind badge over a stack on a packed twelve-seat portrait table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 16 +- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 209 ++++++++- .../wwwroot/js/casino/holdem/actionBar.js | 30 +- .../wwwroot/js/casino/holdem/betting.js | 14 + .../wwwroot/js/casino/holdem/cardPicker.js | 104 ++++- .../wwwroot/js/casino/holdem/handRank.js | 285 ++++++++++++ .../wwwroot/js/casino/holdem/main.js | 9 + .../wwwroot/js/casino/holdem/potWizard.js | 124 +++++- .../wwwroot/js/casino/holdem/render.js | 197 ++++++++- .../wwwroot/js/casino/holdem/state.js | 29 ++ tests/holdem/README.md | 47 +- tests/holdem/board.test.js | 118 +++++ tests/holdem/feltRoom.test.js | 71 +++ tests/holdem/handRank.test.js | 415 ++++++++++++++++++ tests/holdem/load.js | 7 + tests/holdem/readingTheShowdown.test.js | 274 ++++++++++++ tests/holdem/runUiAudit.js | 37 +- 17 files changed, 1918 insertions(+), 68 deletions(-) create mode 100644 JoshHeaps.Net/wwwroot/js/casino/holdem/handRank.js create mode 100644 tests/holdem/board.test.js create mode 100644 tests/holdem/handRank.test.js create mode 100644 tests/holdem/readingTheShowdown.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index bd78595..b5951ae 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -29,11 +29,15 @@
+
-
- Pot - $0 +
+ +
+ Pot + $0 +
@@ -81,6 +85,11 @@

Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.

+ +

Puts the community cards on the felt and asks for them as each street is dealt. Turn the players’ cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.