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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user