Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-09-01 10:57:23 -06:00
7 changed files with 488 additions and 28 deletions
+77 -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 ----------
@@ -2977,3 +3010,39 @@ textarea:focus-visible {
display: none; 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;
}
@@ -40,7 +40,9 @@ const ActionBar = {
publish(); 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() { isRunning() {
return Betting.liveSeats(this.state).length > 0; return Betting.liveSeats(this.state).length > 0;
}, },
@@ -66,7 +68,9 @@ const ActionBar = {
const breakdown = this.renderPotBreakdown(); const breakdown = this.renderPotBreakdown();
if (breakdown) host.appendChild(breakdown); 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()); host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) { } else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
host.appendChild(this.renderStreetDone()); host.appendChild(this.renderStreetDone());
@@ -458,6 +462,59 @@ const ActionBar = {
note.hidden = true; 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() { renderStreetDone() {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "actionButtons"; row.className = "actionButtons";
@@ -29,6 +29,31 @@ const Betting = {
.filter(s => s.player.inHand && !s.player.folded); .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) { committed(player) {
return this.round(player.committed || 0); 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 // 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. // 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) { beginHand(state) {
state.bettingOpen = true;
state.street = "preflop"; state.street = "preflop";
state.currentBet = 0; state.currentBet = 0;
state.lastRaiseSize = 0; state.lastRaiseSize = 0;
+22 -11
View File
@@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}, },
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { makeDealer: index => {
state.dealerSeatIndex = index; // Before the deal there's nothing posted, so this is just moving a marker.
// Before anyone has acted the blinds are still just sitting there, so // After it, the blinds have to follow the button or the badges and the
// moving the button moves them too — a home game passes it round several // money disagree — but that's a real thing happening to real chips, and
// times before the cards come out, and the posted chips have to follow. // the dealer gets told rather than finding out later.
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); 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) => { assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state); const blinds = HoldemState.getBlindSeats(state);
@@ -159,8 +167,10 @@ document.addEventListener("DOMContentLoaded", () => {
// hand's stacks are already right. // hand's stacks are already right.
Betting.creditWinnings(state, payouts); 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); HoldemState.startNextHand(state);
Betting.beginHand(state);
}); });
}); });
}; };
@@ -222,7 +232,9 @@ document.addEventListener("DOMContentLoaded", () => {
return true; return true;
}, },
// Enter confirms a raise if one is being entered, otherwise moves the hand on. // 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"), undo: () => pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"), cancel: () => pressInBar(".raiseCancel"),
}); });
@@ -345,9 +357,8 @@ document.addEventListener("DOMContentLoaded", () => {
? ` ${Money.format(unresolved)} taken off the table.` ? ` ${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); HoldemState.startNextHand(state);
Betting.beginHand(state);
}); });
}); });
@@ -58,6 +58,20 @@ const HoldemState = {
currentBet: 0, currentBet: 0,
lastRaiseSize: 0, lastRaiseSize: 0,
actingSeatIndex: null, 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 // Set when the dealer names the blinds by hand instead of letting them
// follow the button — { sbIndex, bbIndex }. Home games do this constantly // follow the button — { sbIndex, bbIndex }. Home games do this constantly
// ("you had it last time", someone arriving late owing a blind), and the // ("you had it last time", someone arriving late owing a blind), and the
@@ -84,6 +98,17 @@ const HoldemState = {
.forEach(key => { .forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[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 => { parsed.seats.forEach(player => {
if (player.stack === undefined) player.stack = null; if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0; 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 // 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
@@ -306,6 +341,12 @@ const HoldemState = {
startNextHand(state) { startNextHand(state) {
this.advanceButton(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 // 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 // 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. // night, which is the opposite of what naming them once is for.
+205
View File
@@ -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);
});
});
+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));