-
@@ -81,6 +85,11 @@
Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.
+
+ Track the board and read the showdown
+
+
+
Puts the community cards on the felt and asks for them as each street is dealt. Turn the players’ cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.
Small blind
@@ -656,6 +665,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index aca2131..64df86f 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -521,6 +521,138 @@ button,
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
header saying "Texas Hold'em Dealer" a few inches above it. */
+/* The middle of the felt: the board above, the pot below it. One box rather than two
+ loose ones, because the bet chips have to keep clear of whatever is in the middle and
+ that is a single question about a single rectangle — see feltCentreBox() in render.js. */
+#tableCentre {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ min-width: 0;
+ /* The real ceiling is set in pixels by sizeTableCentre(), from the room the seat
+ ring actually leaves. This is the answer before the first measurement and the
+ backstop if there is nothing to measure. */
+ max-width: 86%;
+ overflow: hidden;
+}
+
+/* The community cards, in the order they were turned. Sized off the felt rather than
+ the viewport: the same five cards have to sit on a 248px phone table and an 839px
+ desktop one, and a fixed width is wrong at one end or the other. */
+/* Where the board goes when the middle of the felt cannot hold it — see placeBoard()
+ in render.js. Its own strip above the table, at the same size, with nothing over it. */
+#boardStrip {
+ display: flex;
+ justify-content: center;
+ margin-bottom: var(--sp-2);
+}
+
+#boardStrip[hidden] {
+ display: none;
+}
+
+#boardStrip #boardRow {
+ width: auto;
+ max-width: 100%;
+}
+
+#boardStrip .boardCard {
+ flex: 0 0 auto;
+ min-width: 44px;
+ min-height: 48px;
+}
+
+#boardRow {
+ display: flex;
+ gap: var(--sp-1);
+ justify-content: center;
+ width: 100%;
+ max-width: 100%;
+}
+
+#boardRow[hidden] {
+ display: none;
+}
+
+.boardCard {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ /* They share whatever the middle of the felt turned out to be, down to a floor a
+ rank and a suit still fit in. Every one of these is a control that opens the
+ picker, so the floor is a thumb's worth of card rather than a legible one. */
+ flex: 1 1 0;
+ min-width: 24px;
+ max-width: 44px;
+ min-height: 44px;
+ padding: var(--sp-0) var(--sp-1);
+ border-radius: var(--r-sm);
+ border: 1px solid var(--line-strong);
+ background: linear-gradient(180deg, #f4f1e8, #ded9cb);
+ color: #16181c;
+ font-family: inherit;
+ font-size: var(--fs-md);
+ font-weight: 700;
+ line-height: 1.05;
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
+}
+
+.boardCard .boardCardSuit {
+ font-size: var(--fs-sm);
+ font-weight: 400;
+}
+
+/* A playing card is a narrow thing and a control is a 44px thing, and on a felt with
+ 150px of middle to share between five of them the two cannot both be true of the
+ drawn card. Same answer as the toast's dismiss button and the dealer puck: the hit
+ area grows past the paint. Five cards in a row means the overlap can only be
+ sideways, so it reaches out rather than all round, and the gap between two cards
+ belongs half to each. */
+.boardCard {
+ position: relative;
+}
+
+.boardCard::after {
+ content: "";
+ position: absolute;
+ top: -2px;
+ bottom: -2px;
+ left: -10px;
+ right: -10px;
+}
+
+/* Except on the strip, where the board has the whole width of the page and can simply
+ be big enough. */
+#boardStrip .boardCard::after {
+ content: none;
+}
+
+.boardCard.cardRed {
+ color: #b3202b;
+}
+
+/* A card not yet turned. Dashed and empty on the felt itself rather than a white
+ rectangle, so a board half dealt reads as three cards and two gaps at a glance —
+ which is the same language an empty chair already speaks on this table. */
+.boardCard.boardCardEmpty {
+ background: rgba(0, 0, 0, 0.28);
+ border-style: dashed;
+ border-color: rgba(255, 255, 255, 0.32);
+ color: rgba(255, 255, 255, 0.5);
+ box-shadow: none;
+ font-weight: 400;
+}
+
+@media (hover: hover) {
+ .boardCard:hover {
+ border-color: var(--gold);
+ }
+}
+
#potBadge {
position: relative;
background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55));
@@ -597,30 +729,60 @@ button,
font-variant-numeric: tabular-nums;
}
-/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
+/* Clinging to a puck rather than sitting on its own ring — the phone layout.
+
+ It has to fit the puck it is clinging to, which it did not: measured on a Galaxy S8,
+ the puck was 48px across and every one of the five chips on the felt was 50, so each
+ one hung over both edges of its own seat and three of them reached onto the seat next
+ door. The same fault the small blind badge had, on the other thing that hangs off a
+ puck — and the one that carries a number rather than two letters.
+
+ The disc is what gives way. It is 13px of decoration plus a 4px gap next to a figure
+ the dealer has to read, and dropping it takes the chip from 50px to 33 — inside the
+ narrowest puck the layout will ever draw. Its colour moves to the chip's own rim, so
+ the denomination the legend assigns is still there at no width at all. Same trade the
+ tight puck already makes with the hole cards. */
.betChipAnchored {
left: 50%;
z-index: 3;
+ max-width: 100%;
+ padding: var(--sp-0) var(--sp-1);
+ border-color: var(--betChipColour, rgba(255, 255, 255, 0.14));
}
-.betChipAbove {
+.betChipAnchored .betChipDisc {
+ display: none;
+}
+
+/* Where along the puck's edge it lands is set by render.js, which walks the seat's own
+ spoke out to just past the puck — see anchoredChipOffset(). The `top` here is only what
+ it wears before that lands, and on the one path where there is nothing to measure. */
+.betChipAnchored {
top: -11px;
transform: translate(-50%, -50%);
}
-.betChipBelow {
- top: auto;
- bottom: -11px;
- transform: translate(-50%, 50%);
-}
-
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
position: absolute;
transform: translate(-50%, -50%);
- width: 96px;
- min-height: 54px;
+ /* Set by render.js, which measures the widest puck that lets every seat keep its
+ own box on this particular felt. The 96px is what a table gets before the first
+ measurement lands, and what it keeps whenever there is room for it.
+
+ border-box so the number means the width the puck is drawn at. As content-box it
+ did not: a 64px puck measured 74 across once its padding and border were counted,
+ and the search that sets this compares it against distances on the felt. */
+ box-sizing: border-box;
+ width: var(--seatPuckW, 96px);
+ /* Every min-height on a seat is a drawn height now, border and padding included,
+ because of the box-sizing above. They were content heights before, so each one
+ here and in the three blocks that override it has gained the 8px of padding and
+ 2px of border it used to be quoted without — the puck is the size it always was.
+ Caught by the audit rather than by reading: an empty chair went from a 52px tap
+ target to a 42px one on every phone in the sweep, which is under the floor. */
+ min-height: 64px;
background: linear-gradient(160deg, var(--surface-3), #1b1b1b);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-lg);
@@ -972,14 +1134,29 @@ button,
is marked anyway and has the side benefit of making the two badges far easier to
tell apart at speed — light-on-blue against dark-on-gold, rather than two pale
labels differing only in hue. */
+/* All three sit in the same place, and inside the puck's own width.
+
+ They used to be spread along the top edge — the small blind at -6px, so hanging off
+ the left corner, the big blind at 22px — and that cost twice over. The overhang put
+ the badge onto whatever was to the left of the seat: measured on an iPhone Pro Max at
+ six seats, where the pucks were not touching at all, "SB" was painted across the next
+ player's $1,137. And two badges in two different places means the dealer's eye has to
+ find which of two spots this seat is using before it can read which blind this is,
+ forty times a night. A seat only ever wears one of these — the small and the big
+ blind are different players by construction, and the straddle chain starts left of
+ the big blind and stops short of the small one — so there was never anything for the
+ second position to avoid.
+
+ Left rather than centred because the dealer button's chip owns the top-right corner,
+ and the two are read together: who has the button, and who is posting. */
.sbBadge {
- left: -6px;
+ left: 2px;
background: linear-gradient(180deg, #2f6cb5, #22508c);
color: #eaf3ff;
}
.bbBadge {
- left: 22px;
+ left: 2px;
background: linear-gradient(180deg, #e6ad4d, #c98a2a);
color: #241a05;
}
@@ -992,7 +1169,7 @@ button,
looking for mid-hand is the one telling them where the action comes back to.
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
.strBadge {
- left: 22px;
+ left: 2px;
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
color: #1b1030;
}
@@ -1004,22 +1181,22 @@ button,
outline-offset: 1px;
}
-/* Seats close enough together that full-size pucks would overlap. The class is set
- from a measurement render.js takes off the ring itself — the closest two neighbours,
- straight line — because no media query can see it: the same 390px phone is roomy at
- six seats and crowded at nine, and a wide landscape felt crowds at its ends while
- its rim says there is space. Not scoped to a screen size for the same reason. */
+/* Seats close enough together that a puck this narrow can't carry the full layout. The
+ class is set from a measurement render.js takes off the ring itself, because no media
+ query can see it: the same 390px phone is roomy at six seats and crowded at nine, and
+ a wide landscape felt crowds at its ends while its rim says there is space. Not scoped
+ to a screen size for the same reason.
+
+ It used to carry the width too — 58px, against the roomy layout's 96 — and those two
+ numbers were the whole vocabulary the felt had. A table that needed 68 got 96 and
+ overlapped, or got 58 and threw away ten pixels of name it could have kept. The width
+ is now searched for and set as a custom property; what's left here is the typography,
+ which does still want to change in one step rather than slide. */
.seatsTight .seat {
- /* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
- cleared every overlap at nine seats where 58 left two — but in the state that
- actually matters, a hand with a side pot where the bar has grown and the felt has
- shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
- density is the felt being small, not the puck being wide, and paying for it with
- an ellipsis on every long name buys nothing. What has to hold is that each seat
- keeps its own centre, so a tap lands on the player it was aimed at, and that is
- measured at every size. */
- width: 58px;
- min-height: 38px;
+ /* 38px of content plus 6 of padding and 2 of border, which is what this drew before
+ the box became a border-box — and 46 clears the 44px a chair has to answer a thumb
+ with. A seat is a control: tapping it opens that player's form. */
+ min-height: 46px;
padding: 3px 4px;
}
@@ -3178,13 +3355,19 @@ body.hasActionBar #toastHost {
background: var(--surface-1);
border: 1px solid var(--line);
border-radius: var(--r-md);
+ /* So the fade over the bottom of the list has something to hang off. */
+ position: relative;
width: 100%;
max-width: 480px;
/* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over
the bottom of it — so the last of the wizard sat underneath the bar and its
controls couldn't be tapped. Leaving the bar's own height out of the budget
- keeps the whole dialog above it. */
- max-height: calc(85vh - var(--actionBarHeight, 0px));
+ keeps the whole dialog above it.
+ That was true while this overlay sat at z-index 50, below the bar's 55. It sits
+ at 95 now and the bar cannot draw on it, so the subtraction buys nothing and
+ costs 110px of a 640px screen — which on a Galaxy S8 is the difference between
+ a three-way pot showing all three names and showing two of them. */
+ max-height: 85vh;
/* A column of three: the title, the part that scrolls, and the buttons. The modal
itself no longer scrolls — only its middle does — which is what keeps Confirm on
screen without asking a sticky footer to do something it cannot. */
@@ -3210,8 +3393,29 @@ body.hasActionBar #toastHost {
the same full-width treatment as one that appends a pair — the winner step used to
put a lone Confirm in the left half of the dialog, looking like one option out of
several that hadn't drawn yet. */
+/* There is more list above the buttons than the dialog can show.
+
+ Measured on a Galaxy S8: three players are in the pot, the list holds three rows, and
+ two of them fit. The third was cut off exactly on a row boundary, immediately under a
+ hairline that reads as the end of the list — so the dialog that pays the table looked
+ finished with a name missing from it, and a dealer ticking the two they could see
+ would have paid two thirds of a three-way pot. The list does scroll; nothing said so.
+ Now the cut runs through a row rather than between them, under a fade, which is what
+ a list that continues looks like. */
+#potWizardModal.potWizardMoreBelow #potWizardFooter::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 100%;
+ height: 2.25rem;
+ background: linear-gradient(rgba(30, 30, 30, 0), var(--surface-1) 85%);
+ pointer-events: none;
+}
+
#potWizardFooter {
flex: none;
+ position: relative;
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-4);
@@ -3276,6 +3480,48 @@ body.hasActionBar #toastHost {
list is the only part that grows without limit anyway — the pot breakdown, the
prompt and the buttons are all a fixed size — so bounding it keeps the whole dialog
inside the screen and the button where the dealer's thumb already is. */
+/* What the tool read off the felt, above the list of names. Two of them: the one that
+ settles the pot, and the one that says why it could not. */
+.potWizardVerdict {
+ margin: 0 0 var(--sp-3);
+ padding: var(--sp-2) var(--sp-3);
+ border-radius: var(--r-md);
+ border-left: 3px solid var(--line-strong);
+ background: var(--surface-0);
+ font-size: var(--fs-base);
+ line-height: 1.4;
+}
+
+.potWizardVerdictSure {
+ border-left-color: var(--gold);
+ color: var(--text-bright);
+}
+
+/* Not a warning — the dealer picking the winner themselves is the tool's own default
+ and always has been. This says only that the reading is theirs to make this time. */
+.potWizardVerdictUnsure {
+ color: var(--text-muted);
+}
+
+/* The hand each player is holding, under their name. Muted, because the row's job is
+ still to be a name and a target — this is the working, shown so the answer above can
+ be checked rather than taken. */
+.potWizardChoiceHand {
+ color: var(--text-muted);
+ font-size: var(--fs-xs);
+ line-height: 1.3;
+}
+
+/* What this player can win, out beside the name rather than under it.
+ It used to sit in the same column as the name, and the read hand now has that line —
+ three stacked lines makes a 100px row, and a nine-handed showdown is nine of them.
+ Beside is also where the phone layout had already moved it, for the same reason. */
+.potWizardChoiceUpTo {
+ flex: none;
+ align-self: center;
+ white-space: nowrap;
+}
+
.potWizardChoices {
display: flex;
flex-direction: column;
@@ -3507,9 +3753,12 @@ body.hasActionBar #toastHost {
--feltAspect: 1;
}
+ /* No width here any more. This was 78px, and two blocks below it were 64 and 62 —
+ three guesses, at three breakpoints, at the one number that depends on something
+ a media query cannot see: how many people are sitting down. The same 390px phone
+ has room for a 90px puck at four seats and 61px at nine. render.js measures it. */
.seat {
- width: 78px;
- min-height: 44px;
+ min-height: 54px;
}
}
@@ -3566,12 +3815,11 @@ body.hasActionBar #toastHost {
inset: min(26px, 12%) 44px;
}
- /* Narrower than the desktop puck: at a full ten-handed table the ring is
- flattest at the top and bottom, and wide pucks are what made neighbours
- overlap there. The puck is only a summary — the real form is the sheet. */
+ /* The puck is only a summary — the real form is the sheet — so a phone gives it
+ tighter padding and a shorter floor. How *wide* it gets is measured, not declared
+ here: see the note on the 900px block above. */
.seat {
- width: 64px;
- min-height: 42px;
+ min-height: 52px;
padding: var(--sp-1) var(--sp-1);
}
@@ -3930,10 +4178,37 @@ body.hasActionBar #toastHost {
display: none;
}
- /* Nine seats around a table this small overlap each other, so they shrink to suit. */
+ /* The same trade, for the pot total. Measured on a phone held sideways, the badge is
+ 86x66 on a felt that is 692x113 — it takes 58% of the table's height for a number
+ the action bar is already printing three inches below it, in the line that starts
+ "PRE-FLOP Pot $65". Two seat pucks are 55px tall and the felt has to hold two rows
+ of them, so the badge is not overlapping some empty middle: it is standing in the
+ only band a player's bet chip has to go in, and the bottom-middle seat's chip had
+ nowhere left to be put but on top of its own player's name.
+
+ Upright it stays. There the felt is 248x280, the badge is a fifth of it, and it is
+ the only thing in the middle of the table. */
+ #potBadge {
+ display: none;
+ }
+
+ /* The board stays — it is the one thing in the middle of the felt that is not
+ written down anywhere else. It gets the room the pot badge gave up. */
+ .boardCard {
+ min-width: 26px;
+ min-height: 34px;
+ font-size: var(--fs-sm);
+ padding: 0 2px;
+ }
+
+ .boardCard .boardCardSuit {
+ font-size: var(--fs-xs);
+ }
+
+ /* Seats around a table this small overlap each other, so they shrink to suit — by
+ measurement now rather than by this block's old guess of 62px. */
.seat {
- width: 62px;
- min-height: 40px;
+ min-height: 50px;
}
/* The same inset the phone layout uses, which this branch never had because it is
@@ -4089,6 +4364,19 @@ textarea:focus-visible {
here so the note costs a line rather than a paragraph — the bar is a transient
input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
@media (max-height: 700px) {
+ /* The same economy for the pot dialog's list of winners, which is the other place
+ on a short screen where a line costs a name: two lines become one, a row goes
+ from 78px to 56, and a three-way pot fits without scrolling on a 640px phone.
+ The row is still 56px, so it is still a target a thumb can hit — this is where
+ the money is decided and that floor does not move. */
+ .potWizardChoice {
+ font-size: var(--fs-base);
+ }
+
+ .potWizardChoiceHand {
+ font-size: var(--fs-2xs);
+ }
+
.raiseNote {
margin-top: var(--sp-1);
font-size: var(--fs-xs);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 0c3a31f..f3bcbc9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -37,6 +37,10 @@ const ActionBar = {
this.publishFeltRoom(height);
};
+ // Kept so anything that moves the table without touching the bar can ask for
+ // the felt to be measured again — the board strip appearing above it does
+ // exactly that, and the felt's height is worked out from where the table starts.
+ this.publishHeight = publish;
if (typeof ResizeObserver === "function") {
this.heightWatcher = new ResizeObserver(publish);
this.heightWatcher.observe(host);
@@ -86,13 +90,27 @@ const ActionBar = {
// header in normal flow, so its own height never moves its top and this can't
// chase itself around the screen.
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
- document.documentElement.style.setProperty(
- "--feltRoomPx",
- `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
+ const root = document.documentElement;
+ const was = root.style.getPropertyValue("--feltRoomPx");
+ const now = `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`;
+ root.style.setProperty("--feltRoomPx", now);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
- if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
+ if (typeof Render === "undefined" || !Render.state) return;
+ if (Render.updateSeatDensity) Render.updateSeatDensity();
+
+ // And when it really did change size, the seats are laid out again rather than
+ // only re-measured.
+ //
+ // The seats follow a felt on their own — they are placed in percentages of it —
+ // but a bet chip anchored inside a seat is placed in pixels off that seat's
+ // centre, worked out against the felt as it was. Nothing re-ran that. Measured
+ // at 640x360 with five players: the chips were positioned for a felt 83px tall,
+ // painted on one of 60, and seat 3's $5 came to rest on seat 4's puck. The bar
+ // growing mid-hand does this too — a side pot appearing is 55px off the felt —
+ // so it is not only the board strip that could reach it.
+ if (now !== was && Render.renderSeats) Render.renderSeats();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
@@ -611,6 +629,19 @@ const ActionBar = {
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
// tool a dealer can't talk their way out of.
commit(label, mutate) {
+ // A toast is news about the last thing that happened, and this is the next
+ // thing: whatever it says is now out of date. Two reasons it goes rather than
+ // waiting out its eight seconds. It lies — "Next hand — button on seat 2.
+ // [Undo]" reads as an offer to move the button back, but the snapshot behind
+ // that Undo predates the hand you have since dealt, so taking it would throw
+ // the hand away without saying so. And it is in the way: measured across the
+ // sweep, a toast left up over a dealt table covered seats, the clock button
+ // or the bar's own Undo on thirty of the small screens it was checked at,
+ // because there is nowhere on a 360x640 phone for a 560px strip to go. The
+ // bar's own Undo control takes over from here, and it names the step it
+ // would actually take back.
+ if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
+
this.hooks.beginAction(label);
mutate();
this.hooks.save(this.state);
@@ -1117,6 +1148,10 @@ const ActionBar = {
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
+ // The cards that just came off the deck. Asked for here rather than left
+ // for the dealer to remember, because the moment they are holding them is
+ // the only moment they know what they are without turning the board round.
+ if (this.state.trackBoard && typeof Render !== "undefined") Render.openBoardPicker();
}));
wrap.appendChild(row);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 10f23fd..be77ed7 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -13,6 +13,15 @@
const Betting = {
STREETS: ["preflop", "flop", "turn", "river"],
+ // How many community cards are face up once a street has been dealt. The flop is
+ // three at once and everything after it is one at a time, which is why this is a
+ // table rather than an index.
+ BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
+
+ boardCardsFor(street) {
+ return this.BOARD_CARDS[street] ?? 0;
+ },
+
streetLabel(street) {
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
},
@@ -557,6 +566,7 @@ const Betting = {
state.actingSeatIndex = null;
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
+ HoldemState.clearBoard(state);
// Nothing was posted, as far as the next rotation is concerned: the hand this
// would have counted as is being dealt again from the same button.
state.postedBlinds = null;
@@ -593,6 +603,10 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
+ // A fresh deal starts on an empty felt. Dealing without moving the button —
+ // after a misdeal, or a table set up and dealt twice — would otherwise leave
+ // the last hand's board sitting there being ranked against the new cards.
+ HoldemState.clearBoard(state);
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
index 359b468..16c9ad4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js
@@ -16,45 +16,97 @@ const CardPicker = {
state: null,
hooks: null,
+ // Which set of slots is being filled: a seat's two hole cards, or the five
+ // community cards. The grid, the duplicate check and the tap-to-fill behaviour are
+ // the same job either way — what changes is where the card is written and how many
+ // slots there are to walk along.
+ target: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
+ this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
+ // The board. `limit` is how many of the five are actually out — three on the flop,
+ // four on the turn — so the sheet never offers a slot for a card the dealer has not
+ // turned over yet.
+ openBoard(state, hooks, cardIndex, limit = 5) {
+ this.state = state;
+ this.hooks = hooks;
+ this.target = "board";
+ this.seatIndex = null;
+ this.boardLimit = Math.max(1, Math.min(5, limit));
+ this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
+ this.render();
+ document.getElementById("cardPickerOverlay").hidden = false;
+ },
+
+ onBoard() {
+ return this.target === "board";
+ },
+
+ // The slots the sheet is filling, whichever set that is.
+ slotValues() {
+ return this.onBoard()
+ ? (this.state.board || []).slice(0, this.boardLimit)
+ : this.state.seats[this.seatIndex].cards;
+ },
+
+ write(index, card) {
+ if (this.onBoard()) {
+ if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
+ this.state.board[index] = card;
+ return;
+ }
+ this.state.seats[this.seatIndex].cards[index] = card;
+ },
+
close() {
document.getElementById("cardPickerOverlay").hidden = true;
},
- // Every card sitting in front of anyone else. The slot being edited is excluded,
- // so re-picking the card that's already there isn't treated as a duplicate.
+ // Every card already dealt somewhere — in front of a player or face up in the
+ // middle. The slot being edited is excluded, so re-picking the card that's already
+ // there isn't treated as a duplicate.
+ //
+ // The board belongs in here as much as the hands do, and in both directions: the
+ // seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
+ // the dealer is filling in at the time.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
- if (seat === this.seatIndex && slot === this.cardIndex) return;
+ if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
+ (this.state.board || []).forEach((card, slot) => {
+ if (!card) return;
+ if (this.onBoard() && slot === this.cardIndex) return;
+ used.set(card.toUpperCase(), "the board");
+ });
return used;
},
assign(card) {
- const player = this.state.seats[this.seatIndex];
- player.cards[this.cardIndex] = card;
+ this.write(this.cardIndex, card);
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
- // Straight on to the second card — a dealer picking hole cards is always
- // picking two, and making them re-open the picker doubles the taps.
- if (this.cardIndex === 0 && !player.cards[1]) {
- this.cardIndex = 1;
+ // Straight on to the next empty slot — a dealer picking hole cards is always
+ // picking two and a flop is always three, and making them re-open the picker
+ // between each one multiplies the taps by the only thing the sheet is for.
+ const values = this.slotValues();
+ const next = values.findIndex((value, i) => i > this.cardIndex && !value);
+ if (next !== -1) {
+ this.cardIndex = next;
this.render();
return;
}
@@ -62,29 +114,37 @@ const CardPicker = {
},
clearSlot() {
- const player = this.state.seats[this.seatIndex];
- player.cards[this.cardIndex] = "";
+ this.write(this.cardIndex, "");
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
- render() {
- const player = this.state.seats[this.seatIndex];
- const used = this.cardsInUse();
+ // What this slot is called out loud: the flop's three cards, then the turn, then
+ // the river — which is what a dealer is holding when they open this, rather than
+ // "card 4".
+ boardSlotLabel(slot) {
+ return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
+ },
- document.getElementById("cardPickerTitle").textContent =
- `${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
+ render() {
+ const used = this.cardsInUse();
+ const values = this.slotValues();
+
+ document.getElementById("cardPickerTitle").textContent = this.onBoard()
+ ? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
+ : `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
- [0, 1].forEach(slot => {
+ values.forEach((_, slot) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
- const value = player.cards[slot] || "";
+ const value = values[slot] || "";
btn.textContent = value || "—";
+ if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
@@ -105,8 +165,12 @@ const CardPicker = {
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
- if (takenBy) btn.title = `Already dealt to ${takenBy}`;
- if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
+ if (takenBy) {
+ btn.title = takenBy === "the board"
+ ? "Already face up on the board"
+ : `Already dealt to ${takenBy}`;
+ }
+ if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handRank.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handRank.js
new file mode 100644
index 0000000..d6d1eda
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handRank.js
@@ -0,0 +1,285 @@
+// What beats what.
+//
+// The dealer has always been the one who decides who won: the pot wizard asks "who won
+// the hand?" and pays whoever gets ticked. That is the right default — most hands are
+// won without a showdown, and at a home game the table reads the cards out loud between
+// them. But when the board is on the felt and the hands are on their backs, the tool
+// knows the answer, and reading it off a screen beats three people counting kickers.
+//
+// Cards are the same two-character strings the rest of the tool has always stored:
+// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
+//
+// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
+// wizard decides what to do about it.
+const HandRank = {
+ RANKS: "23456789TJQKA",
+
+ // High to low. The number is the hand's category and nothing else — kickers and
+ // the rest of the tie-breaking live in the `tiebreak` array beside it.
+ STRAIGHT_FLUSH: 8,
+ FOUR_OF_A_KIND: 7,
+ FULL_HOUSE: 6,
+ FLUSH: 5,
+ STRAIGHT: 4,
+ THREE_OF_A_KIND: 3,
+ TWO_PAIR: 2,
+ ONE_PAIR: 1,
+ HIGH_CARD: 0,
+
+ // 2 through 14. Null for anything that isn't a card, which is how a half-filled
+ // slot gets told apart from a bad one.
+ rankValue(card) {
+ if (typeof card !== "string" || card.length < 2) return null;
+ const index = this.RANKS.indexOf(card[0].toUpperCase());
+ return index < 0 ? null : index + 2;
+ },
+
+ suitOf(card) {
+ if (typeof card !== "string" || card.length < 2) return null;
+ const suit = card[card.length - 1].toUpperCase();
+ return "SHDC".includes(suit) ? suit : null;
+ },
+
+ isCard(card) {
+ return this.rankValue(card) !== null && this.suitOf(card) !== null;
+ },
+
+ // Singular and plural, for reading a hand out loud.
+ NAMES: {
+ 14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
+ 11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
+ 8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
+ 5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
+ 2: ["two", "twos"],
+ },
+
+ name(rank, plural = false) {
+ const pair = this.NAMES[rank];
+ return pair ? pair[plural ? 1 : 0] : "?";
+ },
+
+ // ---- The five-card hand -------------------------------------------------------
+
+ // Exactly five cards, ranked. Everything else is built on this.
+ //
+ // Five rather than seven because five is the hand a player actually holds, and it
+ // is small enough to check by hand — which is the whole argument for doing it this
+ // way. The seven-card version below tries all twenty-one five-card hands and keeps
+ // the best, which is a hand of poker's own definition of "your best five" written
+ // out literally. A dealer resolves one pot a hand with at most a dozen players in
+ // it; two hundred and fifty comparisons of five cards is not a cost worth trading
+ // a subtler algorithm's correctness for.
+ evaluate5(cards) {
+ if (!Array.isArray(cards) || cards.length !== 5) return null;
+ if (!cards.every(card => this.isCard(card))) return null;
+
+ const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
+ const suits = cards.map(card => this.suitOf(card));
+ const flush = suits.every(suit => suit === suits[0]);
+
+ // How many of each rank, ordered by how many first and by rank second — so
+ // the front of this list is always the rank that decides the hand.
+ const counts = new Map();
+ ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
+ const groups = [...counts.entries()]
+ .sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
+ const shape = groups.map(([, count]) => count);
+ const byGroup = groups.map(([rank]) => rank);
+
+ const straightHigh = this.straightHigh(ranks);
+
+ const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
+
+ if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
+ if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
+ if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
+ if (flush) return made(this.FLUSH, ranks);
+ if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
+ if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
+ if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
+ if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
+ return made(this.HIGH_CARD, ranks);
+ },
+
+ // The top card of the straight these five ranks make, or null.
+ //
+ // The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
+ // is a straight, and it is the *lowest* one, so its ace counts as five rather than
+ // as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
+ straightHigh(sortedDesc) {
+ const distinct = [...new Set(sortedDesc)];
+ if (distinct.length !== 5) return null;
+ if (distinct[0] - distinct[4] === 4) return distinct[0];
+ // Ace, five, four, three, two.
+ if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
+ return null;
+ },
+
+ // ---- The best five out of however many ----------------------------------------
+
+ // The best five-card hand available from five, six or seven cards — hole cards and
+ // board together, which is what Hold'em asks for. Returns null if there aren't five
+ // real cards, which is the ordinary state of a hand still being dealt.
+ best(cards) {
+ const real = (cards || []).filter(card => this.isCard(card));
+ if (real.length < 5) return null;
+ // A card in two places at once is a mis-entry, not a hand — say so rather than
+ // ranking a board that has the same king on it twice.
+ if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
+
+ let best = null;
+ this.eachFive(real, five => {
+ const hand = this.evaluate5(five);
+ if (!best || this.compare(hand, best) > 0) best = hand;
+ });
+ return best;
+ },
+
+ // Every five-card subset, without building an array of them.
+ eachFive(cards, visit) {
+ const n = cards.length;
+ for (let a = 0; a < n - 4; a++) {
+ for (let b = a + 1; b < n - 3; b++) {
+ for (let c = b + 1; c < n - 2; c++) {
+ for (let d = c + 1; d < n - 1; d++) {
+ for (let e = d + 1; e < n; e++) {
+ visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
+ }
+ }
+ }
+ }
+ }
+ },
+
+ // Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
+ // answer here rather than a rounding of one: two players playing the same board
+ // split the pot, and the fifth card decides it far more often than a table expects.
+ compare(a, b) {
+ if (!a && !b) return 0;
+ if (!a) return -1;
+ if (!b) return 1;
+ if (a.category !== b.category) return a.category - b.category;
+ const length = Math.max(a.tiebreak.length, b.tiebreak.length);
+ for (let i = 0; i < length; i++) {
+ const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+ },
+
+ // ---- Saying it out loud -------------------------------------------------------
+
+ // What a dealer says when they turn the hand over. Named to the level that settles
+ // it and no further: "two pair, aces and eights" is the call, and the kicker only
+ // gets a mention where the tool has had to use one to break a tie.
+ describe(hand) {
+ if (!hand) return "";
+ const [first, second] = hand.tiebreak;
+ switch (hand.category) {
+ case this.STRAIGHT_FLUSH:
+ return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
+ case this.FOUR_OF_A_KIND:
+ return `Four of a kind, ${this.name(first, true)}`;
+ case this.FULL_HOUSE:
+ return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
+ case this.FLUSH:
+ return `Flush, ${this.name(first)} high`;
+ case this.STRAIGHT:
+ return `Straight, ${this.name(first)} high`;
+ case this.THREE_OF_A_KIND:
+ return `Three of a kind, ${this.name(first, true)}`;
+ case this.TWO_PAIR:
+ return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
+ case this.ONE_PAIR:
+ return `Pair of ${this.name(first, true)}`;
+ default:
+ return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
+ }
+ },
+
+ // The same call with the article a table would put in front of it. Not derivable
+ // from the first letter: it is "a full house" and "a flush", but "four of a kind"
+ // and "two pair" take none at all, and "an ace high" is not something anybody says.
+ // So it belongs to the category, like the name does.
+ ARTICLED: {
+ 8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
+ 7: () => null,
+ 6: () => "a",
+ 5: () => "a",
+ 4: () => "a",
+ 3: () => null,
+ 2: () => null,
+ 1: () => "a",
+ 0: () => null,
+ },
+
+ // "wins with X" — the phrase, ready to drop into a sentence.
+ phrase(hand) {
+ if (!hand) return "the best hand";
+ const described = this.describe(hand);
+ const article = (this.ARTICLED[hand.category] || (() => null))(hand);
+ if (article === null) return described.toLowerCase();
+ if (article !== "a") return article;
+ return `a ${described.toLowerCase()}`;
+ },
+
+ // ---- The showdown -------------------------------------------------------------
+
+ // Rank a set of players against a board.
+ //
+ // `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
+ // is returned whether or not they could be ranked, in the order they were given, so
+ // the caller can show a row for each and say which ones it could not read. `place`
+ // is 1 for the winner and shared by a chop; a player whose cards aren't known has a
+ // null hand and no place.
+ //
+ // `complete` is the question the pot wizard actually asks: can this be trusted to
+ // pay the table. It is only true when the board is all five cards and every single
+ // player handed in has two of their own — one unknown hand and the tool cannot say
+ // who won, only who is winning so far, which is not the same thing and must not be
+ // dressed up as it.
+ showdown(board, players) {
+ const boardCards = (board || []).filter(card => this.isCard(card));
+ const boardComplete = boardCards.length === 5;
+
+ const ranked = (players || []).map(player => {
+ const hole = (player.cards || []).filter(card => this.isCard(card));
+ const hand = hole.length === 2 && boardComplete
+ ? this.best([...hole, ...boardCards])
+ : null;
+ return { id: player.id, cards: hole, hand, place: null };
+ });
+
+ const readable = ranked.filter(row => row.hand);
+ // A duplicated card anywhere means somebody has been mis-entered, and the
+ // ranking would be of a deck that doesn't exist.
+ const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
+ const duplicated = new Set(all).size !== all.length;
+
+ const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
+ let place = 0;
+ sorted.forEach((row, i) => {
+ if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
+ row.place = place;
+ });
+
+ const winners = sorted.filter(row => row.place === 1);
+ const complete = boardComplete && !duplicated &&
+ ranked.length > 0 && readable.length === ranked.length;
+
+ return {
+ players: ranked,
+ winners: complete ? winners.map(row => row.id) : [],
+ boardComplete,
+ duplicated,
+ complete,
+ // Who the tool could not read, so the dialog can name them rather than
+ // just declining to help.
+ unknown: ranked.filter(row => !row.hand).map(row => row.id),
+ };
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = HandRank;
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 8b00304..cc0a4d6 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -455,6 +455,15 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderTableHint();
});
+ document.getElementById("trackBoardToggle").addEventListener("change", e => {
+ state.trackBoard = e.target.checked;
+ // Turning it off leaves whatever was entered in place rather than wiping it —
+ // a dealer who taps this by mistake mid-hand can tap it back and the flop is
+ // still there. The next hand clears it the way it clears everything else.
+ hooks.save(state);
+ Render.renderAll();
+ });
+
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
index b1c182b..27feab9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -76,6 +76,39 @@ const PotWizard = {
},
renderStep() {
+ this.renderStepInto();
+ this.markOverflow();
+ },
+
+ // Say when the list runs on past the bottom of the dialog.
+ //
+ // The body scrolls and the footer stays put, which is the right shape and was as far
+ // as it went: nothing on screen said there was anything below. Measured on a Galaxy
+ // S8, a three-way pot showed two names and cut the third off on a row boundary, just
+ // under a hairline that reads as the end of the list. A dealer would have ticked the
+ // two they could see. On the dialog that pays the table, a list that silently
+ // continues is not a small thing.
+ markOverflow() {
+ const body = document.getElementById("potWizardBody");
+ const modal = document.getElementById("potWizardModal");
+ if (!body || !modal) return;
+
+ const update = () => {
+ const below = body.scrollHeight - body.clientHeight - body.scrollTop;
+ modal.classList.toggle("potWizardMoreBelow", below > 1);
+ };
+ if (!this.watchingOverflow) {
+ body.addEventListener("scroll", update, { passive: true });
+ window.addEventListener("resize", update);
+ this.watchingOverflow = true;
+ }
+ update();
+ // Again once the browser has laid the step out — on the first open the body has
+ // no height yet, and an answer taken then is always "nothing below".
+ requestAnimationFrame(update);
+ },
+
+ renderStepInto() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -124,13 +157,25 @@ const PotWizard = {
const round = document.createElement("div");
round.className = "potWizardRound";
+ const showdown = this.readShowdown(contenders);
+ const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
+ const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
+
const prompt = document.createElement("p");
prompt.className = "potWizardPrompt";
- prompt.innerHTML = this.log.length === 0
- ? `Who won the hand? ${Money.format(remaining)} is in play.`
- : `That side pot still leaves ${Money.format(remaining)} unassigned — who's next best?`;
+ prompt.innerHTML = decided
+ // It has read the hands, so it stops asking a question it knows the answer
+ // to. The list stays — the dealer confirms, and a hand entered wrong is
+ // corrected by unticking rather than by starting over.
+ ? `${Money.format(remaining)} in play.`
+ : this.log.length === 0
+ ? `Who won the hand? ${Money.format(remaining)} is in play.`
+ : `That side pot still leaves ${Money.format(remaining)} unassigned — who's next best?`;
round.appendChild(prompt);
+ const verdict = this.renderVerdict(showdown, contenders);
+ if (verdict) round.appendChild(verdict);
+
const choices = document.createElement("div");
choices.className = "potWizardChoices";
contenders.forEach(c => {
@@ -140,6 +185,10 @@ const PotWizard = {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
+ if (decided && decided.has(c.id)) {
+ checkbox.checked = true;
+ label.classList.add("chosen");
+ }
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
@@ -154,11 +203,25 @@ const PotWizard = {
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
+ // How this hand was read, under the name — on every row rather than only
+ // the winning one, because a dealer checking the tool's answer is reading
+ // the losers' hands, and a row that says nothing is a row they have to work
+ // out for themselves. It takes the line the ceiling used to have; the
+ // ceiling moves out beside the name, which is where the phone layout had
+ // already put it to save exactly this line.
+ const read = handById.get(c.id);
+ if (read) {
+ const ranked = document.createElement("span");
+ ranked.className = "potWizardChoiceHand";
+ ranked.textContent = HandRank.describe(read);
+ text.appendChild(ranked);
+ }
+ label.appendChild(text);
+
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
- text.appendChild(upTo);
- label.appendChild(text);
+ label.appendChild(upTo);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
@@ -187,7 +250,11 @@ const PotWizard = {
const confirmBtn = document.createElement("button");
confirmBtn.className = "holdemBtn holdemBtnAccent";
- confirmBtn.textContent = "Confirm Winner(s)";
+ confirmBtn.textContent = decided
+ ? (showdown.winners.length === 1
+ ? `Pay ${this.nameById.get(showdown.winners[0])}`
+ : "Split the pot")
+ : "Confirm Winner(s)";
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
@@ -200,6 +267,84 @@ const PotWizard = {
footer.appendChild(confirmBtn);
},
+ // How the tool reads the hands still in for this round of the pot.
+ //
+ // Asked per round rather than once for the whole hand, because a side pot has its
+ // own contenders: the player who won the main pot is out of the question for the
+ // next one, and the best hand among who is left is a different answer.
+ //
+ // Only when the dealer asked to track the board — everything here is off by default
+ // and the wizard behaves exactly as it always has.
+ readShowdown(contenders) {
+ if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
+ return HandRank.showdown(this.state.board, contenders.map(c => ({
+ id: c.id,
+ cards: this.cardsById.get(c.id) || [],
+ })));
+ },
+
+ // What the winners would actually be paid if this round were confirmed now.
+ //
+ // Worked out from the same function that does the paying rather than from a second
+ // one that agrees with it today — a preview that can drift from the payout is worse
+ // than no preview, because it is the number the dealer says out loud.
+ previewPayout(winnerIds) {
+ try {
+ const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
+ return result.perWinner || [];
+ } catch {
+ return [];
+ }
+ },
+
+ // "Alexandra wins $25 with a full house, kings full of threes."
+ //
+ // The whole point of the feature, in one line: who won and how much. It goes above
+ // the list rather than replacing it, because the dealer still confirms — the tool
+ // reads the cards, a person still says the pot is good.
+ renderVerdict(showdown, contenders) {
+ if (!showdown) return null;
+
+ const line = document.createElement("p");
+ line.className = "potWizardVerdict";
+
+ if (!showdown.boardComplete) {
+ line.classList.add("potWizardVerdictUnsure");
+ line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
+ return line;
+ }
+ if (showdown.duplicated) {
+ line.classList.add("potWizardVerdictUnsure");
+ line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
+ return line;
+ }
+ if (!showdown.complete) {
+ const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
+ line.classList.add("potWizardVerdictUnsure");
+ line.textContent = missing.length
+ ? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
+ : "Not enough cards to read this one.";
+ return line;
+ }
+
+ const paid = this.previewPayout(showdown.winners);
+ const amount = paid.length ? Money.format(paid[0].amount) : "";
+ const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
+ const hand = showdown.players.find(p => p.id === showdown.winners[0]);
+ const described = HandRank.phrase(hand && hand.hand);
+
+ line.classList.add("potWizardVerdictSure");
+ line.textContent = names.length === 1
+ ? `${names[0]} wins ${amount} with ${described}.`
+ : `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
+ return line;
+ },
+
+ listOf(names) {
+ if (names.length <= 1) return names[0] || "";
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+ },
+
// The row of buttons that never scrolls away.
footer() {
return document.getElementById("potWizardFooter");
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 2e0247f..b45a272 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -28,6 +28,7 @@ const Render = {
this.selectedSeatIndex = null;
}
this.renderSeats();
+ this.renderBoard();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
@@ -45,6 +46,7 @@ const Render = {
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
+ document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
@@ -169,7 +171,10 @@ const Render = {
// finishes inside the table. Falls back to the old pair whenever there is nothing to
// measure, which is every call from the unit tests and the first render before the
// felt has been laid out.
- seatRingRadii() {
+ // `puckSize` is the footprint the caller has just measured, badges included. Without
+ // one this falls back to the first chair on the felt, which is what it always did —
+ // and which is wrong whenever some other chair is the taller one.
+ seatRingRadii(puckSize = null) {
const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY };
if (typeof document === "undefined") return fallback;
@@ -179,7 +184,7 @@ const Render = {
if (!box || !box.width || !box.height || !table || !puck) return fallback;
const tableBox = table.getBoundingClientRect();
- const puckBox = puck.getBoundingClientRect();
+ const puckBox = puckSize || puck.getBoundingClientRect();
if (!tableBox.width || !puckBox.width) return fallback;
// Worked in absolute pixels rather than from half-widths, because the seat
@@ -238,14 +243,42 @@ const Render = {
// The two answers are within half a degree of each other, so this is not a fix for
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
- betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
+ betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii(), spacing = null) {
const seat = this.seatPosition(index, count, aspect, radii);
- // Fractions of the seat's own reach, not of the felt — so when the ring pulls in
- // to keep the pucks on the table, the chips come with it and stay on the same
- // spoke, the same proportion of the way to the pot.
+ if (!spacing || !spacing.box || !spacing.box.width || !spacing.box.height) {
+ // Fractions of the seat's own reach, not of the felt — so when the ring pulls
+ // in to keep the pucks on the table, the chips come with it and stay on the
+ // same spoke, the same proportion of the way to the pot. This is what the
+ // ring always did, and it is still the answer when there is nothing to
+ // measure: every call from the unit suite, and the first render.
+ return {
+ left: 50 + (seat.left - 50) * (27 / 44),
+ top: 50 + (seat.top - 50) * (25 / 42),
+ };
+ }
+
+ // With measurements to hand, the chip is placed by clearance rather than by
+ // proportion. A fixed fraction is a guess about how much room there is between a
+ // puck's inner edge and the pot, and the answer changes with the shape of the
+ // felt: on a phone held sideways the ring pulls right in, 27/44 of a small reach
+ // is inside the puck, and every one of the five chips on the felt was painted
+ // behind a seat — the seats carry `z-index: 1` and the bets layer carries none,
+ // so a chip that lands under a puck is simply gone.
+ const { box, puck, chip, clearance } = spacing;
+ const dx = ((seat.left - 50) / 100) * box.width;
+ const dy = ((seat.top - 50) / 100) * box.height;
+ const reach = Math.hypot(dx, dy);
+ if (reach < 1) return { left: seat.left, top: seat.top };
+
+ const ux = dx / reach, uy = dy / reach;
+ const inset = this.clearanceReach(puck, chip, ux, uy) + clearance;
+ // Never nearer the middle than this: the pot badge lives there, and a chip on
+ // top of the pot total is the same fault at the other end of the spoke.
+ const pulled = Math.max(reach * 0.38, reach - inset);
+ const scale = pulled / reach;
return {
- left: 50 + (seat.left - 50) * (27 / 44),
- top: 50 + (seat.top - 50) * (25 / 42),
+ left: 50 + ((dx * scale) / box.width) * 100,
+ top: 50 + ((dy * scale) / box.height) * 100,
};
},
@@ -364,32 +397,37 @@ const Render = {
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
- // Called on every render and again whenever the felt is resized — the action bar
- // growing mid-hand shortens the table, and the seats have to answer that without
- // waiting for the next render to come along.
- // Which layout the felt gets, and how wide its ring is.
- //
- // These two answers depend on each other, which is what made the first attempt at
- // this wobble: the ring's radius is limited by the size of a puck, the puck's size
- // is set by the `seatsTight` class, and that class was being decided from the gaps
- // between seats — measured at a radius that hadn't been worked out yet. Fixing the
- // gap measurement to use the real radius then made a nine-handed phone overhang
- // that had been fine, because now the two were chasing each other.
- //
- // So it is settled by trying the candidates in order of preference rather than by
- // solving it in one pass: the roomy layout if everything fits, the tight one if it
- // doesn't, and if neither fits, the tight pucks on a ring wide enough that seats
- // don't overlap — because two seats fighting over a tap is a worse failure than a
- // puck sitting slightly over the rail. A dealer can see an overhang; they cannot
- // see that the seat they meant to press wasn't the one that answered.
+ // The range a puck may be drawn at. The bottom of it is where a name stops being a
+ // name: at 44px the tight layout shows about five characters and an ellipsis, which
+ // is still "Chris…" rather than nothing.
+ SEAT_WIDTH_MAX: 96,
+ SEAT_WIDTH_MIN: 44,
+ // The step the search walks down in. Six pixels is finer than the difference between
+ // two font sizes and coarse enough to settle in ten tries.
+ SEAT_WIDTH_STEP: 6,
+ // Below this a puck cannot hold two lines of name and a row of hole cards, so the
+ // typography drops to the tight set in one go. Width scales continuously; type
+ // changes deliberately, which is the split the modern guidance actually draws —
+ // clamp for the continuum, a query for the things meant to be discrete.
+ SEAT_TIGHT_BELOW_PX: 76,
+ // Daylight between two pucks. They are rounded, so touching reads as overlapping.
+ SEAT_CLEARANCE_PX: 4,
+ // And between a puck's inner edge and the chip sitting in front of it.
+ BET_CLEARANCE_PX: 5,
+ // How far a blind badge hangs off the puck it belongs to — the `top: -9px` and
+ // `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
+ // takes no room between two seats; what it must not do is reach *sideways*, which is
+ // what seatFootprint.test.js holds the stylesheet to.
+ SEAT_BADGE_OVERHANG_PX: 9,
+
// How much space two neighbouring seats need between their centres.
//
- // This was a flat 84px, which is the figure a 96px-wide puck was judged against —
- // seats allowed to sit a little closer than their own width, since the pucks are
- // rounded and a small overlap at the corners reads fine. Asking the *tight* layout
- // to clear the same 84px is asking a 68px puck to keep a 96px puck's distance: it
- // could never pass, so every landscape table fell through to the fallback and took
- // an overhang it didn't need. The ratio is what carries over, not the number.
+ // Kept for closestSeatGapPx's sake and no longer what decides the layout. It was a
+ // proxy — a single distance standing in for "do these two boxes want the same
+ // pixels" — and the answer it gave was wrong in both directions: a 96px puck was
+ // allowed within 84px of its neighbour on the theory that rounded corners forgive a
+ // little overlap, and the overlap it forgave turned out to be 20px of a stack
+ // figure. seatsCollide asks the real question instead.
SEAT_ROOM_RATIO: 84 / 96,
seatRoomNeededPx() {
@@ -400,34 +438,198 @@ const Render = {
return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX;
},
+ // Does any pair of seats want the same pixels?
+ //
+ // Two equal axis-aligned boxes overlap exactly when their centres are closer than
+ // one box in *both* axes, so this answers the question rather than standing in for
+ // it. The distance test it replaces could not: distance has one number in it and
+ // the pucks are half again as wide as they are tall, so the same 84px was generous
+ // sideways and nowhere near enough on the diagonal.
+ //
+ // Every pair, not only neighbours — this is the half that was actually missing.
+ // On a felt 528px wide and 87 tall the ring is very nearly a straight line, and the
+ // seat that lands on top of you is the one across the table rather than the one
+ // beside you. Measured on the running page before this went in: ten of the tables
+ // in the sweep had a collision between two seats that a neighbours-only check
+ // cannot see, five of them at 640x360, where seats 1 and 7 overlapped by 68px.
+ seatsCollide(count, box, radii, width, height, clearance = this.SEAT_CLEARANCE_PX) {
+ if (count < 2 || !box || !box.width || !box.height) return false;
+ const room = { x: width + clearance, y: height + clearance };
+ const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height)
+ .map(seat => ({
+ x: (seat.left / 100) * box.width,
+ y: (seat.top / 100) * box.height,
+ }));
+ for (let a = 0; a < points.length; a++) {
+ for (let b = a + 1; b < points.length; b++) {
+ if (Math.abs(points[a].x - points[b].x) < room.x &&
+ Math.abs(points[a].y - points[b].y) < room.y) return true;
+ }
+ }
+ return false;
+ },
+
+ // The box a seat actually takes up, at whatever width is applied right now.
+ //
+ // Widest and tallest are taken separately and across every chair, because a
+ // two-line name makes one puck taller than its neighbours and the ring has to have
+ // room for the worst of them, not the first one it measures.
+ measuredSeatBox() {
+ if (typeof document === "undefined") return null;
+ const seats = document.querySelectorAll("#seatsLayer .seat");
+ if (!seats.length) return null;
+ let width = 0, height = 0;
+ seats.forEach(seat => {
+ const box = seat.getBoundingClientRect();
+ width = Math.max(width, box.width);
+ height = Math.max(height, box.height);
+ });
+ return width && height ? { width, height } : null;
+ },
+
+ // What two seats have to clear each other by, which is the puck and nothing else.
+ //
+ // The blind badge was the reason to think otherwise, and it was worth chasing: "SB"
+ // was painted across the next player's $1,137 on an iPhone Pro Max whose pucks were
+ // not touching at all, so something was reaching past a puck's own box.
+ //
+ // It was reaching sideways. The badge sat at `left: -6px`, hanging off the puck's
+ // left corner onto whatever was beside it, and that is fixed where it was caused —
+ // in the stylesheet, by moving the badges inside the puck's own width.
+ //
+ // Its remaining overhang is vertical and costs nothing, because it always points
+ // *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower
+ // one, so two seats facing each other across the felt wear their badges back to
+ // back. Two seats on the same arc wear them at the same height, offset by exactly
+ // as much as the pucks are.
+ //
+ // Adding it anyway was tried first, and it broke the case it was least expected to:
+ // a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave
+ // 18px between them, and demanding 18px more for badges that point outward made a
+ // six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three
+ // phones went the same way. The clearance below is what keeps rounded corners from
+ // reading as a touch.
+ seatFootprint(puckBox) {
+ return puckBox && { width: puckBox.width, height: puckBox.height };
+ },
+
+ applySeatWidth(layer, width) {
+ layer.style.setProperty("--seatPuckW", `${width}px`);
+ layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX);
+ },
+
+ // Widest first, because the widest that fits is the one we want.
+ seatWidthCandidates() {
+ const widths = [];
+ for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) {
+ widths.push(w);
+ }
+ widths.push(this.SEAT_WIDTH_MIN);
+ return widths;
+ },
+
+ // What the table has changed about the shape of its pucks. Anything that can make a
+ // name wrap to a second line belongs here, because the height that comes back from
+ // measuring is what the ring is sized against.
+ seatShapeSignature() {
+ if (!this.state) return "";
+ return `${this.state.seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}`;
+ },
+
+ // Which width the pucks get, and how wide a ring they sit on.
+ //
+ // This used to choose between two numbers — 96px or 58px — and when neither fitted
+ // it widened the ring and let the seats overlap, on the reasoning that a dealer can
+ // see an overhang but cannot see a tap landing on the wrong player. That trade was
+ // real and it was also a false choice, because the tables that overlapped did not
+ // want either number. Measured across the sweep at nine seats: a Galaxy S8 had room
+ // for a 61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79.
+ // Handing all four the same 58 or the same 96 is how a puck ends up twenty pixels
+ // inside its neighbour with a stack figure underneath it — which is what the running
+ // page did, on every phone in the list, at the most ordinary table there is.
+ //
+ // So the width is searched for rather than chosen. There is a width that works on
+ // every table in the sweep, down to a dozen seats on the smallest phone: the code
+ // was never asking for it.
+ //
+ // Walked from the top rather than bisected. Feasibility is *nearly* monotone — a
+ // narrower puck sits on a wider ring and has a smaller box — but not quite, because
+ // a narrower puck can wrap a name onto a second line and come back taller. Walking
+ // down takes the widest that actually measures clean, which is the honest answer;
+ // bisection would take the widest that a model of the puck says is clean.
+ //
+ // Measuring is the expensive half — each width has to be applied and read back, and
+ // that is a forced layout apiece — so it happens once per width, up front. Which
+ // ring each width then sits on is arithmetic on numbers already in hand.
+ //
+ // The ring is allowed to grow past the one that keeps every seat on the felt, but
+ // only after every width has been tried on the contained one. That order is the
+ // trade the old code named and got right: a dealer can see a puck over the rail;
+ // they cannot see that the seat they pressed wasn't the one that answered. It only
+ // ever comes up on a phone held sideways, where the felt is a 692x117 slot.
+ RING_GROWTH_STEPS: 8,
+
resolveSeatLayout(count) {
const layer = document.getElementById("seatsLayer");
if (!layer) return { radii: this.seatRingRadii() };
- for (const tight of [false, true]) {
- layer.classList.toggle("seatsTight", tight);
- const radii = this.seatRingRadii();
- const roomy = this.closestSeatGapPx(count, this.ringBox(), radii) >= this.seatRoomNeededPx();
- if (roomy && !this.tooTallForTheTable()) return { radii };
+ const box = this.ringBox();
+ const key = box &&
+ `${Math.round(box.width)}x${Math.round(box.height)}:${count}:${this.seatShapeSignature()}`;
+ // Ten candidate widths mean ten forced layouts, and renderSeats runs after every
+ // tap a dealer makes. Nothing in the key changes between one tap and the next
+ // unless the table itself has, so within a hand this is measured once and read
+ // back afterwards.
+ if (key && this.seatLayoutCache && this.seatLayoutCache.key === key) {
+ this.applySeatWidth(layer, this.seatLayoutCache.width);
+ return this.seatLayoutCache;
}
- // Neither fits — a phone held sideways gives the felt about 140px of height and
- // there is no ring that puts a dozen readable pucks inside that. Keep the small
- // pucks and widen the ring only as far as it takes to stop the seats colliding,
- // rather than all the way back out: going straight to the full width bought an
- // overhang of 14px where 4 would have done.
- layer.classList.add("seatsTight");
- const contained = this.seatRingRadii();
- const box = this.ringBox();
- for (let step = 1; step <= 8; step++) {
- const t = step / 8;
- const radii = {
- rx: contained.rx + (this.RING_MAX_RX - contained.rx) * t,
- ry: contained.ry + (this.RING_MAX_RY - contained.ry) * t,
- };
- if (this.closestSeatGapPx(count, box, radii) >= this.seatRoomNeededPx()) return { radii };
+ const candidates = [];
+ for (const width of this.seatWidthCandidates()) {
+ this.applySeatWidth(layer, width);
+ const puck = this.measuredSeatBox();
+ if (!puck) {
+ // Nothing laid out yet — the very first render. Take the widest and let
+ // the resize that follows do this properly.
+ const radii = this.seatRingRadii();
+ if (key) this.seatLayoutCache = { key, width, radii };
+ return { width, radii };
+ }
+ candidates.push({
+ width,
+ contained: this.seatRingRadii(puck),
+ footprint: this.seatFootprint(puck),
+ tooTall: this.tooTallForTheTable(),
+ });
}
- return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
+
+ const fits = (candidate, radii) => !candidate.tooTall &&
+ !this.seatsCollide(count, box, radii, candidate.footprint.width, candidate.footprint.height);
+
+ let chosen = null;
+ for (let step = 0; step <= this.RING_GROWTH_STEPS && !chosen; step++) {
+ const t = step / this.RING_GROWTH_STEPS;
+ for (const candidate of candidates) {
+ const radii = {
+ rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
+ ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t,
+ };
+ if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; }
+ }
+ }
+
+ // Nothing cleared at any width on any ring. Keep the narrowest puck on the
+ // widest ring, and let the audit's "no seat covers another seat's writing" say
+ // so out loud rather than the layout quietly papering over it.
+ if (!chosen) {
+ const last = candidates[candidates.length - 1];
+ chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
+ }
+
+ this.applySeatWidth(layer, chosen.width);
+ if (key) this.seatLayoutCache = { key, ...chosen };
+ return chosen;
},
updateSeatDensity() {
@@ -511,6 +713,9 @@ const Render = {
makeBetChip(bet) {
const chip = document.createElement("div");
chip.className = "betChip";
+ // The denomination's colour, published for the anchored layout to put on the
+ // chip's rim — there the disc itself is 17px the puck hasn't got.
+ chip.style.setProperty("--betChipColour", this.chipColorFor(bet));
const disc = document.createElement("span");
disc.className = "betChipDisc";
@@ -525,41 +730,340 @@ const Render = {
return chip;
},
+ // How far it is from the centre of an axis-aligned box to its edge, in a given
+ // direction. Exact, and it has to be: a puck is half again as wide as it is tall, so
+ // a single radius would be wrong on one axis or the other.
+ edgeReach(width, height, ux, uy) {
+ const ax = Math.abs(ux), ay = Math.abs(uy);
+ return Math.min(ax > 1e-6 ? (width / 2) / ax : Infinity,
+ ay > 1e-6 ? (height / 2) / ay : Infinity);
+ },
+
+ // How far one box has to travel from the centre of another, along a given direction,
+ // before the two are clear of each other.
+ //
+ // Not `edgeReach(puck) + edgeReach(chip)`, which is what this used to be and is only
+ // right when the direction is straight up, down or sideways. Two axis-aligned boxes
+ // are clear when they are apart on *one* axis, so the distance is the reach of the
+ // one box you get by adding their widths and adding their heights — and adding the
+ // two reaches is always less than that. Measured at 768x1024, a $10 leaving a seat on
+ // the diagonal wanted 88px and was given 77, and the chip came to rest 11px inside
+ // its own player's puck. Every seat off the four compass points had the same error,
+ // which is most of them on any table with more than four players.
+ clearanceReach(a, b, ux, uy) {
+ return this.edgeReach(a.width + b.width, a.height + b.height, ux, uy);
+ },
+
+ // Where an anchored chip sits relative to its own puck's centre: on the spoke,
+ // just clear of the puck's edge, on the side facing the pot.
+ //
+ // This started as four choices — above, below, and the two sides — which is better
+ // than the one it replaced (always above or below, so a seat at the side of the ring
+ // wore its bet over its own name) and still wrong, because four choices have three
+ // seams. Measured on a Galaxy S8: seat 4 is on the right arc and took the left edge,
+ // seat 5 is on the bottom arc and took the top edge, and at the corner between the
+ // two arcs their chips overlapped by 11px. The seam is the bug, so there are no
+ // sides now — the direction is the spoke itself, which is also where a chip actually
+ // goes. A player on the right of the table pushes their bet to their left.
+ // Off the rim rather than down the spoke, which are the same direction only on a
+ // round table. A phone held sideways gives the felt 692x117: the ring is a slot, the
+ // spoke to a seat on the top arc points diagonally inward, and "inward" there runs
+ // along the rim straight into the next seat. Off the rim it is very nearly straight
+ // down, which is where the empty felt is. The inward normal of the ellipse the ring
+ // is drawn on gives both answers from one expression.
+ anchoredChipOffset(seatPos, puck, chip, box, radii, clearance = this.BET_CLEARANCE_PX) {
+ const { x: ux, y: uy } = this.rimNormal(seatPos, box, radii);
+ const travel = this.clearanceReach(puck, chip, ux, uy) + clearance;
+ return { x: ux * travel, y: uy * travel };
+ },
+
+ // Straight off the rim, inward: the unit normal of the ellipse the seat ring is
+ // drawn on, at that seat.
+ rimNormal(seatPos, box, radii) {
+ const dx = ((seatPos.left - 50) / 100) * box.width;
+ const dy = ((seatPos.top - 50) / 100) * box.height;
+ const a = Math.max(1, (radii.rx / 100) * box.width);
+ const b = Math.max(1, (radii.ry / 100) * box.height);
+ const nx = -dx / (a * a);
+ const ny = -dy / (b * b);
+ const length = Math.hypot(nx, ny);
+ // A seat sitting on the middle of the felt has no rim to be off. Send its chip
+ // upward rather than dividing by nothing.
+ if (length < 1e-9) return { x: 0, y: -1 };
+ return { x: nx / length, y: ny / length };
+ },
+
+ // What is standing in the middle of the felt, in the bets layer's own pixels.
+ //
+ // The pot total and, when the dealer is tracking it, the board above it. Both live
+ // on `#pokerTable` rather than on either overlay, and the two are not concentric —
+ // the layers are inset by different amounts top and bottom — so this is measured
+ // rather than assumed to be at 50%, 50%. One box for the pair of them, because a
+ // chip keeping clear of the middle is one question, not two.
+ feltCentreBox(box) {
+ const centre = document.getElementById("tableCentre") || document.getElementById("potBadge");
+ if (!centre) return null;
+ const at = centre.getBoundingClientRect();
+ if (!at.width || !at.height) return null;
+ return {
+ x: at.left + at.width / 2 - box.left,
+ y: at.top + at.height / 2 - box.top,
+ width: at.width, height: at.height,
+ };
+ },
+
+ // How far each anchored chip actually gets to travel, once the others are taken
+ // into account.
+ //
+ // Off the rim is only somewhere to go if there is felt out there. At 640x360 the
+ // table is 528x83 and a seat puck is 55 of those 83 pixels tall: clearing it takes
+ // 41px, the players facing each other across the table are 70px apart, and both
+ // chips set off 41px into a gap that is 70 wide. Measured on the running site, a
+ // twelve-handed table had three pairs of chips on top of each other and a six-handed
+ // one had two — every pair a number about one player's money hiding a number about
+ // another's.
+ //
+ // Each chip asks for the room it wants and gives ground only to a chip it is
+ // actually on top of, a pixel at a time until the pair is clear. Two rules that
+ // looked simpler were tried first and both cost more than they saved. Stopping every
+ // chip at the felt's midline punishes the seats at the ends of a long rail, which
+ // are 70px from their nearest neighbour and were never going to hit anything —
+ // measured at 692x113, it parked two chips on top of their own player's stack to
+ // solve a collision that did not exist. Scaling every chip's travel by one factor
+ // has the same fault in the other direction: the end seats give up the same
+ // proportion as the crowded ones and end up inside their own pucks.
+ //
+ // Where a chip is pulled all the way back it rests on its own puck's inner edge.
+ // Overlapping the edge of your own puck costs a corner of your own stack;
+ // overlapping the chip opposite costs both amounts outright.
+ CHIP_PULLBACK_STEP_PX: 1,
+ CHIP_PULLBACK_PASSES: 80,
+
+ anchoredChipTravel(chips, count, aspect, radii, spacing) {
+ const { box, puck, clearance, pot } = spacing;
+ const centreOf = pos => ({ x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height });
+ const pucks = Array.from({ length: count }, (_, i) =>
+ centreOf(this.seatPosition(i, count, aspect, radii)));
+
+ const rows = chips.map(({ index, width, height }) => {
+ const seatPos = this.seatPosition(index, count, aspect, radii);
+ const dir = this.rimNormal(seatPos, box, radii);
+ const want = this.clearanceReach(puck, { width, height }, dir.x, dir.y) + clearance;
+ return { index, width, height, dir, travel: want, seat: centreOf(seatPos) };
+ });
+ const at = row => ({ x: row.seat.x + row.dir.x * row.travel,
+ y: row.seat.y + row.dir.y * row.travel });
+ // Touching counts as clear. Two boxes that share an edge to within half a pixel
+ // are not hiding anything from anybody, and treating that as a collision costs a
+ // whole step of travel: on a felt where the seats are symmetric about the middle,
+ // a facing pair lands exactly edge to edge and every one of them was then pulled
+ // back another pixel for nothing.
+ const TOUCHING = 0.5;
+ const hits = (a, b, w, h) =>
+ Math.abs(a.x - b.x) < w - TOUCHING && Math.abs(a.y - b.y) < h - TOUCHING;
+
+ for (let pass = 0; pass < this.CHIP_PULLBACK_PASSES; pass++) {
+ let moved = false;
+ const back = row => {
+ if (row.travel <= 0) return;
+ row.travel = Math.max(0, row.travel - this.CHIP_PULLBACK_STEP_PX);
+ moved = true;
+ };
+
+ // Somebody else's puck. It cannot move, so the chip does all the giving —
+ // and it is the neighbour's puck that matters, not its own, which the chip
+ // is anchored to and is allowed to rest on. Off the rim on a felt 83px tall
+ // is straight into the row of seats opposite: measured at 640x360, six of
+ // the seven chips on a ten-handed table were painted under a stranger's
+ // puck, which on this layer means painted out of existence.
+ rows.forEach(row => {
+ const p = at(row);
+ if (pucks.some((seat, i) => i !== row.index &&
+ hits(p, seat, (row.width + puck.width) / 2,
+ (row.height + puck.height) / 2))) back(row);
+ });
+
+ // And the pot total, which is what is in the middle of the felt and the one
+ // number on the table that belongs to everybody. A seat on the middle of a
+ // rail sends its chip straight at it: on a Galaxy S8 on its side, seat 1's
+ // $10 was sitting on top of "Pot $65".
+ if (pot) {
+ rows.forEach(row => {
+ if (hits(at(row), pot, (row.width + pot.width) / 2,
+ (row.height + pot.height) / 2)) back(row);
+ });
+ }
+
+ for (let a = 0; a < rows.length; a++) {
+ for (let b = a + 1; b < rows.length; b++) {
+ if (!hits(at(rows[a]), at(rows[b]),
+ (rows[a].width + rows[b].width) / 2,
+ (rows[a].height + rows[b].height) / 2)) continue;
+ // The one that has come further gives way; if they are level, both
+ // do, which is what a facing pair across a narrow table always is.
+ const lead = Math.max(rows[a].travel, rows[b].travel);
+ [rows[a], rows[b]].forEach(row => {
+ if (row.travel < lead - 0.01) return;
+ back(row);
+ });
+ }
+ }
+ if (!moved) break;
+ }
+
+ const travels = {};
+ rows.forEach(row => {
+ travels[row.index] = { x: row.dir.x * row.travel, y: row.dir.y * row.travel };
+ });
+ return travels;
+ },
+
// A phone's felt has no room for a third ring: measured every radius between the
// seats and the pot badge and each one collides with one or the other. So on a
// narrow screen the bet clings to its own puck instead — same chip, no ring —
// sitting on whichever side faces the middle of the table.
- renderBets() {
+ //
+ // `radii` is the ring the seats were actually drawn on, handed down by renderSeats.
+ // Asking for it again here was wrong once the ring stopped being a fixed pair: the
+ // width search settles on a radius that can be wider than the one seatRingRadii()
+ // derives on its own, so the chips were being laid out on a ring the seats had left.
+ renderBets(aspect = this.feltAspect(), radii = this.seatRingRadii()) {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
+ // The phone layout puts the chip *inside* its seat, and seats are no longer
+ // thrown away and rebuilt on every render — they are kept and updated in place,
+ // which is what made renderAll fast enough to run after every tap. Clearing the
+ // layer therefore stopped clearing these, and nothing else ever did: measured on
+ // an iPhone 14, a seat had one chip after the deal, four after three more
+ // renders, and five by the third call. A dealer's every tap left another disc on
+ // the felt, and the pile only ever grew.
+ document.querySelectorAll(".seat .betChip").forEach(chip => chip.remove());
- const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
- // Measured once for the whole ring. Asked per seat, this reads the layout back
- // nine times in a loop that is also writing to it, which is the read-after-write
- // pattern that makes the browser recompute layout on every iteration.
- const aspect = this.feltAspect();
- const radii = this.seatRingRadii();
-
+ // Put them on the felt first, position them second. A chip has to be in the
+ // document before it can say how wide it is, and how wide it is decides both
+ // where it goes and whether there is room for a ring at all — so this is one
+ // write, one read, one write, rather than a read for every chip in a loop that
+ // is also writing to the same layout.
+ const placed = [];
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
-
const chip = this.makeBetChip(bet);
- const pos = this.betPosition(index, this.state.seatCount, aspect, radii);
+ layer.appendChild(chip);
+ placed.push({ chip, index });
+ });
+ if (!placed.length) return;
+ const box = this.ringBox();
+ const puck = this.measuredSeatBox();
+ // Each chip's own size, not the first one's standing in for all of them. A chip
+ // is as wide as the number on it: measured on a phone held sideways, "$5" is
+ // 21px and "$10" is 27px, so taking the first chip's width laid out every $10 on
+ // the felt as if it were 6px narrower than it is — six pixels of somebody else's
+ // puck. Taking the *widest* instead is wrong the other way: at 768x1024 a single
+ // 47px chip made the whole table give up a ring that the other four had room for.
+ const sizes = placed.map(({ chip, index }) => {
+ const at = chip.getBoundingClientRect();
+ return { index, width: at.width, height: at.height };
+ });
+ if (!box || !puck || !sizes.some(s => s.width)) return;
+ const spacing = { box, puck, clearance: this.BET_CLEARANCE_PX, pot: this.feltCentreBox(box) };
+
+ // Whether the chips get a ring of their own is a question about how much felt
+ // there is between the seats and the pot, and it was being answered by asking
+ // how wide the *window* is. A phone held sideways is 844px across and has 117px
+ // of felt to work with: it took the ring, and every chip on it was painted behind
+ // a puck — the seats carry `z-index: 1` and the bets layer carries none, so a
+ // chip that lands under a seat is simply gone.
+ //
+ // Measured instead, and only measured. The width test was left in front of the
+ // measurement as a belt-and-braces `||`, which meant every phone still took the
+ // anchored layout whatever the felt said — including the ones with room for a
+ // ring, where clinging to the puck is the worse of the two: a chip that has been
+ // pulled back onto its own puck sits on that player's stack.
+ const anchorToSeat = !this.chipsClearTheSeats(sizes, aspect, radii, spacing);
+ // Worked out across all of them at once, because how far one chip can go off the
+ // rim depends on where the others have got to.
+ const offsets = anchorToSeat
+ ? this.anchoredChipTravel(sizes, this.state.seatCount, aspect, radii, spacing)
+ : null;
+ const sizeOf = index => sizes.find(s => s.index === index);
+
+ placed.forEach(({ chip, index }) => {
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
- const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii);
- chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
+ chip.classList.add("betChipAnchored");
seat.appendChild(chip);
+ const offset = offsets[index];
+ chip.style.left = `calc(50% + ${offset.x.toFixed(1)}px)`;
+ chip.style.top = `calc(50% + ${offset.y.toFixed(1)}px)`;
return;
}
-
+ const size = sizeOf(index);
+ const pos = this.betPosition(index, this.state.seatCount, aspect, radii,
+ { ...spacing, chip: { width: size.width, height: size.height } });
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
- layer.appendChild(chip);
+ });
+ },
+
+ // Would a ring of chips keep clear of every puck on the felt, of the pot, and of
+ // each other?
+ //
+ // The same box-against-box question the seats settle among themselves, asked across
+ // the two layers. betPosition() walks a chip in along its own spoke until it is clear
+ // of its own puck, but it will not walk past 38% of the way to the middle, because
+ // the pot badge is there — and on a felt 117px tall that floor leaves the chip
+ // sitting on the seat opposite.
+ //
+ // That 38% is a guess at where the pot badge ends. Where the badge has actually been
+ // measured, the guess gives way to it: a ring that lands a chip on the pot total is
+ // no more use than one that lands it on a puck.
+ chipsClearTheSeats(chips, aspect, radii, spacing) {
+ const { box, puck, pot } = spacing;
+ const count = this.state.seatCount;
+ const seats = this.state.seats.map((_, i) => {
+ const pos = this.seatPosition(i, count, aspect, radii);
+ return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
+ });
+ // Each chip is as wide as the number on it, and asking the question with one
+ // size for all of them answers it for a table that isn't there: at 768x1024 a
+ // single 47px "$1,200" made the whole felt give up a ring the other four 40px
+ // chips had room for, and dropped them all onto their own players' stacks.
+ const at = ({ index, width, height }) => {
+ const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
+ return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height, width, height };
+ };
+ const placed = chips.map(at);
+
+ // Chips against each other, which this never asked. A ring is a smaller circle
+ // than the seats' one and holds the same number of things, so it is the tighter
+ // fit of the two: on a Pixel 7 with twelve players, two $10s on the ring sat on
+ // top of each other while every puck on the felt was clear. The anchored layout
+ // resolves its own collisions one chip at a time; the ring has no such recourse,
+ // so a ring that collides is a ring this table cannot have.
+ for (let a = 0; a < placed.length; a++) {
+ for (let b = a + 1; b < placed.length; b++) {
+ if (Math.abs(placed[a].x - placed[b].x) < (placed[a].width + placed[b].width) / 2 &&
+ Math.abs(placed[a].y - placed[b].y) < (placed[a].height + placed[b].height) / 2) {
+ return false;
+ }
+ }
+ }
+
+ return chips.every(({ index, width, height }) => {
+ const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
+ const x = (pos.left / 100) * box.width;
+ const y = (pos.top / 100) * box.height;
+ if (pot && Math.abs(x - pot.x) < (width + pot.width) / 2 &&
+ Math.abs(y - pot.y) < (height + pot.height) / 2) {
+ return false;
+ }
+ return seats.every(seat =>
+ Math.abs(x - seat.x) >= (width + puck.width) / 2 ||
+ Math.abs(y - seat.y) >= (height + puck.height) / 2);
});
},
@@ -696,7 +1200,10 @@ const Render = {
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
});
- this.renderBets();
+ // Kept so the board can ask for the chips to be laid out again if placing it
+ // changes what is in the middle of the felt — see placeBoard().
+ this.lastBetLayout = { aspect, radii };
+ this.renderBets(aspect, radii);
},
isRedSuit(card) {
@@ -722,6 +1229,177 @@ const Render = {
slot.textContent = value ? this.formatCard(value) : "+";
},
+ // The community cards, in the middle of the felt.
+ //
+ // Only the cards the dealer has actually turned get a slot: three after the flop,
+ // four after the turn. A row of five gaps before the flop would be the tool asking
+ // for cards that are still in the deck, and on a phone it is a row of furniture in
+ // the middle of the table earning nothing.
+ //
+ // Hidden entirely unless the dealer asked for it. Everything about this feature is
+ // opt-in — a table that does not want to enter cards should not have a board on it,
+ // or a picker opening at them every street.
+ renderBoard() {
+ const row = document.getElementById("boardRow");
+ if (!row) return;
+
+ const showing = this.state.trackBoard
+ ? Betting.boardCardsFor(this.state.bettingOpen ? this.state.street : "preflop")
+ : 0;
+ const strip = document.getElementById("boardStrip");
+ row.hidden = showing === 0;
+ if (strip) strip.hidden = true;
+ row.innerHTML = "";
+ if (showing === 0) return;
+
+ const board = this.state.board || [];
+ for (let slot = 0; slot < showing; slot++) {
+ const card = (board[slot] || "").toUpperCase();
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "boardCard";
+ btn.classList.toggle("cardRed", this.isRedSuit(card));
+ btn.classList.toggle("boardCardEmpty", !card);
+ btn.setAttribute("aria-label", card
+ ? `${CardPicker.boardSlotLabel(slot)}, ${this.formatCard(card)}`
+ : `${CardPicker.boardSlotLabel(slot)}, not turned yet`);
+
+ if (card) {
+ const rank = document.createElement("span");
+ rank.textContent = card[0];
+ const suit = document.createElement("span");
+ suit.className = "boardCardSuit";
+ suit.textContent = this.SUIT_SYMBOLS[card[1]] || "";
+ btn.appendChild(rank);
+ btn.appendChild(suit);
+ } else {
+ btn.textContent = "+";
+ }
+
+ btn.addEventListener("click", () => this.openBoardPicker(slot));
+ row.appendChild(btn);
+ }
+
+ this.placeBoard(row, strip);
+ },
+
+ // Daylight between the board and the nearest seat puck.
+ BOARD_CLEARANCE_PX: 10,
+ // The smallest middle worth putting five cards in. Below this the board comes off
+ // the felt entirely rather than being squeezed into something unreadable.
+ BOARD_MIN_W_PX: 150,
+
+ // Where the board goes: in the middle of the felt, or on a strip above it.
+ //
+ // The middle is the right place for it and the place a real table keeps it — but a
+ // phone on its side has no middle. The felt there is 528x83 and a seat puck is 55 of
+ // those pixels tall, so the ring closes over the centre entirely: measured at
+ // 640x360, the board was under six seat pucks at a full table and two at a
+ // six-handed one, and the seats are painted above it, so what the dealer got was a
+ // board with players on top of it.
+ //
+ // So when the middle cannot hold it, it comes off the felt and sits in its own strip
+ // above the table, at the same size with nothing over it. Decided by measuring the
+ // room rather than by naming a viewport, because how much middle a felt has depends
+ // on how many people are sitting down at least as much as on the size of the screen.
+ //
+ // Called after the cards are in the DOM: how wide the middle can be depends on how
+ // tall what is in it turned out to be, and that is not a thing to guess at when the
+ // answer is one measurement away.
+ placeBoard(row, strip) {
+ const centre = document.getElementById("tableCentre");
+ const width = this.centreRoomWidth(centre);
+ const onStrip = width < this.BOARD_MIN_W_PX;
+ const was = strip ? !strip.hidden : false;
+ const before = this.centreSignature(centre);
+
+ if (onStrip) {
+ if (strip && row.parentElement !== strip) strip.appendChild(row);
+ if (strip) strip.hidden = false;
+ if (centre) { centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
+ } else {
+ if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
+ if (strip) strip.hidden = true;
+ if (centre) centre.style.maxWidth = `${Math.round(width)}px`;
+ }
+
+ // The chips were laid out against whatever was in the middle of the felt a
+ // moment ago, and putting the board there — or taking it away — has just changed
+ // that. Only when it actually changed: on every render after the first, this is
+ // the same box it already was and the chips stay where they are.
+ if (this.centreSignature(centre) !== before && this.lastBetLayout) {
+ this.renderBets(this.lastBetLayout.aspect, this.lastBetLayout.radii);
+ }
+
+ // A strip appearing above the table moves the table down, and how tall the felt
+ // may be is worked out from where it starts — a number nothing else recomputes,
+ // because it is watched by way of the action bar's height and the bar has not
+ // changed. Without this the seats keep the height they had and the bottom row
+ // ends up behind the Fold button.
+ if (was === onStrip) return;
+ // Which re-lays the seats and their chips if the felt has changed height as a
+ // result — see publishFeltRoom(). Once, not in a loop: a felt that has just
+ // given up height to the strip cannot then have room for the board back.
+ if (typeof ActionBar !== "undefined" && ActionBar.publishHeight) ActionBar.publishHeight();
+ },
+
+ centreSignature(centre) {
+ if (!centre) return "";
+ const at = centre.getBoundingClientRect();
+ return `${Math.round(at.left)}:${Math.round(at.top)}:${Math.round(at.width)}:${Math.round(at.height)}`;
+ },
+
+ // How wide the middle of the felt can be without a seat puck landing on it.
+ //
+ // Not the ring's clearance at its widest point, which is what this was first written
+ // as and is only the answer for a seat sitting exactly on the horizontal axis. Every
+ // other seat is nearer the middle than that, and on a nine-handed phone table two of
+ // them were on top of the board while the ring said there was 77px of room either
+ // side. So it is asked of each seat in turn: a seat that clears the board vertically
+ // sets no limit on its width at all, and one that doesn't sets the limit to however
+ // far away it is.
+ centreRoomWidth(centre) {
+ const box = this.ringBox();
+ const puck = this.measuredSeatBox();
+ if (!centre || !box || !puck) return 0;
+
+ const radii = this.seatRingRadii(puck);
+ const count = this.state.seatCount;
+ const clearance = this.BOARD_CLEARANCE_PX;
+ // Its natural height, not whatever a previous pass clamped it to.
+ const wasMaxWidth = centre.style.maxWidth;
+ centre.style.maxWidth = "";
+ const halfHeight = centre.getBoundingClientRect().height / 2;
+ centre.style.maxWidth = wasMaxWidth;
+
+ let halfWidth = box.width / 2;
+ this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).forEach(seat => {
+ const dx = Math.abs(((seat.left - 50) / 100) * box.width);
+ const dy = Math.abs(((seat.top - 50) / 100) * box.height);
+ if (dy >= halfHeight + puck.height / 2 + clearance) return;
+ halfWidth = Math.min(halfWidth, dx - puck.width / 2 - clearance);
+ });
+ return Math.max(0, Math.round(halfWidth * 2));
+ },
+
+ // Opens the picker on the board, at `slot` or at the first gap.
+ openBoardPicker(slot = null) {
+ if (!this.state.trackBoard) return;
+ const showing = Betting.boardCardsFor(this.state.bettingOpen ? this.state.street : "preflop");
+ if (showing === 0) return;
+ const board = this.state.board || [];
+ const firstGap = board.slice(0, showing).findIndex(card => !card);
+ const at = slot === null ? (firstGap === -1 ? 0 : firstGap) : slot;
+
+ CardPicker.openBoard(this.state, {
+ onPick: () => {
+ HoldemState.save(this.state);
+ this.renderBoard();
+ },
+ }, at, showing);
+ },
+
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
// Worth calling out, because at a short-handed table the open seats are what
// the dealer is looking for.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
index 7f1d75c..bfbf74d 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js
@@ -58,6 +58,14 @@ const HoldemState = {
bigBlind: 2,
defaultStack: null,
street: "preflop",
+ // The community cards, in the order they hit the felt: three, then one,
+ // then one. Empty strings for the ones that haven't been turned yet, the
+ // same way a seat's two hole cards are stored.
+ board: ["", "", "", "", ""],
+ // Whether the dealer wants to be asked for them at all. Off is the tool as
+ // it has always been: no board on the felt, and the pot wizard asks who
+ // won. On, it reads the showdown and answers that question itself.
+ trackBoard: false,
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
@@ -216,6 +224,11 @@ const HoldemState = {
// A table saved before the dead button existed has neither flag. False is
// the right guess for both: it means the rotation carries on the ordinary
// way, which is what that table was already doing.
+ // A table saved before the board existed has no board and was not tracking
+ // one. Off is also the right guess for a dealer who never asked for it.
+ parsed.trackBoard = parsed.trackBoard === true;
+ parsed.board = this.normaliseBoard(parsed.board);
+
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
@@ -705,6 +718,7 @@ const HoldemState = {
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
+ this.clearBoard(state);
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -717,6 +731,21 @@ const HoldemState = {
this.ensureButtonOnPlayer(state);
},
+ // Exactly five slots of either a two-character card or an empty string, whatever
+ // arrived. A board read off a file is the one that has been somewhere this tool
+ // cannot see.
+ normaliseBoard(board) {
+ const source = Array.isArray(board) ? board : [];
+ return Array.from({ length: 5 }, (_, i) => {
+ const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
+ return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
+ });
+ },
+
+ clearBoard(state) {
+ state.board = ["", "", "", "", ""];
+ },
+
// Appends a resolved-pot record to the running session log a dealer can point to
// for chip-count disputes, then advances the hand counter. `payouts` is
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 9b2a211..4b97663 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -20,6 +20,8 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
| `state.test.js` | Button rotation, which seats post blinds, session standings |
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
+| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
+| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
## The layout audit
@@ -33,10 +35,38 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
-no way to scroll to it, that no grid column has collapsed to nothing, and that no
-text is painted outside its box. It runs over the table, all five overlays and Quick
-Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
-phones on their side — each viewport twice, once with a mouse and once with a thumb.
+no way to scroll to it, that no grid column has collapsed to nothing, that no
+text is painted outside its box, that no seat covers another seat's writing, and that
+no bet chip is hidden behind a seat or another chip. It
+runs over the table, all five overlays and Quick Split, at thirteen viewports from a
+360px phone to a 1600px desktop and the same phones on their side — each viewport
+twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
+the sweep posts blinds, deals and calls three times before it measures anything, so
+the bets layer has chips on it. It used to audit a table nobody had ever bet at,
+which is a third of what is on the felt during a hand left unlooked at.
+
+"Covered" and "out of view" are told apart before anything is hit-tested. An element
+clipped away by a panel that scrolls is not covered by whatever happens to be painted
+at that height, and reporting it that way is a diagnosis of something that isn't
+happening: the pot wizard's third winner, below the fold in a list, came back for four
+viewports running as "fully covered by button Confirm Winner(s)".
+
+More than one table shape is also newer than it sounds. The sweep ran on one — five
+players at a nine-handed table — for its whole life, and how much room the felt has
+depends on how many people are sitting down far more than on anything a viewport can
+report. A full table is where the seats crowd and it had never been looked at: the
+first sweep that did found one player's name or stack painted under another on eighteen
+of the configurations, on every phone in the list.
+
+The third shape is the same five players with the board turned on and five cards on it.
+It is deliberately the same roster in the same chairs as the second, so the only thing
+that differs between the two rows is the board — a seventh player would have told us
+that a seven-handed table crowds, which is true, already known, and not what that row
+is there to find out. It earned its place immediately: the board takes the middle of
+the felt, or a strip above it where the middle is too small, and the strip costs the
+felt height. The seats follow a felt that changes size on their own, being placed in
+percentages of it; the chips anchored inside them do not, being placed in pixels. They
+were laid out for a felt 23px taller than the one they were painted on.
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
@@ -53,8 +83,8 @@ offenders, which reads identically to a pass. The same goes for the two exemptio
that exist: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
-Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
-this one needs a browser and a running site. Run it after any change that adds or
+Deliberately not in CI: the unit suite is dependency-free and runs in about five
+seconds, while this one needs a browser and a running site. Run it after any change that adds or
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
call `LayoutAudit.runAll()`.
@@ -77,6 +107,23 @@ something changed, and each time they only covered whatever had just been touche
That is how the standings table shipped with a player-name column zero pixels wide
and kept it for two rounds.
+## Why the hand evaluator is tested the way it is
+
+`handRank.js` is the only module here that can pay the wrong person without anybody
+noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking
+the box; one that answers the question itself is wrong on its own account, silently,
+and a table that has come to trust it will not check.
+
+So besides the hand-written cases — every category against every other, the wheel, three
+kickers deep, the counterfeited two pair, the board that plays — it is checked against a
+second evaluator written a different way, on twenty thousand random seven-card hands and
+four thousand random pairs of them. The second one is in the test file and shares no
+helper with the module: it scores a hand by counting ranks into a sortable list rather
+than by asking about shapes. That pass is most of the suite's running time and it is
+worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
+a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
+well as by the cases written for them.
+
## Why the randomised suite exists
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
diff --git a/tests/holdem/betChips.test.js b/tests/holdem/betChips.test.js
new file mode 100644
index 0000000..e97a28d
--- /dev/null
+++ b/tests/holdem/betChips.test.js
@@ -0,0 +1,473 @@
+// Where a player's bet sits on the felt.
+//
+// The chips were the last thing on the table nobody had measured. They were laid out on
+// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
+// seat ring was itself fixed — and the choice between that ring and clinging to the
+// puck was made by asking how wide the *window* was. Neither of those is a question
+// about the felt. A phone held sideways is 844px across and gives the felt 117px of
+// height: it passed the width test, took the ring, and every chip on it was painted
+// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
+// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
+// number saying what that player has in.
+//
+// Everything below is geometry, so it runs without a browser. The felts are the ones
+// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Render } = require("./load");
+
+const RADII = { rx: 44, ry: 42 };
+const PUCK = { width: 96, height: 64 };
+const CHIP = { width: 44, height: 22 };
+
+const felt = (width, height) => ({ width, height });
+const spacingOn = (box, puck = PUCK, chip = CHIP) =>
+ ({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
+
+// A chip's centre in pixels on a given felt.
+const chipPixels = (index, count, box, spacing, radii = RADII) => {
+ const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
+ return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
+};
+
+const seatPixels = (index, count, box, radii = RADII) => {
+ const pos = Render.seatPosition(index, count, box.width / box.height, radii);
+ return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
+};
+
+// Two axis-aligned boxes, by their centres. The same test the seats settle among
+// themselves — see seatFootprint.test.js — because it is the same question.
+const boxesOverlap = (a, b, sizeA, sizeB) =>
+ Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
+ Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
+
+describe("reaching the edge of a box", () => {
+ test("straight out the side is half the width, straight up half the height", () => {
+ assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
+ assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
+ assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
+ });
+
+ test("a corner is whichever edge the ray meets first", () => {
+ // 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
+ // ray leaves through one of those. A single radius — half the diagonal, 57.7 —
+ // would put a chip 25px further out than it needs to be, which on a 117px felt
+ // is the difference between clear of the puck and off the table.
+ const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
+ assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
+ assert.ok(diagonal < 48, "and never further than the wider half-edge");
+ });
+
+ test("a square is the same in every direction it is asked about", () => {
+ const straight = Render.edgeReach(50, 50, 0, 1);
+ assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
+ });
+});
+
+describe("getting one box clear of another", () => {
+ const PUCK96 = { width: 96, height: 88 };
+ const CHIP50 = { width: 50, height: 21 };
+
+ // Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
+ // apart. This is the question clearanceReach is supposed to answer, asked the long
+ // way round so the answer can be checked rather than restated.
+ const apart = (t, ux, uy) =>
+ Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
+ Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
+
+ test("straight up and down it is what adding the two half-heights gives", () => {
+ assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
+ assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
+ });
+
+ test("on a diagonal it is more than the two reaches added together", () => {
+ // The bug this replaced. Two axis-aligned boxes are clear when they are apart on
+ // one axis, and the travel that achieves that is the reach of the box you get by
+ // adding their widths and their heights — which is never less, and on a diagonal
+ // is more, than adding the two boxes' own reaches. At 768x1024 the difference was
+ // 11px, and 11px is how far inside its own player's puck the chip came to rest.
+ const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
+ let shortfalls = 0;
+ dirs.forEach(([ux, uy]) => {
+ const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
+ Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
+ const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
+ assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
+ if (proper <= summed + 1e-9) return;
+ // Where they differ, the old answer was not merely smaller — it was wrong:
+ // travelling that far leaves the two boxes still on top of each other.
+ shortfalls++;
+ assert.ok(!apart(summed, ux, uy),
+ `${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
+ });
+ assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
+ });
+
+ test("and it is exactly enough — a hair less is not", () => {
+ const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
+ dirs.forEach(([ux, uy]) => {
+ const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
+ assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
+ assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
+ });
+ });
+});
+
+describe("a chip on the ring", () => {
+ test("with nothing measured it keeps the proportions the ring always used", () => {
+ // The unit suite and the very first render both call it this way. 27/44 and
+ // 25/42 of the seat's own reach, so the chips move with the ring rather than
+ // with the felt.
+ const seat = Render.seatPosition(3, 9, 1.4, RADII);
+ const chip = Render.betPosition(3, 9, 1.4, RADII, null);
+ assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
+ assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
+ });
+
+ test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
+ const box = felt(560, 400);
+ const spacing = spacingOn(box);
+ for (let i = 0; i < 9; i++) {
+ const seat = seatPixels(i, 9, box);
+ const chip = chipPixels(i, 9, box, spacing);
+ assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
+ `seat ${i + 1}'s own chip is under its own puck`);
+ }
+ });
+
+ test("and never wanders into the pot badge in the middle", () => {
+ // The floor: 38% of the way out. Without it a chip walked in past a puck that
+ // is 96px wide on a felt whose reach is 90px would land on the pot total, which
+ // is the same fault at the other end of the spoke.
+ const box = felt(300, 216);
+ const spacing = spacingOn(box, { width: 200, height: 150 });
+ for (let i = 0; i < 6; i++) {
+ const seat = seatPixels(i, 6, box);
+ const chip = chipPixels(i, 6, box, spacing);
+ const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
+ const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
+ assert.ok(chipReach >= seatReach * 0.38 - 0.5,
+ `chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
+ }
+ });
+
+ test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
+ const box = felt(400, 300);
+ const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
+ assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
+ });
+});
+
+describe("deciding whether a ring of chips fits at all", () => {
+ // The shape the layout works in: one entry per chip on the felt, carrying its own
+ // measured size, because a chip is as wide as the number printed on it.
+ const tableOf = (count, chip = CHIP) => {
+ Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
+ return Array.from({ length: count }, (_, i) =>
+ ({ index: i, width: chip.width, height: chip.height }));
+ };
+
+ test("a desktop felt has room for one", () => {
+ const box = felt(700, 500);
+ const all = tableOf(9);
+ assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
+ });
+
+ test("a phone on its side has not, whatever the window's width says about it", () => {
+ // 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
+ // past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
+ // on the seat across the table from it.
+ const box = felt(692, 117);
+ const all = tableOf(9);
+ assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
+ "this is the felt where every chip was painted behind a puck");
+ });
+
+ test("its answer is the one the geometry gives", () => {
+ // Not a restatement of the two cases above: whatever it returns, it has to agree
+ // with laying the chips out and measuring them against every puck on the felt.
+ const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
+ shapes.forEach(box => {
+ const all = tableOf(9);
+ const spacing = spacingOn(box);
+ const aspect = box.width / box.height;
+ const clear = all.every(({ index: i }) => {
+ const chip = chipPixels(i, 9, box, spacing);
+ return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
+ });
+ assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
+ `${box.width}x${box.height}`);
+ });
+ });
+
+ test("each chip is asked about at its own size", () => {
+ // A chip is as wide as the number on it: at 768x1024, four players' chips were
+ // 40px and one holding a four-figure bet was 47. Answering the question once, at
+ // the widest, told the whole felt it had no room for a ring — and dropped all
+ // five chips onto their own players' stacks to solve a problem four of them
+ // did not have.
+ // The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
+ const box = felt(672, 672);
+ const narrow = { width: 40, height: 20 };
+ const huge = { width: 420, height: 380 };
+ const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
+ const aspect = box.width / box.height;
+
+ assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
+ "a felt this size has room for a ring of ordinary chips");
+ assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
+ "and none at all for a ring of chips that size");
+
+ // Now one of each. The answer has to come from the sizes in the list, not from
+ // one size standing in for all of them: with one huge chip the ring is off, and
+ // taking that chip out of the question puts it back.
+ const mixed = tableOf(9, narrow);
+ mixed[0] = { index: 0, ...huge };
+ assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
+ assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
+ "the eight ordinary ones were never the problem");
+ });
+
+ test("and at the chips against each other", () => {
+ // The ring is a smaller circle than the seats' one and holds the same number of
+ // things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
+ // players, two $10s on the ring sat on top of each other while every puck on the
+ // felt was clear — so a check that only looks at the pucks says yes to a layout
+ // that hides one player's bet behind another's.
+ // The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
+ // pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
+ const box = felt(300, 336);
+ const radii = { rx: 44, ry: 40.84 };
+ const puck = { width: 44, height: 55 };
+ const pot = { x: 150, y: 168, width: 64, height: 53 };
+ const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
+ const aspect = box.width / box.height;
+
+ Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
+ const bettors = [2, 3, 4, 5, 6, 7, 8];
+ const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
+
+ const chips = all.map(entry => ({
+ ...entry,
+ ...chipPixels(entry.index, 12, box,
+ { ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
+ }));
+ const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
+
+ assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
+ "this table is chosen so the pucks are not the problem");
+ assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
+ "nor the pot");
+ assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
+ boxesOverlap(chip, other, chip, other))), "and so the chips are");
+
+ assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
+ });
+
+ test("it looks at every puck, not just the chip's own", () => {
+ // The rule this replaced walked each chip in until it was clear of its *own*
+ // seat and stopped there, which says nothing about the eight other pucks on the
+ // felt. 560x190 is a felt where that rule is satisfied and the ring is still no
+ // good: every chip clears the puck it belongs to, and one of them is sitting on
+ // a stranger's.
+ const box = felt(560, 190);
+ const all = tableOf(9);
+ const spacing = spacingOn(box);
+ const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
+
+ all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
+ `chip ${i + 1} is under its own puck, so this felt does not make the point`));
+ assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
+ boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
+ "expected a chip on somebody else's puck — what the old rule could not see");
+ assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
+ });
+});
+
+describe("a chip clinging to its puck", () => {
+ // The fallback when there is no room for a ring: the chip hangs off the puck on the
+ // side facing the empty middle of the felt.
+ //
+ // The felts the app actually draws, with the puck size each one settles on — read
+ // off the running site at each viewport rather than guessed here, because the tight
+ // felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
+ // a 528x83 felt is describing a table the app never draws.
+ const REAL = [
+ ["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
+ ["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
+ ["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
+ ["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
+ ];
+ // Chips are 21x17 everywhere they are anchored — the amount is the only thing on
+ // one at that size.
+ const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
+
+ // What each chip is told to do, worked out for the whole table at once.
+ const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
+ Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
+ const indices = [...Array(count).keys()];
+ const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
+ const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
+ const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
+ return indices.map(i => {
+ const seat = Render.seatPosition(i, count, box.width / box.height, radii);
+ const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
+ const off = offs[i];
+ return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
+ });
+ };
+
+ test("on its own, a chip asks to sit just clear of its puck", () => {
+ const box = felt(692, 113);
+ const puck = { width: 72, height: 55 };
+ const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
+ const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
+ const travel = Math.hypot(off.x, off.y);
+ const ux = off.x / travel, uy = off.y / travel;
+ assert.ok(Math.abs(travel -
+ (Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
+ });
+
+ test("and gets it, where nothing else is in the way", () => {
+ // 692x113 is the roomier of the two landscape felts. Every chip clears its own
+ // puck and none of them has to give ground.
+ placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
+ assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
+ `seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
+ });
+ });
+
+ test("it goes off the rim, not down the spoke", () => {
+ // On a felt 692x113 the two are nowhere near the same direction. A seat on the
+ // top rail has a spoke pointing diagonally inward — which along a slot this flat
+ // runs sideways into the next seat — while off the rim is very nearly straight
+ // down, into the empty middle. This is the seam that put two chips on top of
+ // each other on a Galaxy S8.
+ const box = felt(692, 113);
+ const rows = placeAll(9, box, { width: 72, height: 55 });
+ const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
+ assert.ok(top, "expected a seat up on the right of the top rail");
+ assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
+ `chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
+ assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
+ });
+
+ test("no two chips on a full table land on each other", () => {
+ // Four sides gave three seams. There are no sides now, and this is what that is
+ // worth — on every felt the app actually draws, at every table size.
+ REAL.forEach(([name, box, puck]) => {
+ [6, 9, 12].forEach(count => {
+ const rows = placeAll(count, box, puck);
+ for (let a = 0; a < rows.length; a++) {
+ for (let b = a + 1; b < rows.length; b++) {
+ assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
+ `${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
+ }
+ }
+ });
+ });
+ });
+
+ test("a chip only gives ground when it is standing on something", () => {
+ // The point of pulling chips back one at a time rather than by a rule that
+ // applies to all of them. Stopping every chip at the felt's midline, or scaling
+ // them all by one factor, takes travel from the seats at the ends of a long
+ // rail — measured at 692x113, that parked two chips on their own player's stack
+ // to solve a collision that did not exist.
+ //
+ // So: every chip either got everything it asked for, or putting the last step
+ // back reintroduces a collision it is part of. The step goes back for all of
+ // them at once, because a facing pair gives ground together and the pass that
+ // finally separates such a pair moves both ends of it.
+ const step = Render.CHIP_PULLBACK_STEP_PX;
+ REAL.forEach(([name, box, puck]) => {
+ [6, 9, 12].forEach(count => {
+ const rows = placeAll(count, box, puck).map(row => {
+ const travel = Math.hypot(row.off.x, row.off.y);
+ const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
+ const asked = travel < 1e-6 ? 0 :
+ Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
+ Render.BET_CLEARANCE_PX;
+ const back = Math.min(asked, travel + step);
+ return { ...row, travel, asked,
+ undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
+ });
+
+ rows.forEach((row, i) => {
+ if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
+ const onSomething = rows.some((other, j) => j !== i && (
+ boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
+ boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
+ assert.ok(onSomething,
+ `${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
+ });
+ });
+ });
+ });
+
+ test("nobody is pushed further out than they asked to go", () => {
+ // The pull-back only ever takes travel away. A chip pushed past its clearance
+ // would be floating out on the felt with nothing to say whose it is.
+ REAL.forEach(([name, box, puck]) => {
+ placeAll(12, box, puck).forEach((row, i) => {
+ const travel = Math.hypot(row.off.x, row.off.y);
+ if (travel < 1e-6) return;
+ const ux = row.off.x / travel, uy = row.off.y / travel;
+ const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
+ Render.BET_CLEARANCE_PX;
+ assert.ok(travel <= asked + 0.5,
+ `${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
+ });
+ });
+ });
+
+ test("the direction turns smoothly all the way round the ring", () => {
+ // The property a set of discrete sides cannot have, and the reason there are
+ // none: neighbouring seats get neighbouring directions, with no step anywhere.
+ const box = felt(560, 400);
+ const angles = Array.from({ length: 24 }, (_, i) => {
+ const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
+ const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
+ return Math.atan2(off.y, off.x);
+ });
+ angles.forEach((angle, i) => {
+ const next = angles[(i + 1) % angles.length];
+ let turn = Math.abs(next - angle);
+ if (turn > Math.PI) turn = 2 * Math.PI - turn;
+ assert.ok(turn < Math.PI / 4,
+ `between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
+ });
+ });
+
+ test("it stops short of the pot total as well", () => {
+ // The other thing in the middle of the felt, and the one number on the table
+ // that belongs to everybody. A seat on the middle of a rail sends its chip
+ // straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
+ // "Pot $65". The badge is measured rather than assumed to be at the centre —
+ // it lives on the table, not on either overlay, and the two are not concentric.
+ const box = felt(528, 83);
+ const pot = { x: 264, y: 40, width: 96, height: 34 };
+ Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
+ const indices = [...Array(12).keys()];
+ const entries = indices.map(i =>
+ ({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
+ const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
+ const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
+
+ indices.forEach(i => {
+ const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
+ const chip = { x: (seat.left / 100) * box.width + offs[i].x,
+ y: (seat.top / 100) * box.height + offs[i].y };
+ assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
+ `chip ${i + 1} is on the pot total`);
+ });
+ });
+
+ test("a seat with no rim to be off still gets an answer", () => {
+ const box = felt(400, 300);
+ const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
+ assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
+ assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
+ });
+});
diff --git a/tests/holdem/board.test.js b/tests/holdem/board.test.js
new file mode 100644
index 0000000..8856450
--- /dev/null
+++ b/tests/holdem/board.test.js
@@ -0,0 +1,118 @@
+// The community cards, as a thing the table remembers.
+//
+// The board is the one part of a hand this tool never had a place for: hole cards go on
+// a seat, chips go in the middle, and the five cards everybody plays lived only on the
+// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
+// cards at all — and everything that reads it has to behave exactly as it always did
+// when it is off.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { Betting, HoldemState, makeTable } = require("./load");
+
+const dealt = (over = {}) => {
+ const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
+ Betting.beginHand(state);
+ return state;
+};
+
+describe("a new table", () => {
+ test("has five empty slots and is not tracking them", () => {
+ const state = HoldemState.createDefault();
+ assert.deepEqual(state.board, ["", "", "", "", ""]);
+ assert.equal(state.trackBoard, false,
+ "off by default: a dealer who never asked for this should not be asked for cards");
+ });
+});
+
+describe("how many cards are out", () => {
+ test("none, three, four, five", () => {
+ assert.equal(Betting.boardCardsFor("preflop"), 0);
+ assert.equal(Betting.boardCardsFor("flop"), 3);
+ assert.equal(Betting.boardCardsFor("turn"), 4);
+ assert.equal(Betting.boardCardsFor("river"), 5);
+ });
+
+ test("and nothing at all for a street that isn't one", () => {
+ assert.equal(Betting.boardCardsFor("showdown"), 0);
+ assert.equal(Betting.boardCardsFor(undefined), 0);
+ });
+
+ test("it matches the streets the betting engine actually deals", () => {
+ // If a street is ever added, this is the table that has to hear about it —
+ // a street with no entry silently draws no cards.
+ Betting.STREETS.forEach(street => {
+ assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
+ `${street} has no card count`);
+ });
+ });
+});
+
+describe("clearing it", () => {
+ test("the next hand takes the board with it", () => {
+ const state = dealt();
+ state.board = ["AH", "KD", "9C", "4S", "2H"];
+ HoldemState.startNextHand(state);
+ assert.deepEqual(state.board, ["", "", "", "", ""]);
+ });
+
+ test("so does a misdeal", () => {
+ const state = dealt();
+ state.board = ["AH", "KD", "9C", "", ""];
+ Betting.takeHandBack(state);
+ assert.deepEqual(state.board, ["", "", "", "", ""]);
+ });
+
+ test("and so does dealing a hand, however the table got here", () => {
+ // Dealing twice off the same button — after a misdeal, or a table set up and
+ // then dealt again — would otherwise rank the last hand's board against this
+ // hand's cards, and it would look exactly like a real answer.
+ const state = dealt();
+ state.board = ["AH", "KD", "9C", "4S", "2H"];
+ Betting.beginHand(state);
+ assert.deepEqual(state.board, ["", "", "", "", ""]);
+ });
+
+ test("but turning the tracking off does not", () => {
+ // A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
+ // the way past would cost them cards they had already entered.
+ const state = dealt({ trackBoard: true });
+ state.board = ["AH", "KD", "9C", "", ""];
+ state.trackBoard = false;
+ assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
+ });
+});
+
+describe("a board off a file", () => {
+ const migrated = board => HoldemState.migrate({
+ seats: [HoldemState.makePlayer()], board,
+ }).board;
+
+ test("a table saved before boards existed gets an empty one, not tracked", () => {
+ const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
+ assert.deepEqual(parsed.board, ["", "", "", "", ""]);
+ assert.equal(parsed.trackBoard, false);
+ });
+
+ test("it is always five slots, however many arrived", () => {
+ assert.equal(migrated(["AH"]).length, 5);
+ assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
+ assert.equal(migrated("not an array").length, 5);
+ assert.equal(migrated(null).length, 5);
+ });
+
+ test("anything that isn't a card becomes an empty slot", () => {
+ assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
+ });
+
+ test("and a real one keeps its place, upper-cased", () => {
+ assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
+ });
+
+ test("tracking is only on if the file said so, in so many words", () => {
+ const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
+ const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
+ assert.equal(on.trackBoard, true);
+ assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
+ });
+});
diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js
index 6dacae8..d0b182f 100644
--- a/tests/holdem/designTokens.test.js
+++ b/tests/holdem/designTokens.test.js
@@ -103,8 +103,12 @@ describe("the corner scale", () => {
describe("the tokens themselves", () => {
test("every var() the stylesheet reaches for exists", () => {
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
- // Published from JS at runtime rather than declared here — see actionBar.js.
- const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]);
+ // Published from JS at runtime rather than declared here — see actionBar.js for
+ // the first two, render.js for the seat width and the chip's denomination colour,
+ // which is picked per chip from the amount on it.
+ const fromScript = new Set([
+ "--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
+ ]);
// Defined by the site's own stylesheet, not this one.
const fromSite = new Set(["--font-mono"]);
const missing = [...used].filter(t =>
diff --git a/tests/holdem/feltRoom.test.js b/tests/holdem/feltRoom.test.js
index 6c21c0b..7582def 100644
--- a/tests/holdem/feltRoom.test.js
+++ b/tests/holdem/feltRoom.test.js
@@ -71,3 +71,74 @@ describe("a window with room to spare", () => {
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
});
});
+
+describe("when the felt changes size, so does everything on it", () => {
+ // The felt is published as a custom property and the seats follow it on their own,
+ // because they are placed in percentages of it. A bet chip anchored inside a seat
+ // is not: it is placed in pixels off that seat's centre, worked out against the felt
+ // as it was, and nothing re-ran that when the felt moved.
+ //
+ // Measured at 640x360 with five players, on a felt that lost 23px of height: the
+ // chips were positioned for the old one and painted on the new, and seat 3's $5 came
+ // to rest on seat 4's puck. The seat pucks had the same problem — they are searched
+ // for a width the felt no longer has — and two pairs of them were overlapping.
+ //
+ // A side pot appearing does this too: it is 55px off the felt, mid-hand.
+ const fakeDom = (windowHeight = 900) => {
+ const root = { style: { values: {},
+ setProperty(k, v) { this.values[k] = v; },
+ getPropertyValue(k) { return this.values[k] || ""; } } };
+ global.document = {
+ documentElement: root,
+ getElementById: id => (id === "pokerTableWrap"
+ ? { getBoundingClientRect: () => ({ top: 146 }) }
+ : null),
+ };
+ global.window = { innerHeight: windowHeight, scrollY: 0 };
+ return root;
+ };
+
+ const spyRender = () => {
+ const calls = { density: 0, seats: 0 };
+ global.Render = {
+ state: {},
+ updateSeatDensity() { calls.density++; },
+ renderSeats() { calls.seats++; },
+ };
+ return calls;
+ };
+
+ test.afterEach(() => {
+ delete global.document;
+ delete global.window;
+ delete global.Render;
+ });
+
+ test("a felt that changed height lays the seats out again", () => {
+ fakeDom();
+ const calls = spyRender();
+ ActionBar.publishFeltRoom(117);
+ assert.equal(calls.seats, 1, "first publish sets a height it did not have");
+
+ ActionBar.publishFeltRoom(172);
+ assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
+ });
+
+ test("a felt that did not change is left alone", () => {
+ fakeDom();
+ const calls = spyRender();
+ ActionBar.publishFeltRoom(117);
+ ActionBar.publishFeltRoom(117);
+ ActionBar.publishFeltRoom(117);
+ assert.equal(calls.seats, 1,
+ "re-laying the table on every resize tick would be work for nothing");
+ assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
+ });
+
+ test("it publishes the height whether or not anything is listening", () => {
+ const root = fakeDom();
+ delete global.Render;
+ assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
+ assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
+ });
+});
diff --git a/tests/holdem/handRank.test.js b/tests/holdem/handRank.test.js
new file mode 100644
index 0000000..1a54527
--- /dev/null
+++ b/tests/holdem/handRank.test.js
@@ -0,0 +1,415 @@
+// What beats what.
+//
+// This is the one module in the tool that can pay the wrong person without anybody
+// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
+// ticking the box; one that answers the question itself is wrong on its own account,
+// silently, and a table that has come to trust it will not check. So the ordering is
+// tested from both ends: every category against every other, the traps that a
+// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
+// that plays), and then a lot of random seven-card hands against a second
+// implementation written a different way.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { HandRank } = require("./load");
+
+const best = (...cards) => HandRank.best(cards.flat());
+const beats = (a, b) => HandRank.compare(a, b) > 0;
+const chops = (a, b) => HandRank.compare(a, b) === 0;
+
+// A hand written as one string, for the tests that list a lot of them.
+const hand = str => str.trim().split(/\s+/);
+
+describe("the five-card hand", () => {
+ const of = str => HandRank.evaluate5(hand(str));
+
+ test("each category is recognised", () => {
+ const cases = [
+ ["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
+ ["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
+ ["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
+ ["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
+ ["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
+ ["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
+ ["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
+ ["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
+ ["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
+ ["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
+ ];
+ cases.forEach(([cards, category, described]) => {
+ const ranked = of(cards);
+ assert.equal(ranked.category, category, cards);
+ assert.equal(HandRank.describe(ranked), described, cards);
+ });
+ });
+
+ test("the categories are in the right order", () => {
+ // Listed weakest first, then every pair checked both ways round. The order
+ // itself is the thing being asserted, so it is written out rather than derived
+ // from the constants it is meant to be checking.
+ const ladder = [
+ "AC JD 8H 5S 2C", // ace high
+ "2C 2D 8H 5S 3C", // a pair of deuces
+ "AC AD 8H 8S 4C", // two pair
+ "3C 3D 3H 7S 2C", // trips
+ "9C 8D 7H 6S 5C", // straight
+ "AD JD 8D 5D 2D", // flush
+ "2C 2D 2H 3S 3C", // full house
+ "7C 7D 7H 7S 2C", // quads
+ "9H 8H 7H 6H 5H", // straight flush
+ ];
+ for (let low = 0; low < ladder.length; low++) {
+ for (let high = low + 1; high < ladder.length; high++) {
+ assert.ok(beats(of(ladder[high]), of(ladder[low])),
+ `${ladder[high]} should beat ${ladder[low]}`);
+ assert.ok(!beats(of(ladder[low]), of(ladder[high])),
+ `${ladder[low]} should not beat ${ladder[high]}`);
+ }
+ }
+ });
+
+ test("the same hand in different suits is a chop", () => {
+ assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
+ assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
+ });
+
+ test("nothing but five real cards is a hand", () => {
+ assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
+ assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
+ assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
+ assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
+ assert.equal(HandRank.evaluate5(null), null);
+ });
+
+ test("case does not matter, because the picker is not the only way cards get in", () => {
+ assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
+ });
+});
+
+describe("the wheel", () => {
+ const of = str => HandRank.evaluate5(hand(str));
+
+ // The straight that runs through the ace at the bottom. An evaluator that scores a
+ // straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
+ // the best straight there is instead of the worst — which pays the wrong player
+ // every time it comes up against a broadway.
+ test("A-2-3-4-5 is a straight", () => {
+ assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
+ assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
+ });
+
+ test("and it is the lowest one", () => {
+ assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
+ "six high beats the wheel");
+ assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
+ "broadway beats the wheel");
+ });
+
+ test("the same goes for a steel wheel", () => {
+ const steel = of("AH 2H 3H 4H 5H");
+ assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
+ assert.equal(HandRank.describe(steel), "Straight flush, five high");
+ assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
+ assert.ok(beats(of("AS KS QS JS TS"), steel));
+ });
+
+ test("an ace with a four-card run either side of it is not a straight", () => {
+ // K-A-2-3-4 does not wrap.
+ assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
+ assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
+ });
+});
+
+describe("kickers", () => {
+ const of = str => HandRank.evaluate5(hand(str));
+
+ test("the fifth card settles two pair", () => {
+ assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
+ assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
+ });
+
+ test("and a pair, three kickers deep", () => {
+ assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
+ assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
+ assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
+ });
+
+ test("the higher pair beats the better kicker", () => {
+ assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
+ });
+
+ test("two pair is read by its higher pair first", () => {
+ assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
+ });
+
+ test("a full house is read by its trips first", () => {
+ assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
+ });
+
+ test("a flush is read all the way down", () => {
+ assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
+ assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
+ });
+});
+
+describe("the best five out of seven", () => {
+ test("it finds the flush the two hole cards do not both belong to", () => {
+ const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
+ assert.equal(ranked.category, HandRank.FLUSH);
+ assert.equal(HandRank.describe(ranked), "Flush, ace high");
+ });
+
+ test("it plays the board when the board is better than anything you hold", () => {
+ // Nobody can beat a royal on the board; both hands are the board itself.
+ const board = ["AS", "KS", "QS", "JS", "TS"];
+ const one = best(["2C", "3D"], board);
+ const two = best(["7H", "8H"], board);
+ assert.equal(HandRank.describe(one), "Royal flush");
+ assert.ok(chops(one, two), "the board plays, so it is a chop");
+ });
+
+ test("it does not use three hole cards, because there are only two", () => {
+ const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
+ assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
+ });
+
+ test("a counterfeited two pair falls back on the board", () => {
+ // Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
+ // the best five are the board's two pair with the ace, and the sixes never play.
+ const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
+ assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
+ assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
+ });
+
+ test("five, six and seven cards are all rankable; four is not", () => {
+ assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
+ assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
+ assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
+ assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
+ });
+
+ test("the same card twice is not a hand", () => {
+ // A mis-entry, not a monster. Ranking it would mean ranking a deck that does
+ // not exist, and the answer would look exactly as confident as a real one.
+ assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
+ assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
+ });
+});
+
+describe("the showdown", () => {
+ const board = ["AH", "KD", "9C", "4S", "2H"];
+
+ test("it names one winner and says how it read every hand", () => {
+ const result = HandRank.showdown(board, [
+ { id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
+ { id: "b", cards: ["9H", "9S"] }, // trips
+ { id: "c", cards: ["AC", "3D"] }, // a pair of aces
+ ]);
+ assert.equal(result.complete, true);
+ assert.deepEqual(result.winners, ["b"]);
+ assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
+ assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
+ "Two pair, aces and kings",
+ "Three of a kind, nines",
+ "Pair of aces",
+ ]);
+ });
+
+ test("a chop comes back as two winners on the same place", () => {
+ const result = HandRank.showdown(board, [
+ { id: "a", cards: ["AS", "QC"] },
+ { id: "b", cards: ["AD", "QH"] },
+ { id: "c", cards: ["7H", "5S"] },
+ ]);
+ assert.deepEqual(result.winners.sort(), ["a", "b"]);
+ assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
+ "and the player behind them is third, not second");
+ });
+
+ test("an incomplete board is not a showdown", () => {
+ const result = HandRank.showdown(["AH", "KD", "9C"], [
+ { id: "a", cards: ["AS", "KC"] },
+ { id: "b", cards: ["9H", "9S"] },
+ ]);
+ assert.equal(result.boardComplete, false);
+ assert.equal(result.complete, false);
+ assert.deepEqual(result.winners, [], "it must not name one");
+ });
+
+ test("one unknown hand and it will not say who won", () => {
+ // Who is *ahead* and who *won* are different claims, and the second one is the
+ // only one that can pay somebody. A player who never turned their cards over
+ // could have anything.
+ const result = HandRank.showdown(board, [
+ { id: "a", cards: ["AS", "KC"] },
+ { id: "b", cards: ["", ""] },
+ ]);
+ assert.equal(result.complete, false);
+ assert.deepEqual(result.winners, []);
+ assert.deepEqual(result.unknown, ["b"]);
+ assert.ok(result.players[0].hand, "but it still reads the hands it can");
+ assert.equal(result.players[1].hand, null);
+ });
+
+ test("a card in two places stops it dead", () => {
+ const result = HandRank.showdown(board, [
+ { id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
+ { id: "b", cards: ["9H", "9S"] },
+ ]);
+ assert.equal(result.duplicated, true);
+ assert.equal(result.complete, false);
+ assert.deepEqual(result.winners, []);
+ });
+
+ test("no players at all is not a showdown either", () => {
+ const result = HandRank.showdown(board, []);
+ assert.equal(result.complete, false);
+ assert.deepEqual(result.winners, []);
+ });
+});
+
+describe("against a second opinion", () => {
+ // The strongest check available without a poker library: a completely separate
+ // ranking, written the other way round — every hand turned into a sortable string
+ // by counting ranks, with no shared helper between it and the module. Where the two
+ // disagree, one of them is wrong and the test says which hand it was.
+ const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
+ const val = card => VALUE[card[0]] || Number(card[0]);
+
+ function reference5(cards) {
+ const ranks = cards.map(val).sort((x, y) => y - x);
+ const suited = new Set(cards.map(c => c[1])).size === 1;
+
+ const tally = {};
+ ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
+ const bySize = Object.keys(tally)
+ .map(Number)
+ .sort((x, y) => tally[y] - tally[x] || y - x);
+ const sizes = bySize.map(r => tally[r]).join("");
+
+ const unique = [...new Set(ranks)];
+ let run = null;
+ if (unique.length === 5) {
+ if (unique[0] - unique[4] === 4) run = unique[0];
+ else if (unique.join(",") === "14,5,4,3,2") run = 5;
+ }
+
+ // [category, ...tiebreak], the same shape, arrived at from the group sizes
+ // rather than from a chain of ifs about shape[0] and shape[1].
+ if (suited && run !== null) return [8, run];
+ if (sizes === "41") return [7, ...bySize];
+ if (sizes === "32") return [6, ...bySize];
+ if (suited) return [5, ...ranks];
+ if (run !== null) return [4, run];
+ if (sizes === "311") return [3, ...bySize];
+ if (sizes === "221") return [2, ...bySize];
+ if (sizes === "2111") return [1, ...bySize];
+ return [0, ...ranks];
+ }
+
+ const worse = (a, b) => {
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
+ }
+ return false;
+ };
+
+ function referenceBest(cards) {
+ let top = null;
+ for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
+ for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
+ for (let e = d + 1; e < 7; e++) {
+ const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
+ if (!top || worse(top, score)) top = score;
+ }
+ return top;
+ }
+
+ // A deck, and a deterministic shuffle — a failure has to be reproducible or it is
+ // just an anecdote about one CI run.
+ const DECK = [];
+ "23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
+
+ let seed = 20260904;
+ const random = () => {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ return seed / 0x7fffffff;
+ };
+
+ test("twenty thousand random seven-card hands rank the same way", () => {
+ const deck = [...DECK];
+ for (let round = 0; round < 20000; round++) {
+ // Seven off the top of a shuffle of only what it needs.
+ const drawn = [];
+ for (let i = 0; i < 7; i++) {
+ const pick = i + Math.floor(random() * (deck.length - i));
+ [deck[i], deck[pick]] = [deck[pick], deck[i]];
+ drawn.push(deck[i]);
+ }
+ const mine = HandRank.best(drawn);
+ const theirs = referenceBest(drawn);
+ assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
+ `disagreed on ${drawn.join(" ")}`);
+ }
+ });
+
+ test("and four thousand random pairs of hands are ordered the same way", () => {
+ // The categories agreeing is not the same as the comparison agreeing: this is
+ // the half that pays somebody.
+ const deck = [...DECK];
+ for (let round = 0; round < 4000; round++) {
+ const drawn = [];
+ for (let i = 0; i < 9; i++) {
+ const pick = i + Math.floor(random() * (deck.length - i));
+ [deck[i], deck[pick]] = [deck[pick], deck[i]];
+ drawn.push(deck[i]);
+ }
+ const board = drawn.slice(0, 5);
+ const one = [...board, drawn[5], drawn[6]];
+ const two = [...board, drawn[7], drawn[8]];
+
+ const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
+ const a = referenceBest(one), b = referenceBest(two);
+ const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
+ assert.equal(mine, theirs,
+ `board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
+ }
+ });
+});
+
+describe("saying it in a sentence", () => {
+ // The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
+ // its own article. Which article is not something the first letter can answer: it
+ // is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
+ // and nobody has ever said "an ace high".
+ test("every category takes the article a table would give it", () => {
+ const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
+ assert.deepEqual([
+ said("AS KS QS JS TS"),
+ said("9H 8H 7H 6H 5H"),
+ said("7C 7D 7H 7S 2C"),
+ said("KC KD KH 3S 3C"),
+ said("AD JD 8D 5D 2D"),
+ said("9C 8D 7H 6S 5C"),
+ said("QC QD QH 7S 2C"),
+ said("AC AD 8H 8S 4C"),
+ said("QC QD 9H 5S 2C"),
+ said("AC JD 8H 5S 2C"),
+ ], [
+ "a royal flush",
+ "a straight flush, nine high",
+ "four of a kind, sevens",
+ "a full house, kings full of threes",
+ "a flush, ace high",
+ "a straight, nine high",
+ "three of a kind, queens",
+ "two pair, aces and eights",
+ "a pair of queens",
+ "ace high",
+ ]);
+ });
+
+ test("with nothing to say it says something", () => {
+ assert.equal(HandRank.phrase(null), "the best hand");
+ });
+});
diff --git a/tests/holdem/load.js b/tests/holdem/load.js
index e2caca8..4ff30b4 100644
--- a/tests/holdem/load.js
+++ b/tests/holdem/load.js
@@ -15,6 +15,7 @@ function load(name) {
}
const Money = load("money");
+const HandRank = load("handRank");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
@@ -33,6 +34,10 @@ globalThis.HoldemState = HoldemState;
// its methods reach for both. Loaded last so nothing it touches is still undefined.
const ActionBar = load("actionBar");
+// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
+// is plain state work and testable here. Only render() and open() touch the DOM.
+const CardPicker = load("cardPicker");
+
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -78,6 +83,7 @@ function contributions(state) {
module.exports = {
Money,
+ HandRank,
Render,
HandHistoryExport,
PotCalculator,
@@ -86,6 +92,7 @@ module.exports = {
Betting,
HoldemState,
ActionBar,
+ CardPicker,
makeTable,
chipsOnTable,
contributions,
diff --git a/tests/holdem/readingTheShowdown.test.js b/tests/holdem/readingTheShowdown.test.js
new file mode 100644
index 0000000..7f07dc0
--- /dev/null
+++ b/tests/holdem/readingTheShowdown.test.js
@@ -0,0 +1,274 @@
+// Reading the showdown: the picker that takes the cards in, and the pot maths that
+// pays somebody on the strength of them.
+//
+// The evaluator is tested to death next door in handRank.test.js. What is tested here
+// is the join — the two places where a correct ranking can still pay the wrong player:
+// a side pot, where the question is asked again of a smaller field, and a card entered
+// in two places, where the ranking is of a deck that does not exist.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load");
+
+describe("the picker, filling the board", () => {
+ const table = (board = ["", "", "", "", ""]) => {
+ const state = makeTable({ seatCount: 4, stack: 100 });
+ state.board = board;
+ return state;
+ };
+
+ // The sheet's own drawing is the DOM's business; the slot bookkeeping under it is
+ // not, and that is what these ask about.
+ const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; };
+
+ const onBoard = (state, cardIndex = 0, limit = 5) => {
+ headless();
+ CardPicker.state = state;
+ CardPicker.hooks = { onPick: () => {} };
+ CardPicker.target = "board";
+ CardPicker.boardLimit = limit;
+ CardPicker.cardIndex = cardIndex;
+ return CardPicker;
+ };
+
+ const onSeat = (state, seatIndex, cardIndex = 0) => {
+ headless();
+ CardPicker.state = state;
+ CardPicker.hooks = { onPick: () => {} };
+ CardPicker.target = "seat";
+ CardPicker.seatIndex = seatIndex;
+ CardPicker.cardIndex = cardIndex;
+ return CardPicker;
+ };
+
+ test("it only offers the slots that have actually been turned", () => {
+ const state = table();
+ assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three");
+ assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four");
+ assert.equal(onBoard(state, 0, 5).slotValues().length, 5);
+ });
+
+ test("a card goes into the board, not into a seat", () => {
+ const state = table();
+ onBoard(state, 1, 3).assign("KD");
+ assert.deepEqual(state.board, ["", "KD", "", "", ""]);
+ assert.deepEqual(state.seats[0].cards, ["", ""]);
+ });
+
+ test("it walks on to the next empty slot, so a flop is three taps", () => {
+ const state = table();
+ const picker = onBoard(state, 0, 3);
+ picker.assign("KD");
+ assert.equal(picker.cardIndex, 1);
+ picker.assign("9C");
+ assert.equal(picker.cardIndex, 2);
+ picker.assign("4S");
+ assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]);
+ });
+
+ test("and it does not walk past the slots it was given", () => {
+ // Filling the turn must not run on to the river: that card is still in the deck.
+ const state = table(["KD", "9C", "4S", "", ""]);
+ const picker = onBoard(state, 3, 4);
+ picker.assign("AH");
+ assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]);
+ });
+
+ test("clearing a slot empties that one and nothing else", () => {
+ const state = table(["KD", "9C", "4S", "", ""]);
+ onBoard(state, 1, 3).clearSlot();
+ assert.deepEqual(state.board, ["KD", "", "4S", "", ""]);
+ });
+});
+
+describe("a card cannot be in two places", () => {
+ const table = () => {
+ const state = makeTable({ seatCount: 4, stack: 100 });
+ state.board = ["KD", "9C", "4S", "", ""];
+ state.seats[0].cards = ["AH", "AS"];
+ state.seats[1].cards = ["", ""];
+ return state;
+ };
+
+ const picker = (state, setup) => {
+ CardPicker.state = state;
+ CardPicker.hooks = { onPick: () => {} };
+ setup();
+ return CardPicker;
+ };
+
+ test("the board's cards are unavailable to a hand", () => {
+ // The half of this that did not exist before: the picker knew about every
+ // player's cards and nothing about the middle of the table, so the king of
+ // diamonds on the flop could be dealt to somebody as well.
+ const state = table();
+ const used = picker(state, () => {
+ CardPicker.target = "seat";
+ CardPicker.seatIndex = 1;
+ CardPicker.cardIndex = 0;
+ }).cardsInUse();
+ assert.equal(used.get("KD"), "the board");
+ assert.equal(used.get("9C"), "the board");
+ assert.ok(used.has("AH"), "and the other players' are still in there");
+ });
+
+ test("and a hand's cards are unavailable to the board", () => {
+ const state = table();
+ const used = picker(state, () => {
+ CardPicker.target = "board";
+ CardPicker.boardLimit = 5;
+ CardPicker.cardIndex = 3;
+ }).cardsInUse();
+ assert.ok(used.has("AH"), "the ace in seat 1's hand");
+ assert.ok(used.has("KD"), "and the rest of the board");
+ });
+
+ test("but the slot being edited is not competing with itself", () => {
+ const state = table();
+ const used = picker(state, () => {
+ CardPicker.target = "board";
+ CardPicker.boardLimit = 3;
+ CardPicker.cardIndex = 0;
+ }).cardsInUse();
+ assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate");
+ assert.equal(used.has("9C"), true);
+ });
+
+ test("the same goes for a seat's own slot", () => {
+ const state = table();
+ const used = picker(state, () => {
+ CardPicker.target = "seat";
+ CardPicker.seatIndex = 0;
+ CardPicker.cardIndex = 0;
+ }).cardsInUse();
+ assert.equal(used.has("AH"), false);
+ assert.equal(used.has("AS"), true, "the player's other card still is");
+ });
+});
+
+describe("who wins a side pot", () => {
+ // The case a single ranking of the whole table gets wrong. The best hand takes the
+ // main pot and is then out of the question: the side pot goes to the best of who is
+ // left, which is a different answer, and it is the answer the wizard has to ask for
+ // again each round rather than reading off one list.
+ //
+ // Three players, and the short stack has the best hand of the three.
+ const board = ["KD", "9C", "4S", "AH", "2H"];
+ const hands = {
+ short: ["9H", "9S"], // trips — the best hand at the table
+ big: ["AS", "KC"], // two pair, aces and kings
+ other: ["AC", "3D"], // a pair of aces
+ };
+
+ const entries = () => PotCalculator.buildEntries([
+ { id: "short", contribution: 20, folded: false },
+ { id: "big", contribution: 100, folded: false },
+ { id: "other", contribution: 100, folded: false },
+ ]);
+
+ // What the wizard does: read the hands still in for *this* round.
+ const readRound = state => HandRank.showdown(board,
+ PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] })));
+
+ test("the short stack takes the main pot with the best hand", () => {
+ const round = readRound(entries());
+ assert.equal(round.complete, true);
+ assert.deepEqual(round.winners, ["short"]);
+ });
+
+ test("and the side pot goes to the best of the two who are left", () => {
+ const first = PotCalculator.resolveRound(entries(), ["short"]);
+ // 20 from each of three: the short stack is paid 60 and is out of it.
+ assert.equal(Money.round(first.perWinner[0].amount), 60);
+
+ const round = readRound(first.updatedEntries);
+ assert.deepEqual(round.winners, ["big"],
+ "not the trips, which are no longer eligible for this money");
+ assert.equal(round.players.length, 2, "and the short stack is not even offered");
+
+ const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
+ assert.equal(Money.round(second.perWinner[0].amount), 160);
+ });
+
+ test("every chip is accounted for across the two rounds", () => {
+ const first = PotCalculator.resolveRound(entries(), ["short"]);
+ const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
+ assert.equal(Money.round(first.payoutPool + second.payoutPool), 220);
+ assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0);
+ });
+});
+
+describe("what it refuses to answer", () => {
+ const board = ["KD", "9C", "4S", "AH", "2H"];
+
+ test("a folded player's cards are not in the running", () => {
+ // They are not a contender, so they are never handed in — which means a hand
+ // that would have won cannot take a pot its owner threw away.
+ const table = PotCalculator.buildEntries([
+ { id: "live", contribution: 50, folded: false },
+ { id: "mucked", contribution: 50, folded: true },
+ ]);
+ const contenders = PotCalculator.getContenders(table);
+ assert.deepEqual(contenders.map(c => c.id), ["live"]);
+
+ const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] })));
+ assert.deepEqual(round.winners, ["live"], "even holding nothing");
+ });
+
+ test("one hand not turned over and it hands the question back", () => {
+ const round = HandRank.showdown(board, [
+ { id: "a", cards: ["9H", "9S"] },
+ { id: "b", cards: ["", ""] },
+ ]);
+ assert.equal(round.complete, false);
+ assert.deepEqual(round.winners, []);
+ assert.deepEqual(round.unknown, ["b"]);
+ });
+
+ test("a board still being dealt is not a showdown", () => {
+ const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [
+ { id: "a", cards: ["9H", "9S"] },
+ { id: "b", cards: ["AS", "KC"] },
+ ]);
+ assert.equal(round.complete, false);
+ assert.deepEqual(round.winners, []);
+ });
+
+ test("a card entered twice stops it rather than guessing", () => {
+ // The failure mode that matters: a mis-typed card produces a hand that ranks
+ // perfectly well and is not the one on the table. Refusing is the only honest
+ // answer, and the dealer picks the winner the way they always could.
+ const round = HandRank.showdown(board, [
+ { id: "a", cards: ["9H", "9S"] },
+ { id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board
+ ]);
+ assert.equal(round.duplicated, true);
+ assert.equal(round.complete, false);
+ assert.deepEqual(round.winners, []);
+ });
+
+ test("and two players holding the same card is caught as well", () => {
+ const round = HandRank.showdown(board, [
+ { id: "a", cards: ["QH", "QS"] },
+ { id: "b", cards: ["QH", "JC"] },
+ ]);
+ assert.equal(round.duplicated, true);
+ assert.equal(round.complete, false);
+ });
+});
+
+describe("a table that never asked for any of this", () => {
+ test("has no board, tracks nothing, and reads no showdown", () => {
+ const state = HoldemState.createDefault();
+ assert.equal(state.trackBoard, false);
+ // Which is what the wizard checks before it reads anything at all — see
+ // readShowdown() in potWizard.js. With it off, an empty board would decline
+ // anyway, so the tool cannot start answering a question nobody asked.
+ const round = HandRank.showdown(state.board, [
+ { id: "a", cards: ["9H", "9S"] },
+ { id: "b", cards: ["AS", "KC"] },
+ ]);
+ assert.equal(round.complete, false);
+ assert.deepEqual(round.winners, []);
+ });
+});
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
index 08090bd..9e5911a 100644
--- a/tests/holdem/runUiAudit.js
+++ b/tests/holdem/runUiAudit.js
@@ -34,26 +34,55 @@ const OVERLAYS = [
];
// A table part-way through a real night: long names, uneven stacks, a hand log.
-function seedTable() {
+//
+// Two of them. The sweep ran on one table shape for its whole life — five players at a
+// nine-handed table — and how much room the felt has depends on how many people are
+// sitting down more than on anything a viewport can tell you. A full table is where the
+// seats crowd, and it was never once looked at: measured when it finally was, seventeen
+// of fifty-four tables had one player's name or stack painted under another.
+const TABLES = [
+ ["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
+ ["Bob", 250], ["Dee", 600]], false],
+ ["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
+ ["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
+ ["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
+ // The half-full table again, with the board turned on and five cards on it. The
+ // board is furniture in the middle of the felt that nothing else was ever measured
+ // against — and where the middle cannot hold it, it moves to a strip above the
+ // table and takes the felt's height with it, which is the seats' problem too.
+ //
+ // Deliberately the same five players in the same nine chairs as the shape above, so
+ // the only thing that differs between them is the board. A seventh player would have
+ // told us that a seven-handed table crowds, which is true, already known, and not
+ // what this row is here to find out.
+ ["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
+ ["Bob", 250], ["Dee", 600]], true],
+];
+
+function seedTable([seats, players, board]) {
const state = HoldemState.createDefault();
- HoldemState.resizeSeats(state, 9);
- [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
- .forEach(([name, cash], i) => {
- state.seats[i].name = name;
- state.seats[i].inHand = true;
- state.seats[i].buyIns = cash;
- state.seats[i].stack = cash;
- });
+ HoldemState.resizeSeats(state, seats);
+ players.forEach(([name, cash], i) => {
+ state.seats[i].name = name;
+ state.seats[i].inHand = true;
+ state.seats[i].buyIns = cash;
+ state.seats[i].stack = cash;
+ });
state.smallBlind = 5;
state.bigBlind = 10;
for (let hand = 1; hand <= 4; hand++) {
state.handHistory.push({
handNumber: hand, timestamp: Date.now(), pot: 120,
- payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
- ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
+ payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
+ ledger: state.seats.slice(0, players.length)
+ .map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
});
}
state.handNumber = 5;
+ if (board) {
+ state.trackBoard = true;
+ state.board = ["KD", "9C", "4S", "AH", "2H"];
+ }
HoldemState.save(state);
}
@@ -85,33 +114,75 @@ async function main() {
const hand = touch ? "touch" : "mouse";
for (const [width, height, rawLabel] of VIEWPORTS) {
- const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
- await page.goto(`${BASE}/Casino/TexasHoldem`);
- await page.evaluate(seedTable);
- await page.goto(`${BASE}/Casino/TexasHoldem`);
- await page.click("#nextHandBtn");
- await page.waitForTimeout(300);
- await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
- await page.addScriptTag({ path: AUDIT });
-
- const table = await page.evaluate(() => LayoutAudit.runAll());
- checks++;
- if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
- table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
-
- for (const [name, open] of OVERLAYS) {
- await page.evaluate(open);
- await page.waitForTimeout(180);
- checks++;
- const result = await page.evaluate(() => {
- const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
- if (!overlay) return { offenders: [] };
- return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
+ for (const [shape, seats, players, board] of TABLES) {
+ const label = `${rawLabel} · ${shape} · ${hand}`;
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.evaluate(seedTable, [seats, players, board]);
+ await page.goto(`${BASE}/Casino/TexasHoldem`);
+ await page.click("#nextHandBtn");
+ await page.waitForTimeout(300);
+ // Deal it, and get chips in front of a few players. The sweep used to
+ // audit a table that had never had a bet on it, so the bets layer was
+ // empty at every viewport it visited and nothing it drew was ever looked
+ // at — which is a third of what is on the felt during a hand.
+ await page.evaluate(() => {
+ const deal = [...document.querySelectorAll("#actionBar button")]
+ .find(b => /post blinds/i.test(b.textContent));
+ if (deal) deal.click();
});
- await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
- if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
+ await page.waitForTimeout(220);
+ for (let i = 0; i < 3; i++) {
+ const acted = await page.evaluate(() => {
+ const btn = [...document.querySelectorAll("#actionBar button")]
+ .find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
+ if (!btn) return false;
+ btn.click();
+ return true;
+ });
+ if (!acted) break;
+ await page.waitForTimeout(120);
+ }
+ if (board) {
+ await page.evaluate(() => {
+ const s = HoldemState.load();
+ s.street = "river";
+ s.board = ["KD", "9C", "4S", "AH", "2H"];
+ s.seats.filter(p => p.inHand).forEach((p, i) => {
+ p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
+ ["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
+ });
+ HoldemState.save(s);
+ });
+ await page.reload();
+ await page.waitForTimeout(300);
+ }
+ await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
+ await page.addScriptTag({ path: AUDIT });
+
+ const table = await page.evaluate(() => LayoutAudit.runAll());
+ checks++;
+ if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
+ table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
+
+ // The overlays don't change with the table's shape, so they are only
+ // opened over the half-full one — twice the viewports is enough time
+ // spent already.
+ if (shape !== TABLES[0][0]) continue;
+
+ for (const [name, open] of OVERLAYS) {
+ await page.evaluate(open);
+ await page.waitForTimeout(180);
+ checks++;
+ const result = await page.evaluate(() => {
+ const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
+ if (!overlay) return { offenders: [] };
+ return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
+ });
+ await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
+ if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
+ }
}
}
diff --git a/tests/holdem/seatFootprint.test.js b/tests/holdem/seatFootprint.test.js
new file mode 100644
index 0000000..beeec27
--- /dev/null
+++ b/tests/holdem/seatFootprint.test.js
@@ -0,0 +1,174 @@
+// Whether two seats can end up wanting the same pixels.
+//
+// The felt used to decide this from one number: the straight-line distance between the
+// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
+// halves of that were wrong, and the running page showed it. Measured across nine
+// viewports and six table sizes:
+//
+// seventeen of fifty-four tables had one seat's name or stack painted under another
+// ten of them had the collision between two seats that are not neighbours
+//
+// The second number is the interesting one. On a phone held sideways the felt is 528px
+// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
+// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
+// every neighbouring pair was fine and the check said the table was clean.
+//
+// The first is what a distance test can't fix even looking at every pair: distance has
+// one number in it, and a puck is half again as wide as it is tall. 84px between centres
+// is generous side by side and nowhere near enough on the diagonal.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { Render } = require("./load");
+
+const CSS = fs.readFileSync(
+ path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
+
+describe("two boxes on a ring", () => {
+ // A phone held sideways, once the action bar has taken its share: a felt far wider
+ // than it is tall, on which the ring is very nearly a straight line.
+ const felt = { width: 400, height: 70 };
+ const flat = { rx: 44, ry: 42 };
+
+ test("a pair that is not neighbours still counts", () => {
+ // Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
+ // on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
+ // you here is the one across the table, because the rim doubles back at the ends
+ // of a shape this flat and a long walk apart is no distance at all.
+ assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
+
+ const neighbours = (count, w, h) => {
+ const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
+ .map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
+ return points.some((point, i) => {
+ const next = points[(i + 1) % points.length];
+ return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
+ });
+ };
+ assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
+ "no neighbouring pair overlaps, which is exactly why this had to look at all of them");
+ });
+
+ test("and the answer is the box, not a radius", () => {
+ // Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
+ // between them is 69px, so the test this replaces — "are these two further apart
+ // than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
+ const boxes = { width: 200, height: 100 };
+ const twoSeats = (dx, dy) => {
+ const original = Render.ringPositions;
+ Render.ringPositions = () => [
+ { left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
+ { left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
+ ];
+ try {
+ return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
+ } finally {
+ Render.ringPositions = original;
+ }
+ };
+ assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
+ assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
+ assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
+ assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
+ });
+
+ test("with daylight, so rounded corners don't read as touching", () => {
+ assert.ok(Render.SEAT_CLEARANCE_PX > 0);
+ });
+
+ test("a table nobody is sitting at can't collide with itself", () => {
+ assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
+ assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
+ });
+});
+
+describe("the widths the felt may choose from", () => {
+ test("run widest first, because the widest that fits is the one wanted", () => {
+ const widths = Render.seatWidthCandidates();
+ widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
+ });
+
+ test("and span the whole declared range", () => {
+ const widths = Render.seatWidthCandidates();
+ assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
+ assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
+ });
+
+ test("the floor is still a name, not a smudge", () => {
+ // At 44px the tight layout shows about five characters and an ellipsis. Below
+ // that a puck stops telling the dealer who is sitting there, which is the one
+ // thing on the felt it exists to say.
+ assert.ok(Render.SEAT_WIDTH_MIN >= 44);
+ assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
+ assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
+ });
+
+ test("and there are few enough of them to measure after every tap", () => {
+ // Each one costs a forced layout. Ten is the budget; the cache carries the rest.
+ assert.ok(Render.seatWidthCandidates().length <= 12);
+ });
+});
+
+describe("what the stylesheet has to keep holding up", () => {
+ test("the puck's width comes from the property, not from a breakpoint", () => {
+ // Three media queries used to declare it — 78px, 64px and 62px — three guesses
+ // at a number that depends on how many people are sitting down, which no media
+ // query can see. The same 390px phone has room for 90px at four seats and 61 at
+ // nine.
+ const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
+ const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
+ .map(m => m[3]);
+ const hardWidths = seatRules
+ .flatMap(body => body.match(/(? !decl.includes("var(--seatPuckW"));
+ assert.deepEqual(hardWidths, []);
+ });
+
+ test("the puck measures what it says it measures", () => {
+ // As content-box a 64px puck came out 74 across once its padding and border were
+ // counted, and the search compares that number against distances on the felt.
+ const rule = CSS.match(/\n\.seat \{[^}]*\}/);
+ assert.ok(rule, ".seat rule not found");
+ assert.match(rule[0], /box-sizing:\s*border-box/);
+ });
+
+ test("and a blind badge never reaches sideways off its own puck", () => {
+ // This is what makes the footprint the puck and nothing more. The badge's
+ // remaining overhang is vertical, and it points away from the table at both ends
+ // — `top` on the upper arc, `bottom` on the lower — so two seats facing each
+ // other across the felt wear their badges back to back.
+ //
+ // It was not always so: at `left: -6px` the small blind hung off the puck's left
+ // corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
+ // "SB" was painted across the next player's $1,137.
+ const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
+ assert.equal(badges.length, 3, "expected all three blind badges");
+ badges.forEach(([, which, body]) => {
+ const left = body.match(/(?= 0,
+ `.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
+ });
+ });
+
+ test("and all three sit in the same place, so there is one spot to look at", () => {
+ // Two badges in two positions means the dealer's eye has to find which of them
+ // this seat is using before it can read which blind it is, forty times a night.
+ // A seat only ever wears one: the blinds are different players by construction,
+ // and the straddle chain starts left of the big blind and stops short of the
+ // small one.
+ const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
+ .map(([, , body]) => (body.match(/(? {
+ test("nothing here throws", () => {
+ assert.equal(Render.measuredSeatBox(), null);
+ assert.equal(Render.seatFootprint(null), null);
+ assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 });
+ });
+});
diff --git a/tests/holdem/staleToast.test.js b/tests/holdem/staleToast.test.js
new file mode 100644
index 0000000..0c90b74
--- /dev/null
+++ b/tests/holdem/staleToast.test.js
@@ -0,0 +1,106 @@
+// The toast that outlived the thing it was talking about.
+//
+// Toasts are news about the last action, and they carry an Undo. Take another action
+// and both of those go wrong at once: the sentence describes a step that is now two
+// steps back, and the Undo behind it restores a snapshot from before everything since.
+// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
+// players call is an offer to move the button, wired to a button that deletes the hand.
+//
+// It is also simply in the way. The toast is a fixed strip near the bottom of the
+// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
+// felt and the action bar, so a stale one covers seats and controls for eight seconds.
+//
+// So the bar drops it as it takes the next action. These tests hold that funnel shut:
+// every action a dealer can take from the bar goes through `commit`, and `commit`
+// clears the toast before it does anything else.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { ActionBar, Betting, makeTable } = require("./load");
+
+const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
+const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
+
+// A bar sat at a real table, with the toast replaced by something that keeps a log
+// and the re-render stubbed out — none of these rules need the DOM back.
+function onTable() {
+ const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
+ const log = [];
+ globalThis.Toast = {
+ onScreen: null,
+ show(message) { this.onScreen = message; log.push(`show ${message}`); },
+ dismiss() { this.onScreen = null; log.push("dismiss"); },
+ };
+ ActionBar.state = state;
+ ActionBar.render = () => {};
+ ActionBar.hooks = {
+ beginAction: label => log.push(`begin ${label}`),
+ save: () => log.push("save"),
+ rerender: () => log.push("rerender"),
+ };
+ return { bar: ActionBar, state, log };
+}
+
+test.afterEach(() => { delete globalThis.Toast; });
+
+describe("a toast does not outlive its own news", () => {
+ test("the next action from the bar clears it", () => {
+ const { bar, state, log } = onTable();
+ Toast.show("Next hand — button on seat 2.");
+
+ bar.commit("dealing the hand", () => Betting.beginHand(state));
+
+ assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
+ assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
+ `the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
+ });
+
+ test("a toast raised by the action itself survives", () => {
+ const { bar, log } = onTable();
+ bar.commit("something that talks back", () => Toast.show("Bob is all in."));
+
+ assert.equal(Toast.onScreen, "Bob is all in.",
+ "commit clears what was there when it started, not what its own work puts up");
+ assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
+ });
+
+ test("with no toast on screen it is still just one dismiss", () => {
+ const { bar, log } = onTable();
+ bar.commit("a fold", () => {});
+ assert.equal(log.filter(l => l === "dismiss").length, 1);
+ });
+
+ test("a page with no Toast at all does not throw", () => {
+ const { bar, state } = onTable();
+ delete globalThis.Toast;
+ assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
+ });
+});
+
+describe("every bar action really does go through that funnel", () => {
+ // The dismiss is worth nothing if some button reaches past `commit` to mutate the
+ // table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
+ // straight from a click handler would be one that skips the clean-up — and the
+ // undo history with it, which is the older reason this funnel exists.
+ test("no click handler calls the betting engine directly", () => {
+ const strays = barSource
+ .split("\n")
+ .map((line, i) => [i + 1, line])
+ .filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
+ .filter(([, line]) => !/this\.commit\(/.test(line))
+ .map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
+
+ assert.deepEqual(strays, [], "these change the table without going through commit");
+ });
+
+ test("commit is what clears the toast, not each button", () => {
+ const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
+ // Two: this one, and the raise pad — which opens without committing anything,
+ // and needs the strip clear because the pad makes the bar tall enough to push
+ // a toast up over the seats.
+ assert.equal(dismissals, 2,
+ "a third dismissal means a button is doing by hand what commit already does");
+ });
+});
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index 8a80799..6d0c633 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -93,6 +93,20 @@ const LayoutAudit = {
return false;
},
+ // The scrolling ancestor whose box this element has fallen outside of, if any.
+ // Wholly outside — an element straddling the edge is still half on show, and the
+ // half that is showing is worth hit-testing.
+ clippedOutBy(el, rect) {
+ for (let node = el.parentElement; node; node = node.parentElement) {
+ const style = getComputedStyle(node);
+ if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue;
+ const box = node.getBoundingClientRect();
+ if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 ||
+ rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node;
+ }
+ return null;
+ },
+
// The modal that is currently on top, if any.
//
// Every check below asks "is this thing reachable", and while a sheet or dialog is
@@ -143,6 +157,22 @@ const LayoutAudit = {
return;
}
+ // Still on screen, but clipped away by a panel that scrolls. Hit-testing it
+ // is meaningless: the point lands on whatever the panel's own edge has next
+ // to it, and the answer comes back as a diagnosis of something that isn't
+ // happening. The pot wizard's third winner, below the fold in a list that
+ // scrolls, was reported for four viewports running as "fully covered by
+ // button Confirm Winner(s)" — the button is simply what is painted at that
+ // height. Whether it is a fault is the same question as for anything else
+ // out of view: can it be scrolled to.
+ const clip = this.clippedOutBy(el, rect);
+ if (clip) {
+ if (!this.reachableByScrolling(el)) {
+ offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`);
+ }
+ return;
+ }
+
const points = [
[rect.left + rect.width / 2, rect.top + rect.height / 2],
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
@@ -777,6 +807,140 @@ const LayoutAudit = {
return { name: "seat names aren't painted under their own badges", offenders };
},
+ // A seat's writing, covered by something belonging to a different seat.
+ //
+ // seatLabelsClear() above asks whether a puck's own decoration sits on its own name.
+ // This asks the other half, and the other half was where the tool was actually
+ // failing: measured across nine viewports and six table sizes before this went in,
+ // seventeen of fifty-four tables had one seat's name or stack painted under a
+ // neighbour. On a Galaxy S8 at the most ordinary table there is — six players at a
+ // nine-handed table — seat 2's stack was under seat 3's puck, and five pairs of
+ // seats overlapped, the worst by 61px.
+ //
+ // Two ways in, and it takes both:
+ // - the pucks themselves overlapping, which the layout used to permit on the
+ // reasoning that rounded corners forgive a little; what they were forgiving was
+ // a four-figure stack.
+ // - a blind badge hanging off its puck onto the seat next door, which no amount
+ // of space between the pucks fixes on its own.
+ //
+ // Hit-tested against the glyph run rather than the element box. A name is centred,
+ // so its box is wider than its ink and testing the box would fail on empty air.
+ //
+ // The glyph run is then clipped back to what is actually painted, which is not the
+ // same thing and cost this check its first honest-looking failure. A puck's name is
+ // a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every
+ // line the text *would* have taken — including the two that the clamp throws away.
+ // Measured at 640x360 with twelve players: "Mike from work" reported a line 30px
+ // below its own puck, which duly hit-tested onto the seat underneath, and the seats
+ // were not touching at all. Nothing is painted there, so nothing can cover it.
+ seatsKeepTheirText() {
+ const name = "no seat covers another seat's writing";
+ if (this.modalScope()) {
+ return { name, offenders: [], skipped: "a sheet is open over the table" };
+ }
+ const seats = [...document.querySelectorAll(".seat")];
+ if (!seats.length) return { name, offenders: [], skipped: "no seats on this page" };
+
+ const offenders = [];
+ seats.forEach((seat, index) => {
+ seat.querySelectorAll(".seatPuckName, .seatPuckStack, .seatPuckTotal").forEach(label => {
+ const text = label.textContent.trim();
+ if (!text) return;
+ const painted = label.getBoundingClientRect();
+ if (!painted.width || !painted.height) return;
+ const range = document.createRange();
+ range.selectNodeContents(label);
+ [...range.getClientRects()].forEach(raw => {
+ const line = {
+ left: Math.max(raw.left, painted.left),
+ right: Math.min(raw.right, painted.right),
+ top: Math.max(raw.top, painted.top),
+ bottom: Math.min(raw.bottom, painted.bottom),
+ };
+ if (line.right - line.left <= 1 || line.bottom - line.top <= 1) return;
+ // Along the line rather than at its middle: a badge takes the start
+ // of it and a neighbouring puck the end, and a single centre probe
+ // walks past both.
+ for (let across = 0.08; across <= 0.92; across += 0.14) {
+ const x = line.left + (line.right - line.left) * across;
+ const y = (line.top + line.bottom) / 2;
+ const over = document.elementFromPoint(x, y);
+ if (!over || seat.contains(over) || over.contains(seat)) continue;
+ offenders.push(
+ `seat ${index + 1} "${text}" is under ${this.describe(over)}`);
+ return;
+ }
+ });
+ });
+ });
+
+ return { name, offenders };
+ },
+
+ // The other thing on the felt that carries a number.
+ //
+ // A chip says what a player has put in this street, which is what a call costs. Two
+ // of them on top of each other is two figures a dealer can't read, and one behind a
+ // puck is a figure that isn't there at all.
+ //
+ // Geometry rather than hit-testing, which is the opposite of how every other check
+ // here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint
+ // never returns a chip — it returns whatever is behind it, for every chip, always.
+ // A first version of this probe hit-tested and duly reported all forty-five
+ // configurations as broken, including the ones that were fine. Paint order is known
+ // and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat
+ // carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind.
+ chipsKeepTheirAmounts() {
+ const name = "no bet chip is hidden behind a seat or another chip";
+ if (this.modalScope()) {
+ return { name, offenders: [], skipped: "a sheet is open over the table" };
+ }
+ const chips = [...document.querySelectorAll(".betChip")]
+ .filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
+ if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" };
+
+ const overlap = (A, B) =>
+ Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 &&
+ Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5;
+ const offenders = [];
+ const seats = [...document.querySelectorAll(".seat")];
+
+ chips.forEach(chip => {
+ const label = chip.textContent.trim() || "a chip";
+ const box = chip.getBoundingClientRect();
+ seats.forEach((seat, index) => {
+ // Its own puck is the one thing it is allowed to touch: on a narrow felt
+ // the chip clings to the seat rather than taking a ring of its own.
+ if (seat.contains(chip)) return;
+ if (overlap(box, seat.getBoundingClientRect())) {
+ offenders.push(`chip ${label} is behind seat ${index + 1}`);
+ }
+ });
+ // A chip wider than the puck it is clinging to is on its neighbours by
+ // construction, whatever the ring does.
+ const host = chip.closest(".seat");
+ if (host && chip.classList.contains("betChipAnchored")) {
+ const puck = host.getBoundingClientRect();
+ if (box.width > puck.width + 0.5) {
+ offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` +
+ `wider than its own puck`);
+ }
+ }
+ });
+
+ for (let a = 0; a < chips.length; a++) {
+ for (let b = a + 1; b < chips.length; b++) {
+ if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) {
+ offenders.push(`chip ${chips[a].textContent.trim()} and chip ` +
+ `${chips[b].textContent.trim()} are on top of each other`);
+ }
+ }
+ }
+
+ return { name, offenders: [...new Set(offenders)] };
+ },
+
// ---- Runner ----
// Every check this file knows how to make, every time.
@@ -897,6 +1061,8 @@ const LayoutAudit = {
this.oneDealerButton(),
this.hiddenButPainted(),
this.seatLabelsClear(),
+ this.seatsKeepTheirText(),
+ this.chipsKeepTheirAmounts(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];