Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal

Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
2026-09-01 10:57:17 -06:00
7 changed files with 193 additions and 23 deletions
+48 -8
View File
@@ -568,8 +568,19 @@ button,
.dealerChip { .dealerChip {
position: absolute; position: absolute;
top: -10px; /* Inside its own seat, not on the rim.
right: -10px;
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; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
@@ -595,10 +606,20 @@ button,
.dealerChip::after { .dealerChip::after {
content: ""; content: "";
position: absolute; position: absolute;
top: -9px; /* Grows inward, over its own seat, not outward onto the neighbours.
right: -7px;
bottom: -15px; The chip already sits outside its seat by design — top:-10px, right:-10px — so
left: -14px; 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 { .dealerChip:focus-visible {
@@ -2787,9 +2808,20 @@ body.hasActionBar #toastHost {
against the containing block's width, not the element's own, so narrowing the against the containing block's width, not the element's own, so narrowing the
wrap on its own left it full height regardless. */ wrap on its own left it full height regardless. */
#pokerTableWrap { #pokerTableWrap {
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem); /* A wide ellipse, which is what a real poker table is.
width: min(100%, calc(var(--feltRoom) / 0.72));
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); height: var(--feltRoom);
min-height: 7rem;
padding-bottom: 0; padding-bottom: 0;
} }
@@ -2826,6 +2858,7 @@ body.hasActionBar #toastHost {
width: 62px; width: 62px;
min-height: 40px; min-height: 40px;
} }
} }
/* ---------- Short phones, upright ---------- /* ---------- Short phones, upright ----------
@@ -3006,3 +3039,10 @@ textarea:focus-visible {
color: #ecffef; color: #ecffef;
font-weight: 600; 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;
}
@@ -494,6 +494,17 @@ const ActionBar = {
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line); 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"); const row = document.createElement("div");
row.className = "actionButtons"; row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
@@ -33,10 +33,22 @@ const Betting = {
// while the table is still being set, so a dealer can see where they'll land // while the table is still being set, so a dealer can see where they'll land
// before any money moves. // before any money moves.
blindPreview(state) { 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; if (sbIndex === null || bbIndex === null) return null;
return { return {
sbIndex, bbIndex, headsUp, manual: !!manual, sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
smallBlind: this.round(state.smallBlind || 0), smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0), bigBlind: this.round(state.bigBlind || 0),
}; };
+14 -6
View File
@@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}, },
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { 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; state.dealerSeatIndex = index;
// Before anyone has acted the blinds are still just sitting there, so if (willRepost) Betting.repostBlinds(state);
// 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);
}),
assignBlind: (index, which) => { assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state); const blinds = HoldemState.getBlindSeats(state);
@@ -273,15 +273,25 @@ const HoldemState = {
// position off it. On the very first hand of a fresh table it starts on seat 1 // 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 // 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. // leaves it stranded. Move it to a real player before a hand is dealt.
ensureButtonOnPlayer(state) { // Where the button will be when the hand is dealt — without moving it.
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return; //
// 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++) { for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step); const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) { if (state.seats[index].inHand) return index;
state.dealerSeatIndex = index;
return;
}
} }
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 // A hand-picked blind is only usable if that seat is actually in the hand — a
+42
View File
@@ -161,3 +161,45 @@ describe("a table saved mid-hand comes back mid-hand", () => {
assert.equal(state.bettingOpen, false); 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);
});
});
+48 -1
View File
@@ -133,7 +133,18 @@ const LayoutAudit = {
if (el.contains(top) || top === el || top.contains(el)) return; if (el.contains(top) || top === el || top.contains(el)) return;
// Seats ring a round table and overlap each other at the rim by // Seats ring a round table and overlap each other at the rim by
// design; that is crowding, not an overlay hiding a control. // 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++; blocked++;
if (!blockedBy) blockedBy = this.describe(top); if (!blockedBy) blockedBy = this.describe(top);
}); });
@@ -468,6 +479,41 @@ const LayoutAudit = {
return { name: `touch targets at least ${minimum}px`, offenders }; 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 ---- // ---- Runner ----
runAll(options = {}) { runAll(options = {}) {
@@ -480,6 +526,7 @@ const LayoutAudit = {
this.textSpill(), this.textSpill(),
this.horizontalOverflow(), this.horizontalOverflow(),
this.fieldFontSize(), this.fieldFontSize(),
this.seatOwnership(),
]; ];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget)); if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));