diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index b413bb0..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 ---------- @@ -2977,3 +3010,39 @@ textarea:focus-visible { display: none; } } + +/* ---------- The deal step ---------- + Between hands the bar shows who will post and waits. Dealing used to happen the + instant the previous hand ended, which committed blinds while people were still + sitting down — and left the blind badges disagreeing with the money already in. */ +.dealPrompt { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dealPromptLine { + margin: 0; + text-align: center; + font-size: 0.8rem; + color: #c8c8c8; + line-height: 1.35; +} + +.dealPromptLine.dealPromptBlocked { + color: #9a9a9a; +} + +.actionBtn.actionDeal { + background: linear-gradient(180deg, #16783a, #0d5225); + border-color: #1e8449; + 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 044c895..2befb48 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -40,7 +40,9 @@ const ActionBar = { publish(); }, - // Anything to act on? A hand with nobody dealt in doesn't get a bar. + // Anything to act on? A hand with nobody dealt in doesn't get a bar — and that + // still holds during setup, because a seated player is a live seat whether or not + // the betting has been opened. An empty table stays uncluttered. isRunning() { return Betting.liveSeats(this.state).length > 0; }, @@ -66,7 +68,9 @@ const ActionBar = { const breakdown = this.renderPotBreakdown(); if (breakdown) host.appendChild(breakdown); - if (Betting.isHandOver(this.state)) { + if (!this.state.bettingOpen) { + host.appendChild(this.renderDealPrompt()); + } else if (Betting.isHandOver(this.state)) { host.appendChild(this.renderHandOver()); } else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) { host.appendChild(this.renderStreetDone()); @@ -458,6 +462,59 @@ const ActionBar = { note.hidden = true; }, + // The table is set but the cards aren't out. This is the step that used to happen + // by itself the instant the previous hand ended, which meant blinds were committed + // while people were still sitting down — and the blind badges would drift away from + // the money already posted as seats changed. Now the dealer says when. + renderDealPrompt() { + const wrap = document.createElement("div"); + wrap.className = "dealPrompt"; + + const preview = Betting.blindPreview(this.state); + const seated = Betting.liveSeats(this.state).length; + + const line = document.createElement("p"); + line.className = "dealPromptLine"; + + if (!preview) { + // Two players is the least you can deal. Say what's missing rather than + // offering a button that would quietly do nothing. + line.textContent = seated === 0 + ? "Nobody is dealt in yet. Tap a seat to add a player." + : "Only one player is dealt in — a hand needs two."; + line.classList.add("dealPromptBlocked"); + wrap.appendChild(line); + return wrap; + } + + const name = i => HoldemState.displayName(this.state.seats[i], i); + line.textContent = + `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` + + `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + + `${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", () => { + this.commit("dealing the hand", () => Betting.beginHand(this.state)); + })); + wrap.appendChild(row); + + return wrap; + }, + renderStreetDone() { const row = document.createElement("div"); row.className = "actionButtons"; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 29f293f..734ba37 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -29,6 +29,31 @@ const Betting = { .filter(s => s.player.inHand && !s.player.folded); }, + // Who would post if the hand were dealt right now. Used to preview the blinds + // while the table is still being set, so a dealer can see where they'll land + // before any money moves. + blindPreview(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), + }; + }, + committed(player) { return this.round(player.committed || 0); }, @@ -291,7 +316,12 @@ const Betting = { // Deals a fresh betting round: blinds posted, action to the seat after the big // blind, which is where a dealer expects to be looking when the cards go out. + // + // Called when the dealer says the table is ready, not automatically when the + // previous hand ends — everything before this point is setup, where seats and the + // button can move freely because no money has been committed. beginHand(state) { + state.bettingOpen = true; state.street = "preflop"; state.currentBet = 0; state.lastRaiseSize = 0; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 31be248..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); @@ -159,8 +167,10 @@ document.addEventListener("DOMContentLoaded", () => { // hand's stacks are already right. Betting.creditWinnings(state, payouts); } + // Settling a pot ends the hand; it doesn't start the next one. The + // table goes back to setup so people can sit down or cash out before + // any blinds are committed. HoldemState.startNextHand(state); - Betting.beginHand(state); }); }); }; @@ -222,7 +232,9 @@ document.addEventListener("DOMContentLoaded", () => { return true; }, // Enter confirms a raise if one is being entered, otherwise moves the hand on. - advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"), + // Dealing is an advance too — Enter should carry a dealer through setup, deal, + // streets and showdown without reaching for the trackpad. + advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"), undo: () => pressInBar(".actionUndo"), cancel: () => pressInBar(".raiseCancel"), }); @@ -345,9 +357,8 @@ document.addEventListener("DOMContentLoaded", () => { ? ` ${Money.format(unresolved)} taken off the table.` : ""; - runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => { + runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => { HoldemState.startNextHand(state); - Betting.beginHand(state); }); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 1cd4163..a3621ec 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -58,6 +58,20 @@ const HoldemState = { currentBet: 0, lastRaiseSize: 0, actingSeatIndex: null, + // Whether the betting has actually been opened for this hand. + // + // Dealing and betting used to be one button: "Next Hand" moved the button + // and posted the blinds in the same breath. That meant the moment between + // hands — the one where people are still sitting down, standing up and + // arguing about whose turn it is — was spent with money already committed, + // and the blind badges recomputing off `inHand` while the posted chips + // stayed frozen from when the hand began. Seat somebody after pressing it + // and the table would show a big blind who had posted nothing. + // + // So the dealer says when. False means the table is being set up: nothing + // is committed, the badges are a preview, and seating or moving the button + // costs nothing. + bettingOpen: false, // Set when the dealer names the blinds by hand instead of letting them // follow the button — { sbIndex, bbIndex }. Home games do this constantly // ("you had it last time", someone arriving late owing a blind), and the @@ -84,6 +98,17 @@ const HoldemState = { .forEach(key => { if (parsed[key] === undefined) parsed[key] = defaults[key]; }); + + // A table saved before dealing and betting were separate has no flag, and + // guessing false would show "deal the hand" over a hand already in + // progress. Anything already committed, or anyone already on the clock, + // means the betting was open when they closed the tab. + if (parsed.bettingOpen === undefined) { + parsed.bettingOpen = + parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined || + parsed.seats.some(p => + (Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0); + } parsed.seats.forEach(player => { if (player.stack === undefined) player.stack = null; if (player.committed === undefined) player.committed = 0; @@ -248,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 @@ -306,6 +341,12 @@ const HoldemState = { startNextHand(state) { this.advanceButton(state); + // Cards aren't out and nothing is posted until the dealer says so. + state.bettingOpen = false; + state.actingSeatIndex = null; + state.street = "preflop"; + state.currentBet = 0; + state.lastRaiseSize = 0; // A hand-picked blind applies to the hand it was picked for. Carrying it into // the next one would freeze the blinds on two players for the rest of the // night, which is the opposite of what naming them once is for. diff --git a/tests/holdem/dealStep.test.js b/tests/holdem/dealStep.test.js new file mode 100644 index 0000000..9c73529 --- /dev/null +++ b/tests/holdem/dealStep.test.js @@ -0,0 +1,205 @@ +// Dealing and betting are two decisions, and fusing them meant the table guessed. +// +// The moment between hands is when people sit down, stand up and argue about whose +// turn it is. With blinds posted the instant the previous hand ended, the blind +// badges recomputed off `inHand` while the committed chips stayed frozen — so seating +// somebody produced a big blind who had posted nothing. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +const postedBy = state => state.seats + .map((p, i) => (Betting.committed(p) > 0 ? i : null)) + .filter(i => i !== null); + +describe("the table waits to be dealt", () => { + test("finishing a hand leaves the next one un-dealt", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + assert.equal(state.bettingOpen, true); + + HoldemState.startNextHand(state); + + assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt"); + assert.equal(state.actingSeatIndex, null, "nobody is on the clock"); + assert.deepEqual(postedBy(state), [], "and no blind has been committed"); + }); + + test("no money moves until the dealer deals", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), start); + assert.equal(HoldemState.totalPot(state), 0); + + Betting.beginHand(state); + assert.equal(state.bettingOpen, true); + assert.equal(postedBy(state).length, 2, "now the blinds are in"); + assert.equal(chipsOnTable(state), start, "and nothing was created doing it"); + }); +}); + +describe("setting the table costs nothing", () => { + test("seating a player before the deal changes who posts", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + + const before = Betting.blindPreview(state); + state.seats[3].inHand = true; + state.seats[3].stack = 100; + const after = Betting.blindPreview(state); + + assert.notDeepEqual( + [before.sbIndex, before.bbIndex], + [after.sbIndex, after.bbIndex], + "the preview follows the new table" + ); + assert.deepEqual(postedBy(state), [], "and still nothing is committed"); + + Betting.beginHand(state); + assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(), + "the money lands where the preview said it would"); + }); + + test("removing a player before the deal can't strand their blind", () => { + // The old failure: take out the big blind after the hand had begun and their + // chips stayed in the pot while the badge moved to somebody who'd posted + // nothing. Before the deal there is nothing to strand. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + + const doomed = Betting.blindPreview(state).bbIndex; + state.seats[doomed].inHand = false; + + assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded"); + + Betting.beginHand(state); + const posted = postedBy(state); + assert.ok(!posted.includes(doomed), "the player who left posts nothing"); + posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post")); + }); + + test("moving the button before the deal just moves it", () => { + const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 }); + const start = chipsOnTable(state); + HoldemState.startNextHand(state); + + [2, 4, 6, 0].forEach(seat => { + state.dealerSeatIndex = seat; + assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`); + assert.equal(chipsOnTable(state), start); + }); + + Betting.beginHand(state); + assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air"); + }); + + test("what the preview promises is what gets posted", () => { + const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 }); + HoldemState.startNextHand(state); + + const preview = Betting.blindPreview(state); + Betting.beginHand(state); + + assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind); + assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind); + }); + + test("there's nothing to preview without two players", () => { + const alone = makeTable({ seatCount: 6, seated: [2] }); + HoldemState.startNextHand(alone); + assert.equal(Betting.blindPreview(alone), null); + + const empty = makeTable({ seatCount: 6, seated: [] }); + HoldemState.startNextHand(empty); + assert.equal(Betting.blindPreview(empty), null); + }); + + test("a hand-picked blind shows up in the preview", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + HoldemState.startNextHand(state); + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + + const preview = Betting.blindPreview(state); + assert.equal(preview.manual, true); + assert.equal(preview.sbIndex, 3); + assert.equal(preview.bbIndex, 0); + }); +}); + +describe("a table saved mid-hand comes back mid-hand", () => { + const loadWith = saved => { + const original = globalThis.localStorage; + globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} }; + const state = HoldemState.load(); + globalThis.localStorage = original; + return state; + }; + + test("chips already committed mean the betting was open", () => { + const state = loadWith({ + seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }], + handNumber: 3, + }); + assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand"); + }); + + test("somebody on the clock means the betting was open", () => { + const state = loadWith({ + seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }], + actingSeatIndex: 1, handNumber: 2, + }); + assert.equal(state.bettingOpen, true); + }); + + test("a quiet table comes back waiting to be dealt", () => { + const state = loadWith({ + seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }], + actingSeatIndex: null, handNumber: 5, + }); + 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));