diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 3e71d26..8b31fb5 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -568,8 +568,19 @@ button, .dealerChip { position: absolute; - top: -10px; - right: -10px; + /* Inside its own seat, not on the rim. + + It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval + of overlapping seats means hanging over the seat *next to it*. Measured on a + nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That + was always slightly true and the enlarged hit area made it much worse; three of + nine seats were losing taps, one of them losing its own middle. + + Tucked inside, the overlap is zero at every size. It reads as a puck in the + corner of the seat rather than on its edge, which is a small price for tapping + a player and getting that player. */ + top: 3px; + right: 3px; width: 22px; height: 22px; border-radius: 50%; @@ -595,10 +606,20 @@ button, .dealerChip::after { content: ""; position: absolute; - top: -9px; - right: -7px; - bottom: -15px; - left: -14px; + /* Grows inward, over its own seat, not outward onto the neighbours. + + The chip already sits outside its seat by design — top:-10px, right:-10px — so + a pad that also reaches up and right lands on whatever seat is next around the + oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1 + losing 10 of 49 sampled points including its own centre, which meant tapping + the middle of seat 1 gave the button to seat 9. + + Down and left is its own seat, which is a large target that can spare the + corner. The two pixels of outward reach are just to cover the chip's border. */ + top: -3px; + right: -3px; + bottom: -18px; + left: -18px; } .dealerChip:focus-visible { @@ -2787,9 +2808,20 @@ body.hasActionBar #toastHost { against the containing block's width, not the element's own, so narrowing the wrap on its own left it full height regardless. */ #pokerTableWrap { - --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem); - width: min(100%, calc(var(--feltRoom) / 0.72)); + /* A wide ellipse, which is what a real poker table is. + + Capping the width by the height to hold the portrait aspect ratio was + throwing away most of the screen: at 640x360 the felt came out 135px wide + inside a 601px column — 22% of the width — leaving 366px of rim for nine + seats that need 662px. Adjacent seat centres ended up 13px apart, which is + why a seat's centre belonged to its neighbour. + + Landscape has width and no height, so the table takes the width it has and + is only as tall as the room left over. */ + --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem); + width: 100%; height: var(--feltRoom); + min-height: 7rem; padding-bottom: 0; } @@ -2826,6 +2858,7 @@ body.hasActionBar #toastHost { width: 62px; min-height: 40px; } + } /* ---------- Short phones, upright ---------- @@ -3006,3 +3039,10 @@ textarea:focus-visible { color: #ecffef; font-weight: 600; } + +/* The quieter half of the deal prompt: things the deal is about to do that a dealer + would otherwise only discover afterwards. */ +.dealPromptLine.dealPromptAside { + font-size: 0.72rem; + color: #9a9a9a; +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index ec2032e..2befb48 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -494,6 +494,17 @@ const ActionBar = { `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; wrap.appendChild(line); + // Dealing relocates a button sitting on an empty chair. That has to be said + // before it happens rather than discovered afterwards — a fresh table starts + // the button on seat 1 whether or not anyone is sitting there. + if (preview.buttonMoves) { + const moved = document.createElement("p"); + moved.className = "dealPromptLine dealPromptAside"; + moved.textContent = + `Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`; + wrap.appendChild(moved); + } + const row = document.createElement("div"); row.className = "actionButtons"; row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 764d406..734ba37 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -33,10 +33,22 @@ const Betting = { // while the table is still being set, so a dealer can see where they'll land // before any money moves. blindPreview(state) { - const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(state); + // Dealing relocates a button parked on an empty chair before it works out who + // posts, so the preview has to answer for the button where it *will* be. Asking + // from where it currently sits gave a different pair of blinds than the deal + // produced — the preview promising one thing and doing another is exactly the + // guessing this whole step exists to remove. + const buttonIndex = HoldemState.buttonSeatForDeal(state); + const asDealt = buttonIndex === state.dealerSeatIndex + ? state + : { ...state, dealerSeatIndex: buttonIndex }; + + const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt); if (sbIndex === null || bbIndex === null) return null; return { sbIndex, bbIndex, headsUp, manual: !!manual, + buttonIndex, + buttonMoves: buttonIndex !== state.dealerSeatIndex, smallBlind: this.round(state.smallBlind || 0), bigBlind: this.round(state.bigBlind || 0), }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 8173a22..00fd555 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => { }); }, - makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { - state.dealerSeatIndex = index; - // Before anyone has acted the blinds are still just sitting there, so - // moving the button moves them too — a home game passes it round several - // times before the cards come out, and the posted chips have to follow. - if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); - }), + makeDealer: index => { + // Before the deal there's nothing posted, so this is just moving a marker. + // After it, the blinds have to follow the button or the badges and the + // money disagree — but that's a real thing happening to real chips, and + // the dealer gets told rather than finding out later. + const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) && + HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS; + + seatAction(index, willRepost + ? `Button on ${nameOf(index)} — blinds taken back and re-posted.` + : `Button on ${nameOf(index)}.`, () => { + state.dealerSeatIndex = index; + if (willRepost) Betting.repostBlinds(state); + }); + }, assignBlind: (index, which) => { const blinds = HoldemState.getBlindSeats(state); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index fdcf299..a3621ec 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -273,15 +273,25 @@ const HoldemState = { // position off it. On the very first hand of a fresh table it starts on seat 1 // whether or not anybody is sitting there, and a table that empties out around it // leaves it stranded. Move it to a real player before a hand is dealt. - ensureButtonOnPlayer(state) { - if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return; + // Where the button will be when the hand is dealt — without moving it. + // + // Kept separate from ensureButtonOnPlayer because the deal preview has to be able + // to ask the question without answering it. It couldn't, and so the preview lied: + // with the button parked on an empty chair it promised one pair of blinds and + // dealing produced another, because beginHand relocates the button first and then + // works out who posts. + buttonSeatForDeal(state) { + const seated = state.seats[state.dealerSeatIndex]; + if (seated && seated.inHand) return state.dealerSeatIndex; for (let step = 0; step < state.seatCount; step++) { const index = this.nextSeatIndex(state, state.dealerSeatIndex, step); - if (state.seats[index].inHand) { - state.dealerSeatIndex = index; - return; - } + if (state.seats[index].inHand) return index; } + return state.dealerSeatIndex; + }, + + ensureButtonOnPlayer(state) { + state.dealerSeatIndex = this.buttonSeatForDeal(state); }, // A hand-picked blind is only usable if that seat is actually in the hand — a diff --git a/tests/holdem/dealStep.test.js b/tests/holdem/dealStep.test.js index 86874a1..9c73529 100644 --- a/tests/holdem/dealStep.test.js +++ b/tests/holdem/dealStep.test.js @@ -161,3 +161,45 @@ describe("a table saved mid-hand comes back mid-hand", () => { assert.equal(state.bettingOpen, false); }); }); + +describe("the preview tells the truth about the button", () => { + test("a button parked on an empty chair is answered for where it will be", () => { + // The preview used to read the button where it sat, while dealing relocated it + // first — so it promised one pair of blinds and produced another. + const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 }); + state.bettingOpen = false; + assert.equal(state.seats[0].inHand, false, "the button is on an empty chair"); + + const preview = Betting.blindPreview(state); + assert.equal(preview.buttonMoves, true, "and the preview knows it will move"); + + Betting.beginHand(state); + + assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind); + assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind); + assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said"); + }); + + test("asking where the button will be doesn't move it", () => { + const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 }); + state.bettingOpen = false; + + Betting.blindPreview(state); + Betting.blindPreview(state); + + assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action"); + }); + + test("a button already on a player is left alone", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + state.bettingOpen = false; + const preview = Betting.blindPreview(state); + assert.equal(preview.buttonMoves, false); + assert.equal(preview.buttonIndex, 0); + }); + + test("buttonSeatForDeal copes with a table nobody is sitting at", () => { + const state = makeTable({ seatCount: 6, seated: [] }); + assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 98e91e3..bcd0b86 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -133,7 +133,18 @@ const LayoutAudit = { if (el.contains(top) || top === el || top.contains(el)) return; // Seats ring a round table and overlap each other at the rim by // design; that is crowding, not an overlay hiding a control. - if (top.closest && top.closest(".seat")) return; + // + // But only the seat's *body* is harmless. A neighbouring seat's own + // controls sitting on top of this one is a control being stolen, and + // exempting the whole seat subtree hid exactly that: an enlarged + // dealer-chip hit area was taking the centre of the seat three + // places round the table, and this check called it crowding and + // moved on. + const neighbourSeat = top.closest && top.closest(".seat"); + if (neighbourSeat) { + const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']"); + if (!isControl) return; + } blocked++; if (!blockedBy) blockedBy = this.describe(top); }); @@ -468,6 +479,41 @@ const LayoutAudit = { return { name: `touch targets at least ${minimum}px`, offenders }; }, + // Tapping the middle of a seat must give you that seat. + // + // Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew + // outward from a chip that already hung over its seat's corner — and therefore + // over the seat next to it. On a nine-seat table, tapping the centre of seat 1 + // handed the button to seat 9. The occlusion check missed it because it treated + // anything inside a `.seat` as harmless crowding. + // + // Reported separately from occlusion because the cause is different: not an + // overlay on top of the table, but two pieces of the table fighting over a tap. + seatOwnership() { + const seats = [...document.querySelectorAll(".seat")]; + const offenders = []; + + seats.forEach((seat, index) => { + const rect = seat.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return; + + const hit = document.elementFromPoint(x, y); + if (!hit || seat.contains(hit) || hit === seat) return; + + const other = hit.closest(".seat"); + const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit); + const control = hit.matches && hit.matches("button, a, input, select, [role='button']"); + offenders.push( + `seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}` + ); + }); + + return { name: "a seat's centre belongs to that seat", offenders }; + }, + // ---- Runner ---- runAll(options = {}) { @@ -480,6 +526,7 @@ const LayoutAudit = { this.textSpill(), this.horizontalOverflow(), this.fieldFontSize(), + this.seatOwnership(), ]; if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));