Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.
Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.
Turning the players' cards over is unchanged. That already worked the way it should.
What is new is what happens next. Resolve Pot stops asking who won and says so:
Alexandra wins $50 with three of a kind, nines.
with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.
It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.
Side pots are read per round, not once. 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 the one the tool has to ask for again.
handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel 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. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.
The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.
That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.
The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.
728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -29,11 +29,15 @@
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
<div id="tableCentre">
|
||||
<div id="boardRow" hidden aria-label="Community cards"></div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="betsLayer" aria-hidden="true"></div>
|
||||
@@ -81,6 +85,11 @@
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Track the board and read the showdown</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">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.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
@@ -656,6 +665,7 @@
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -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));
|
||||
@@ -3348,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;
|
||||
@@ -4018,6 +4192,19 @@ body.hasActionBar #toastHost {
|
||||
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 {
|
||||
@@ -4177,21 +4364,17 @@ 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) {
|
||||
/* And 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. The player's ceiling moves
|
||||
from under their name to beside it: 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. */
|
||||
.potWizardChoiceText {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.potWizardChoiceUpTo {
|
||||
flex: none;
|
||||
.potWizardChoiceHand {
|
||||
font-size: var(--fs-2xs);
|
||||
}
|
||||
|
||||
.raiseNote {
|
||||
|
||||
@@ -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
|
||||
@@ -1130,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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -157,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? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> 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.
|
||||
? `<strong>${Money.format(remaining)}</strong> in play.`
|
||||
: this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> 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 => {
|
||||
@@ -173,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);
|
||||
@@ -187,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.
|
||||
@@ -220,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;
|
||||
@@ -233,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");
|
||||
|
||||
@@ -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 ?? "";
|
||||
@@ -791,14 +793,17 @@ const Render = {
|
||||
return { x: nx / length, y: ny / length };
|
||||
},
|
||||
|
||||
// The pot total's box, in the bets layer's own pixels. It lives on `#pokerTable`
|
||||
// rather than on either overlay, and the two are not concentric — the layers are
|
||||
// inset by different amounts top and bottom — so it is measured rather than assumed
|
||||
// to be at 50%, 50%.
|
||||
potBadgeBox(box) {
|
||||
const badge = document.getElementById("potBadge");
|
||||
if (!badge) return null;
|
||||
const at = badge.getBoundingClientRect();
|
||||
// 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,
|
||||
@@ -963,7 +968,7 @@ const Render = {
|
||||
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.potBadgeBox(box) };
|
||||
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
|
||||
@@ -1195,6 +1200,9 @@ const Render = {
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
});
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
@@ -1221,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+38
-9
@@ -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
|
||||
@@ -38,7 +40,7 @@ text is painted outside its box, that no seat covers another seat's writing, and
|
||||
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 two table shapes, mid-hand:
|
||||
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.
|
||||
@@ -49,12 +51,22 @@ 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)".
|
||||
|
||||
Two table shapes 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.
|
||||
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
|
||||
@@ -71,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()`.
|
||||
|
||||
@@ -95,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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -42,13 +42,24 @@ const OVERLAYS = [
|
||||
// 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]]],
|
||||
["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]) {
|
||||
function seedTable([seats, players, board]) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
players.forEach(([name, cash], i) => {
|
||||
@@ -68,6 +79,10 @@ function seedTable([seats, players]) {
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
if (board) {
|
||||
state.trackBoard = true;
|
||||
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
}
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
@@ -101,10 +116,10 @@ async function main() {
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
for (const [shape, seats, players] of TABLES) {
|
||||
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]);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
@@ -129,6 +144,20 @@ async function main() {
|
||||
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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user