Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0cfd24647 | ||
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 | ||
|
|
4071be5329 | ||
|
|
8a7d7b09bf | ||
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace | ||
|
|
3fe16fa7f2 | ||
|
|
f199148046 | ||
|
|
38536a5c0c | ||
|
|
286a924fe4 | ||
|
|
3e9e2e899d | ||
|
|
96cd7684f0 | ||
|
|
1af82dfddf | ||
|
|
2cc2c66976 |
@@ -25,7 +25,6 @@
|
||||
<section id="tableSection">
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
@@ -103,6 +102,7 @@
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session</summary>
|
||||
<div class="panelBody">
|
||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div class="standingsTableWrap">
|
||||
<div id="standingsTable">
|
||||
@@ -156,6 +156,14 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel" id="keyboardPanel">
|
||||
<summary>Keyboard</summary>
|
||||
<div class="panelBody">
|
||||
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<div class="panelBody">
|
||||
@@ -294,7 +302,9 @@
|
||||
not during it.
|
||||
</p>
|
||||
|
||||
<section class="ruling">
|
||||
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
|
||||
|
||||
<section class="ruling" data-short="Misdeal">
|
||||
<h3>Redeal it — misdeal</h3>
|
||||
<ul>
|
||||
<li>The first card goes to the wrong seat.</li>
|
||||
@@ -304,7 +314,7 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Board card early">
|
||||
<h3>A board card turned too early</h3>
|
||||
<p>
|
||||
If the fourth board card gets turned before the betting round is finished,
|
||||
@@ -313,7 +323,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Out of turn">
|
||||
<h3>Acting out of turn</h3>
|
||||
<p>
|
||||
A one-off is usually just backed up if no one has acted behind them.
|
||||
@@ -321,7 +331,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Short all-in">
|
||||
<h3>A short all-in doesn't reopen the betting</h3>
|
||||
<p>
|
||||
Somebody shoving in less than a full raise — say $3 more when the last
|
||||
@@ -336,7 +346,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Uncalled bet">
|
||||
<h3>Chips nobody called come back</h3>
|
||||
<p>
|
||||
Bet more than anyone was able to call and the excess is returned to you
|
||||
@@ -346,7 +356,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Oversized chip">
|
||||
<h3>One big chip, nothing said</h3>
|
||||
<p>
|
||||
The most common argument at a home table. A player facing a bet who
|
||||
@@ -362,7 +372,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Minimum raise">
|
||||
<h3>A raise has to be a full raise</h3>
|
||||
<p>
|
||||
At least as much again as the last raise, or the big blind if there
|
||||
@@ -373,7 +383,7 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<section class="ruling" data-short="Odd chip">
|
||||
<h3>The odd chip in a split pot</h3>
|
||||
<p>
|
||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||
@@ -383,6 +393,22 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="String bet">
|
||||
<h3>Betting in one motion — the string bet</h3>
|
||||
<p>
|
||||
A bet is whatever goes in on the first trip to the stack, unless the
|
||||
amount was said out loud first. Reaching back for more chips without
|
||||
having said "raise" is a string bet, and only the first motion stands.
|
||||
Saying the number is always enough on its own: "raise to sixty" binds
|
||||
whatever order the chips arrive in.
|
||||
</p>
|
||||
<p>
|
||||
It's the rule home games invoke most and enforce least, and both are
|
||||
fine — the point is that a player can't watch the table react and then
|
||||
decide how much to bet. Say it, or push it all at once.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="rulingsSource">
|
||||
Follows the Tournament Directors Association rules, which most rooms and
|
||||
home games take their lead from.
|
||||
|
||||
@@ -212,6 +212,20 @@ button,
|
||||
}
|
||||
|
||||
.holdemBtn {
|
||||
/* Stated, because this class is worn by both <button> and <a> and the browser
|
||||
does not give them the same box. Chromium's own stylesheet makes a button
|
||||
border-box and leaves a link content-box, so `min-height: 46px` on the header
|
||||
row came out as 46px on the buttons and 64px on the two links — and the row
|
||||
stretches to its tallest item, so every button became 64px too. A 46px touch
|
||||
target was quietly costing 64px of a phone screen, twice over, because the
|
||||
header wraps to two rows. */
|
||||
box-sizing: border-box;
|
||||
/* And the touch floor is stated here rather than only under a width breakpoint.
|
||||
It used to arrive by accident — the content-box links were 64px tall and the
|
||||
row stretched everything to match — so once the boxes agree, a landscape phone
|
||||
(wider than the phone breakpoint, still a thumb) would have been left with 42px
|
||||
buttons. 44px is the floor everywhere; the narrow layout still asks for 46. */
|
||||
min-height: 44px;
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
color: #eee;
|
||||
border: 1px solid #3a3a3a;
|
||||
@@ -343,21 +357,18 @@ button,
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.06);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
|
||||
from behind the pot badge in two pieces — measured at every seat count from two to
|
||||
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
|
||||
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
|
||||
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
|
||||
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
|
||||
fell off the oval entirely at 360px. A square felt with six to nine seats has no
|
||||
clear band wide enough for a word.
|
||||
|
||||
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
|
||||
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. */
|
||||
|
||||
#potBadge {
|
||||
position: relative;
|
||||
@@ -499,13 +510,53 @@ button,
|
||||
|
||||
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
|
||||
the name has to stay readable rather than fading almost to the felt. */
|
||||
/* Out of the hand, still readable.
|
||||
|
||||
Both of these used to fade the whole seat — opacity 0.5 folded, 0.58 sitting out —
|
||||
and opacity on a box fades the text you still have to read along with the box you
|
||||
wanted to push back. Measured off the rendered pixels at 11.5px text, where AA asks
|
||||
for 4.5:1:
|
||||
|
||||
live name 13.28 stack 4.75
|
||||
folded name 4.75 stack 2.22
|
||||
sat out name 7.00 stack 3.22
|
||||
|
||||
A folded player's stack is not decoration. It's what they'll have next hand and the
|
||||
answer to "how much has Ben got?", and at 2.22:1 it was barely there.
|
||||
|
||||
So the box recedes and the text doesn't: a flatter, darker background and a muted
|
||||
border say "not in this hand", while the name and the stack keep colours that
|
||||
measure. The folded seat keeps its red border and the line through the name, which
|
||||
is the part that actually names the state. */
|
||||
.seat.seatInactive,
|
||||
.seat.seatFolded {
|
||||
background: linear-gradient(160deg, #232323, #171717);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* A folded seat already says so twice — a red border and a line through the name — but
|
||||
a seat that's sitting out said it only by being faint, and taking the faintness away
|
||||
took the whole signal with it. A dashed edge is the same thing an empty chair wears,
|
||||
which is right: neither is in this hand. The name and the stack tell them apart. */
|
||||
.seat.seatInactive {
|
||||
opacity: 0.58;
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.seat.seatInactive .seatPuckName,
|
||||
.seat.seatFolded .seatPuckName {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.seat.seatInactive .seatPuckStack,
|
||||
.seat.seatFolded .seatPuckStack,
|
||||
.seat.seatInactive .seatPuckTotal,
|
||||
.seat.seatFolded .seatPuckTotal {
|
||||
color: #8f8f8f;
|
||||
}
|
||||
|
||||
.seat.seatFolded {
|
||||
border-color: rgba(224, 123, 123, 0.4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.seat.seatFolded .seatPuckName {
|
||||
@@ -695,6 +746,23 @@ button,
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Seats on the bottom of the ring wear their badge underneath, because that's the
|
||||
edge the pot isn't on. Bets sit between a seat and the pot, so on the bottom arc
|
||||
the chip comes down onto the seat's top edge — which is exactly where this badge
|
||||
was, and the chip is a layer above it. See render.js for the measurement. */
|
||||
.seat.seatBelowCentre .blindBadge {
|
||||
top: auto;
|
||||
bottom: -9px;
|
||||
}
|
||||
|
||||
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
|
||||
can't hide it again. Which blind a seat is posting is the one thing on that puck
|
||||
nothing else on the felt repeats — a bet chip's amount is on the seat's own total
|
||||
line as well. Measured across 12 table sizes: no chip loses its amount to this. */
|
||||
.blindBadge {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Both badges failed WCAG AA against their own gradients — measured at the worst
|
||||
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
|
||||
dealer is meant to read at a glance across a table.
|
||||
@@ -982,9 +1050,24 @@ button,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* A bare checkbox ships at 13x13, which misses the 24px minimum with a mouse as well
|
||||
as with a thumb. The row around it is the real target on touch, but the box itself
|
||||
should be findable on any device. */
|
||||
#dedicatedDealerToggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
/* An underlined word was a 91x14 target, which misses the 24px minimum with a
|
||||
mouse as well as with a thumb. The coarse-pointer block takes it to 44; this is
|
||||
the floor everywhere else, and it changes nothing about how it looks. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
color: #d8b46a;
|
||||
font-family: inherit;
|
||||
@@ -1049,6 +1132,104 @@ button,
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Amber, like the bank check's disagreement — both are "your record of the night is
|
||||
wrong and you can still do something about it". */
|
||||
.storageWarning {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid rgba(240, 180, 106, 0.45);
|
||||
border-radius: 7px;
|
||||
background: rgba(240, 180, 106, 0.08);
|
||||
color: #f0b46a;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
|
||||
telling it about them — the same pointer question the touch-target rules ask. */
|
||||
@media (pointer: coarse) {
|
||||
#keyboardPanel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcutLegend {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.45rem 0.6rem;
|
||||
margin: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.shortcutLegend dt {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.shortcutLegend dd {
|
||||
margin: 0;
|
||||
color: #b8b8b8;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.shortcutKey {
|
||||
background: #232323;
|
||||
border: 1px solid #3f3f3f;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.32rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
color: #d8b46a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
|
||||
a dealer scanning this is looking for the words somebody at the table just said. */
|
||||
.rulingsIndex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.rulingsIndexBtn {
|
||||
box-sizing: border-box;
|
||||
min-height: 32px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: #232323;
|
||||
color: #d8b46a;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.rulingsIndexBtn:hover {
|
||||
background: #2e2e2e;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
}
|
||||
|
||||
/* A thumb needs the same 44px here as everywhere else. */
|
||||
@media (pointer: coarse) {
|
||||
.rulingsIndexBtn {
|
||||
min-height: 44px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* The heading the index jumps to takes focus, and a focus ring on a heading that the
|
||||
dealer just asked for reads as noise rather than as help. */
|
||||
.ruling h3:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ruling {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 0.7rem;
|
||||
@@ -1439,7 +1620,16 @@ button,
|
||||
|
||||
.sidebarPanel summary {
|
||||
cursor: pointer;
|
||||
padding: 0.7rem 0;
|
||||
/* These headers are the sidebar's whole navigation and were 38px tall on every
|
||||
device, phones included — the touch pass further down never reached them.
|
||||
Padding rather than a flex box with min-height, because a <summary> only draws
|
||||
its disclosure triangle while it is display: list-item. */
|
||||
padding: 0.85rem 0;
|
||||
/* A <summary> is content-box, so min-height would have been *added* to the padding
|
||||
— measured at 71px before this line went in. Same trap the header buttons were
|
||||
in, one file over. */
|
||||
box-sizing: border-box;
|
||||
min-height: 44px;
|
||||
font-size: 0.9rem;
|
||||
color: #eee;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -2017,6 +2207,25 @@ body.hasActionBar #holdemSidebar {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
|
||||
.actionUndoKey {
|
||||
/* See .actionKey below for why these are not fainter. */
|
||||
margin-left: 0.4rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.75;
|
||||
font-family: inherit;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
|
||||
to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
|
||||
the folded seats above: a fade chosen for looks pushing text a dealer is meant to
|
||||
read under the threshold. They're still quieter than the label they sit beside. */
|
||||
.actionKey {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@@ -2024,7 +2233,7 @@ body.hasActionBar #holdemSidebar {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.4;
|
||||
opacity: 0.75;
|
||||
font-family: inherit;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.4;
|
||||
@@ -2548,6 +2757,16 @@ body.hasActionBar #toastHost {
|
||||
color: #d6ffe0;
|
||||
}
|
||||
|
||||
/* The chips to push, under the amount they add up to. Quieter than the payout it
|
||||
belongs to — the dealer reads the name and the money first, then counts. */
|
||||
.payoutChips {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.76rem;
|
||||
color: #9fb8a6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.potWizardActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
@@ -2668,10 +2887,6 @@ body.hasActionBar #toastHost {
|
||||
padding: 0.25rem 0.3rem;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
#potBadge {
|
||||
padding: 0.5rem 1.1rem;
|
||||
}
|
||||
@@ -3154,3 +3369,66 @@ textarea:focus-visible {
|
||||
font-size: 0.72rem;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
/* ---------- Touch targets on a wide touch screen ----------
|
||||
|
||||
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
|
||||
never the question. A phone held sideways is 844px wide, so none of that work
|
||||
applied to it. Measured on an emulated touch device at 844x390, with the sidebar
|
||||
panels open: twelve controls under 44px, including the rulings link at 91x14 and
|
||||
the dedicated-dealer checkbox at 13x13.
|
||||
|
||||
Restated here against the pointer instead of the viewport. The width-scoped block
|
||||
keeps its layout changes — one column, stacked dialogs — because those really are
|
||||
about how much room there is. This is only about what a thumb can hit, so it asks
|
||||
about the thumb. */
|
||||
@media (pointer: coarse) {
|
||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody select {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.panelBody .holdemBtnSmall,
|
||||
.handHistoryActions .holdemBtn,
|
||||
.toastAction {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Destructive and tiny is the worst combination: these delete a chip colour or a
|
||||
custom field, and are 28px wide. */
|
||||
.removeChipColorBtn,
|
||||
.removeFieldBtn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* flex-shrink would take a stated width straight back off again. */
|
||||
.chipColorInput {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* A bare checkbox can't grow into a target, but the row it sits in can — the
|
||||
label is what a tap actually lands on. */
|
||||
.panelBody .fieldRow {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#dedicatedDealerToggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* An underlined word is a 14px-tall target; give it room without making it look
|
||||
like a button. */
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,12 @@ const ActionBar = {
|
||||
row.appendChild(pot);
|
||||
|
||||
// How many are still in the hand — part of the standard per-street
|
||||
// announcement, and the number a dealer is asked for most often.
|
||||
const remaining = Betting.liveSeats(this.state).length;
|
||||
// announcement, and the number a dealer is asked for most often. Before the
|
||||
// deal it counts the table as it will be dealt, so a busted player waiting on a
|
||||
// rebuy isn't announced as one of the players. Read-only: the sitting-out
|
||||
// itself happens when the hand is dealt, not while the bar is being drawn.
|
||||
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
|
||||
const remaining = Betting.liveSeats(counted).length;
|
||||
const players = document.createElement("span");
|
||||
players.className = "actionPlayers";
|
||||
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
||||
@@ -172,6 +176,17 @@ const ActionBar = {
|
||||
undo.className = "actionUndo";
|
||||
undo.title = `Undo ${undoLabel}`;
|
||||
undo.textContent = `↶ ${undoLabel}`;
|
||||
// U undoes, and nothing said so — this control was the only place a dealer
|
||||
// could have learned it, and it was the one action bar control without its
|
||||
// key printed on it.
|
||||
const key = document.createElement("kbd");
|
||||
// Its own class, not .actionKey: that one pins itself to the top-right
|
||||
// corner of a big action button, and this control is a 30px pill with
|
||||
// overflow hidden and no positioning context — the cap escaped it and
|
||||
// floated above the bar. Here the key belongs inline, after the label.
|
||||
key.className = "actionUndoKey";
|
||||
key.textContent = this.KEY_HINTS.actionUndo;
|
||||
undo.appendChild(key);
|
||||
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||
row.appendChild(undo);
|
||||
}
|
||||
@@ -277,6 +292,8 @@ const ActionBar = {
|
||||
actionAdvance: "↵",
|
||||
raiseConfirm: "↵",
|
||||
raiseCancel: "esc",
|
||||
raiseAllIn: "A",
|
||||
actionUndo: "U",
|
||||
},
|
||||
|
||||
button(label, className, onClick, sublabel) {
|
||||
@@ -289,7 +306,10 @@ const ActionBar = {
|
||||
main.textContent = label;
|
||||
btn.appendChild(main);
|
||||
|
||||
const hint = this.KEY_HINTS[className];
|
||||
// Looked up by any of the button's classes, so a button can carry a shared
|
||||
// class for its looks and a specific one for its key — the raise pad's four
|
||||
// quick amounts are all raiseQuickBtn, but only All-in has a key.
|
||||
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
|
||||
if (hint) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "actionKey";
|
||||
@@ -341,7 +361,10 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
if (stack > owed) {
|
||||
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
|
||||
// Not "does this player owe anything" — the big blind owes nothing and is
|
||||
// still raising. It's whether there is a live bet to raise.
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
|
||||
this.raiseOpen = true;
|
||||
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||
this.render();
|
||||
@@ -374,6 +397,7 @@ const ActionBar = {
|
||||
// or a player whose whole stack is under a full raise never gets told that.
|
||||
const legalMin = Betting.minRaiseTo(this.state);
|
||||
const minTo = Math.min(legalMin, maxTo);
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "raisePad";
|
||||
@@ -389,7 +413,7 @@ const ActionBar = {
|
||||
];
|
||||
options.forEach(option => {
|
||||
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||
const btn = this.button(option.label, "raiseQuickBtn", () => {
|
||||
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
|
||||
this.raiseDraft = value;
|
||||
this.render();
|
||||
}, Money.format(value));
|
||||
@@ -406,7 +430,7 @@ const ActionBar = {
|
||||
input.inputMode = "decimal";
|
||||
input.className = "raiseInput";
|
||||
input.value = this.raiseDraft ?? minTo;
|
||||
input.setAttribute("aria-label", "Raise to amount");
|
||||
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
|
||||
input.addEventListener("input", () => {
|
||||
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||
@@ -418,11 +442,16 @@ const ActionBar = {
|
||||
this.render();
|
||||
}));
|
||||
|
||||
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
||||
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
|
||||
// under it to raise past.
|
||||
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
|
||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||
this.raiseOpen = false;
|
||||
const who = HoldemState.displayName(player, index);
|
||||
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
|
||||
const label = raising
|
||||
? `${who}'s raise to ${Money.format(target)}`
|
||||
: `${who}'s bet of ${Money.format(target)}`;
|
||||
this.commit(label, () => Betting.raiseTo(this.state, index, target));
|
||||
}));
|
||||
|
||||
wrap.appendChild(entry);
|
||||
@@ -451,6 +480,7 @@ const ActionBar = {
|
||||
|
||||
const typed = Number(this.raiseDraft);
|
||||
const current = this.state.currentBet || 0;
|
||||
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||
|
||||
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
||||
|
||||
@@ -459,16 +489,20 @@ const ActionBar = {
|
||||
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
||||
|
||||
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
||||
note.textContent =
|
||||
`${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
|
||||
// The way out of an under-minimum differs: with a bet in front of you the
|
||||
// alternative is calling it, and with an empty pot there's nothing to call.
|
||||
note.textContent = raising
|
||||
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
|
||||
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
|
||||
note.className = "raiseNote raiseNoteWarn";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
||||
note.textContent =
|
||||
`All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
|
||||
note.textContent = raising
|
||||
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
|
||||
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
|
||||
note.className = "raiseNote raiseNoteInfo";
|
||||
note.hidden = false;
|
||||
return;
|
||||
@@ -503,22 +537,35 @@ const ActionBar = {
|
||||
|
||||
const preview = Betting.blindPreview(this.state);
|
||||
const seated = Betting.liveSeats(this.state).length;
|
||||
const broke = Betting.brokeSeats(this.state);
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
const listNames = indexes => indexes.length === 1
|
||||
? name(indexes[0])
|
||||
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine";
|
||||
|
||||
if (!preview) {
|
||||
// Two players is the least you can deal. Say what's missing rather than
|
||||
// offering a button that would quietly do nothing.
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
: "Only one player is dealt in — a hand needs two.";
|
||||
// offering a button that would quietly do nothing — and say the *right*
|
||||
// thing: "only one player is dealt in" is wrong when four are sitting there
|
||||
// and three of them are just out of chips.
|
||||
const withChips = seated - broke.length;
|
||||
if (broke.length > 0 && withChips < 2) {
|
||||
line.textContent = withChips === 1
|
||||
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
|
||||
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
||||
} else {
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
: "Only one player is dealt in — a hand needs two.";
|
||||
}
|
||||
line.classList.add("dealPromptBlocked");
|
||||
wrap.appendChild(line);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
line.textContent =
|
||||
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
@@ -536,6 +583,16 @@ const ActionBar = {
|
||||
wrap.appendChild(moved);
|
||||
}
|
||||
|
||||
// Dealing sits out anyone with nothing behind. Said before it happens, for the
|
||||
// same reason the button move is: a dealer shouldn't find out from the felt.
|
||||
if (preview.satOut.length > 0) {
|
||||
const out = document.createElement("p");
|
||||
out.className = "dealPromptLine dealPromptAside";
|
||||
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
|
||||
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
|
||||
wrap.appendChild(out);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
|
||||
@@ -32,28 +32,102 @@ const Betting = {
|
||||
// Who would post if the hand were dealt right now. Used to preview the blinds
|
||||
// while the table is still being set, so a dealer can see where they'll land
|
||||
// before any money moves.
|
||||
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
|
||||
//
|
||||
// Not the same as all-in: an all-in player has money riding on the hand. This is
|
||||
// somebody who busted and hasn't bought back in yet, which a home game produces
|
||||
// about once an hour. A seat with no stack *recorded* isn't broke either —
|
||||
// stackOf falls back to the table's starting stack for those — only an explicit
|
||||
// zero counts.
|
||||
brokeSeats(state) {
|
||||
const broke = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
if (!player.inHand) return;
|
||||
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
|
||||
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
|
||||
broke.push(index);
|
||||
});
|
||||
return broke;
|
||||
},
|
||||
|
||||
// The table as it will be once the seats that can't post are dealt around. A view,
|
||||
// not a change: the preview has to ask the same question the deal will answer, and
|
||||
// asking it of a different shape of table is how the two came apart last time.
|
||||
tableAsDealt(state) {
|
||||
const broke = this.brokeSeats(state);
|
||||
if (broke.length === 0) return state;
|
||||
const skip = new Set(broke);
|
||||
return {
|
||||
...state,
|
||||
seats: state.seats.map((player, index) =>
|
||||
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
|
||||
};
|
||||
},
|
||||
|
||||
blindPreview(state) {
|
||||
// Dealing relocates a button parked on an empty chair before it works out who
|
||||
// posts, so the preview has to answer for the button where it *will* be. Asking
|
||||
// from where it currently sits gave a different pair of blinds than the deal
|
||||
// produced — the preview promising one thing and doing another is exactly the
|
||||
// guessing this whole step exists to remove.
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(state);
|
||||
const asDealt = buttonIndex === state.dealerSeatIndex
|
||||
? state
|
||||
: { ...state, dealerSeatIndex: buttonIndex };
|
||||
// guessing this whole step exists to remove. Same reason the broke seats come
|
||||
// out first: the preview once promised a small blind from a seat with no chips.
|
||||
const dealt = this.tableAsDealt(state);
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||
? dealt
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
|
||||
const satOut = this.brokeSeats(state);
|
||||
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
||||
if (sbIndex === null || bbIndex === null) return null;
|
||||
return {
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual,
|
||||
buttonIndex,
|
||||
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||
satOut,
|
||||
smallBlind: this.round(state.smallBlind || 0),
|
||||
bigBlind: this.round(state.bigBlind || 0),
|
||||
};
|
||||
},
|
||||
|
||||
// Does handing this seat chips also put them back in the game?
|
||||
//
|
||||
// At a real table those are one event, and here they were two: a busted player got
|
||||
// their chips and stayed sat out, silently skipped hand after hand until somebody
|
||||
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
|
||||
// read "Ben added $100".
|
||||
//
|
||||
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
|
||||
// somebody out on purpose and is topping them up, and dealing them back in would be
|
||||
// the tool guessing at something it wasn't told. Only a seat with no stack of its
|
||||
// own: busted at zero, or a chair being bought into for the first time.
|
||||
//
|
||||
// Deliberately reads player.stack rather than stackOf(), which substitutes the
|
||||
// table's starting stack for a seat that has none — the question here is what this
|
||||
// seat was actually recorded as holding.
|
||||
buyInDealsThemIn(player) {
|
||||
if (!player || player.inHand) return false;
|
||||
const own = player.stack;
|
||||
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
|
||||
return !recorded || Number(own) <= this.EPSILON_CHIPS;
|
||||
},
|
||||
|
||||
// "bet" or "raise" — what putting more in is actually called right now.
|
||||
//
|
||||
// A wager into a pot nobody has wagered into is a bet; everything after that is a
|
||||
// raise. The bar decided this from what the acting player still owes, which gets
|
||||
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
|
||||
// their own blind is a live bet and putting more in is a raise. It gets the other
|
||||
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
|
||||
// and wrote "Ann's raise to $10" into the undo control for it.
|
||||
//
|
||||
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
|
||||
// blind's option and one for an opening bet). This is only what it's called, which
|
||||
// for a tool a dealer announces the action from is not a small thing.
|
||||
wagerVerb(state) {
|
||||
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
|
||||
},
|
||||
|
||||
committed(player) {
|
||||
return this.round(player.committed || 0);
|
||||
},
|
||||
@@ -303,6 +377,11 @@ const Betting = {
|
||||
// True when the hand is still live but nobody can bet again — the dealer is just
|
||||
// running the board out.
|
||||
isRunningOut(state) {
|
||||
// Only while a hand is actually being played. Before the deal, a table of
|
||||
// busted players who haven't rebought looks exactly like a table of all-ins to
|
||||
// this test, and the bar was announcing a run-out over a felt with no cards and
|
||||
// no chips on it.
|
||||
if (!state.bettingOpen) return false;
|
||||
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||
},
|
||||
|
||||
@@ -340,6 +419,21 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
// Deal around anyone with no chips, before anything else reads the table.
|
||||
//
|
||||
// A busted player who hasn't rebought used to be dealt in anyway, and the
|
||||
// result was a hand nobody could finish: the table promised a small blind from
|
||||
// a seat with nothing to post it with, took it from nobody, put the action on
|
||||
// the big blind with only their own money in front of them, and announced
|
||||
// "3 players — all-in, running out the board" before a card was dealt.
|
||||
//
|
||||
// Sitting them out is what a dealer does. Seat, name and buy-in record are
|
||||
// untouched, so dealing them back in is one tap when they buy chips.
|
||||
this.brokeSeats(state).forEach(index => {
|
||||
state.seats[index].inHand = false;
|
||||
state.seats[index].folded = false;
|
||||
});
|
||||
|
||||
// Position is read off the button all through the hand, so it has to be on
|
||||
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||
// brand-new table starts on.
|
||||
|
||||
@@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
},
|
||||
|
||||
// Two players change chairs. Undoable like every other seat action, because a
|
||||
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||
swapSeats: (from, to) => {
|
||||
const movedName = nameOf(from);
|
||||
const intoName = nameOf(to);
|
||||
const wasEmpty = Render.isEmptySeat(state.seats[to]);
|
||||
runUndoable(
|
||||
wasEmpty
|
||||
? `${movedName} moved to seat ${to + 1}.`
|
||||
: `${movedName} and ${intoName} changed seats.`,
|
||||
() => HoldemState.swapSeats(state, from, to));
|
||||
},
|
||||
|
||||
makeDealer: index => {
|
||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
@@ -140,11 +153,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
|
||||
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
|
||||
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
|
||||
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||
|
||||
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
|
||||
(dealBackIn
|
||||
? handRunning ? " — in from the next hand." : " — dealt in."
|
||||
: ".");
|
||||
|
||||
seatAction(seatIndex, message, p => {
|
||||
// Chips in front and cash in are different questions — the stack moves
|
||||
// every hand, what they've handed over only moves when they buy.
|
||||
player.stack = Money.round(Betting.stackOf(state, player) + amount);
|
||||
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
|
||||
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
||||
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
|
||||
|
||||
if (dealBackIn) {
|
||||
p.inHand = true;
|
||||
// Same rule "Deal in" follows: you can't buy into a pot you haven't
|
||||
// been paying for, and `folded` is exactly that state for one hand.
|
||||
p.folded = handRunning;
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -378,6 +407,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show("Table reset.", { duration: 2500 });
|
||||
});
|
||||
|
||||
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
|
||||
//
|
||||
// Delegated from the document rather than wired per field: the raise pad, the
|
||||
// add-chips sheet and the seat editor all build their inputs fresh every render,
|
||||
// and a box added later should not have to remember to ask for this.
|
||||
document.addEventListener("focusin", event => {
|
||||
const field = event.target;
|
||||
if (!Money.selectsOnFocus(field)) return;
|
||||
if (!field.value) return;
|
||||
// After the browser has finished placing its own caret, or it wins the race
|
||||
// and the selection collapses again the moment the tap completes.
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== field) return;
|
||||
try {
|
||||
field.select();
|
||||
} catch {
|
||||
// A number input refuses setSelectionRange in some browsers; select()
|
||||
// is the one that works on both, and if neither does the field is
|
||||
// simply left as it was.
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
@@ -406,7 +458,90 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
||||
// The one time the browser refuses to save, say so out loud as well as leaving the
|
||||
// standing warning in the Session panel. Set before the first render so a failure
|
||||
// during start-up is caught too.
|
||||
// Another tab writing to the same table. Said once, with the way out attached —
|
||||
// reloading here adopts whatever that tab has done, which is the only resolution
|
||||
// that doesn't throw somebody's work away.
|
||||
HoldemState.onOtherTab = () => {
|
||||
Toast.show(
|
||||
"This table is open in another tab. Whichever acts last wins.",
|
||||
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
|
||||
Render.renderStorageWarning();
|
||||
};
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
HoldemState.onSaveFailed = () => {
|
||||
Toast.show(
|
||||
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
||||
{ duration: 12000 });
|
||||
Render.renderStorageWarning();
|
||||
};
|
||||
|
||||
// The shortcut legend, built from the same list the handlers are keyed off, so a
|
||||
// shortcut can't exist without a line here saying what it does.
|
||||
const legend = document.getElementById("shortcutLegend");
|
||||
if (legend) {
|
||||
Shortcuts.LEGEND.forEach(entry => {
|
||||
const term = document.createElement("dt");
|
||||
entry.keys.forEach(key => {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "shortcutKey";
|
||||
kbd.textContent = key;
|
||||
term.appendChild(kbd);
|
||||
});
|
||||
const detail = document.createElement("dd");
|
||||
detail.textContent = entry.what;
|
||||
legend.appendChild(term);
|
||||
legend.appendChild(detail);
|
||||
});
|
||||
}
|
||||
|
||||
// An index across the top of the rulings sheet, one chip per ruling.
|
||||
//
|
||||
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
|
||||
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
|
||||
// the odd-chip rule had to read past seven others to find it.
|
||||
//
|
||||
// Built from the sections rather than written out beside them, so a ruling added
|
||||
// later turns up in the index without anyone having to remember to add it twice.
|
||||
const buildRulingsIndex = () => {
|
||||
const index = document.getElementById("rulingsIndex");
|
||||
if (!index || index.childElementCount > 0) return;
|
||||
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
|
||||
const heading = section.querySelector("h3");
|
||||
if (!heading) return;
|
||||
if (!section.id) section.id = `ruling${i + 1}`;
|
||||
|
||||
const jump = document.createElement("button");
|
||||
jump.type = "button";
|
||||
jump.className = "rulingsIndexBtn";
|
||||
// A short label where the ruling gives one. The headings are sentences —
|
||||
// "A short all-in doesn't reopen the betting" — and nine of those stacked
|
||||
// one per row made the index 342px tall on a phone, which is most of the
|
||||
// sheet before the first ruling. "Short all-in" says the same thing to
|
||||
// somebody who is looking for it, and three fit on a line.
|
||||
jump.textContent = section.dataset.short || heading.textContent;
|
||||
// The full heading is still what it announces, so the short label never
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
// The heading is what the dealer is now reading, so that is where the
|
||||
// keyboard and a screen reader should be too.
|
||||
heading.setAttribute("tabindex", "-1");
|
||||
heading.focus({ preventScroll: true });
|
||||
});
|
||||
index.appendChild(jump);
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => {
|
||||
buildRulingsIndex();
|
||||
rulingsOverlay.hidden = false;
|
||||
});
|
||||
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||
rulingsOverlay.addEventListener("click", e => {
|
||||
if (e.target === rulingsOverlay) closeRulings();
|
||||
@@ -447,4 +582,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
});
|
||||
|
||||
// The load happens on the first line of this file, long before there's a Toast to
|
||||
// show it in, so this is checked here rather than wired as a callback. The standing
|
||||
// warning in the Session panel doesn't need it — that reads the flag every render —
|
||||
// but an empty table is a thing a dealer needs told, not left to notice.
|
||||
if (HoldemState.loadFailed) {
|
||||
Toast.show(
|
||||
"The saved table couldn't be read, so this one started empty.",
|
||||
{ duration: 12000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +48,76 @@ const Money = {
|
||||
return (this.toCents(amount) / 100).toFixed(2);
|
||||
},
|
||||
|
||||
// The chips to actually push, for an amount.
|
||||
//
|
||||
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
|
||||
// still have to work out that $132 is two blacks, a green, a red and two whites
|
||||
// while six people watch. The chip legend already knows the denominations.
|
||||
//
|
||||
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
|
||||
// values — but poker chips are not arbitrary. Every real set is canonical (each
|
||||
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
|
||||
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
|
||||
// use one chip more than it had to.
|
||||
//
|
||||
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||
// problem for the dealer to solve, not a rounding error to hide.
|
||||
chipBreakdown(amount, legend) {
|
||||
let cents = this.toCents(amount);
|
||||
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||
|
||||
const values = (legend || [])
|
||||
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
|
||||
.filter(entry => entry.cents > 0)
|
||||
.sort((a, b) => b.cents - a.cents);
|
||||
|
||||
const chips = [];
|
||||
values.forEach(entry => {
|
||||
const count = Math.floor(cents / entry.cents);
|
||||
if (count <= 0) return;
|
||||
cents -= count * entry.cents;
|
||||
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
|
||||
});
|
||||
|
||||
return { chips, remainder: this.fromCents(cents) };
|
||||
},
|
||||
|
||||
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
|
||||
//
|
||||
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
|
||||
// hasn't told us its denominations, so there is no advice to give and the amount
|
||||
// stands on its own. A legend that exists but can't reach the amount is different,
|
||||
// and does say so.
|
||||
formatChipBreakdown(amount, legend) {
|
||||
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
|
||||
if (!hasDenominations) return "";
|
||||
|
||||
const { chips, remainder } = this.chipBreakdown(amount, legend);
|
||||
if (chips.length === 0 && remainder === 0) return "";
|
||||
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
|
||||
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
|
||||
return parts.join(", ");
|
||||
},
|
||||
|
||||
// Should focusing this field select what's already in it?
|
||||
//
|
||||
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
|
||||
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
|
||||
// it already has. A dealer taps the box because they want a *different* number,
|
||||
// and a tap puts the caret after the suggestion instead of over it. Measured on a
|
||||
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
|
||||
// showing 200, tap, type "50" — 20050.
|
||||
//
|
||||
// Only number boxes. A name or a note is a thing you edit, and selecting all of
|
||||
// it on focus would be the same mistake pointing the other way.
|
||||
selectsOnFocus(field) {
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
const type = String(field.type || "").toLowerCase();
|
||||
if (type === "number") return true;
|
||||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
|
||||
@@ -246,10 +246,23 @@ const PotWizard = {
|
||||
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
const legend = (this.state && this.state.chipLegend) || [];
|
||||
const list = document.createElement("ul");
|
||||
for (const [id, amount] of this.totals.entries()) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||
|
||||
// The amount is the answer to the arithmetic; the chips are the answer
|
||||
// to the dealer's job. Only when the legend has been filled in — a
|
||||
// table that never set up chip colours gets the number on its own,
|
||||
// which is what it had before.
|
||||
const chips = Money.formatChipBreakdown(amount, legend);
|
||||
if (chips) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "payoutChips";
|
||||
note.textContent = chips;
|
||||
li.appendChild(note);
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(list);
|
||||
|
||||
@@ -49,11 +49,21 @@ const Render = {
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
},
|
||||
|
||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||
// generated from a touch screen, so on a phone the last clause of the hint below
|
||||
// described something impossible — on the device the rest of the sentence was
|
||||
// written for. Touch gets told about the menu route instead, which works.
|
||||
canDragSeats() {
|
||||
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
|
||||
},
|
||||
|
||||
renderTableHint() {
|
||||
const hint = document.getElementById("tableHint");
|
||||
// Gestures are invisible until someone tells you about them, and this is the
|
||||
// only place on the table with room to say so.
|
||||
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
||||
hint.textContent = this.canDragSeats()
|
||||
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
|
||||
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
@@ -171,6 +181,16 @@ const Render = {
|
||||
node.style.top = `${pos.top}%`;
|
||||
node.dataset.seatIndex = String(index);
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip
|
||||
// arrives from — the chip sits on the same spoke, closer in. For a seat on
|
||||
// the top of the ring that's from below; on the bottom, from above.
|
||||
//
|
||||
// The blind badge has to be on the other side or the chip lands on top of
|
||||
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
|
||||
// the badge's 288 square pixels, and it sits a layer higher, so the marker
|
||||
// telling the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
@@ -731,6 +751,41 @@ const Render = {
|
||||
|
||||
this.renderBankReconciliation();
|
||||
this.renderSettleUp(standings);
|
||||
this.renderStorageWarning();
|
||||
},
|
||||
|
||||
// A toast is a moment; this table not being saved is a fact about the rest of the
|
||||
// night. It sits at the top of the panel a dealer opens to check the money, and it
|
||||
// stays there, because the raise pad dismisses toasts and the one time this warning
|
||||
// matters is the one time it must not have been missed.
|
||||
renderStorageWarning() {
|
||||
const host = document.getElementById("storageWarning");
|
||||
if (!host) return;
|
||||
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
|
||||
if (host.hidden) return;
|
||||
|
||||
// Any of these can be true at once — a browser having trouble with storage
|
||||
// tends to have it in both directions — and the one that explains an empty
|
||||
// table goes first, because that's the question the dealer is standing there
|
||||
// asking.
|
||||
const lines = [];
|
||||
if (HoldemState.otherTabActive) {
|
||||
lines.push(
|
||||
"This table is open in another tab, and both are writing to it — " +
|
||||
"whichever acts last wins, so work done in the other one can vanish. " +
|
||||
"Close one, then reload this tab to pick up the table as it stands.");
|
||||
}
|
||||
if (HoldemState.loadFailed) {
|
||||
lines.push(
|
||||
"The saved table couldn't be read, so this one started empty. " +
|
||||
"The unreadable copy has been kept rather than written over.");
|
||||
}
|
||||
if (HoldemState.saveFailed) {
|
||||
lines.push(
|
||||
"This table isn't being saved — the browser refused. It'll keep working, " +
|
||||
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
|
||||
}
|
||||
host.textContent = lines.join(" ");
|
||||
},
|
||||
|
||||
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||
|
||||
@@ -6,10 +6,16 @@ const SeatMenu = {
|
||||
hooks: null,
|
||||
seatIndex: null,
|
||||
|
||||
mode: null,
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.seatIndex = seatIndex;
|
||||
// Always the action list, never whatever the sheet was showing when it last
|
||||
// closed — a seat menu that opens on a list of chairs is a menu that lost the
|
||||
// dealer's place.
|
||||
this.mode = null;
|
||||
this.render();
|
||||
document.getElementById("seatMenuOverlay").hidden = false;
|
||||
},
|
||||
@@ -63,29 +69,81 @@ const SeatMenu = {
|
||||
}
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
// The chairs this player could move to. Every other chair: swapping into an empty
|
||||
// one is how a player moves, and swapping with an occupied one is two players
|
||||
// changing places, which is the other half of the same job.
|
||||
destinationsFor(index) {
|
||||
return this.state.seats
|
||||
.map((player, seat) => ({ player, seat }))
|
||||
.filter(entry => entry.seat !== index);
|
||||
},
|
||||
|
||||
openMovePicker() {
|
||||
this.mode = "move";
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this.mode === "move") {
|
||||
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "seatMenuBtn";
|
||||
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||
const named = (other.name || "").trim();
|
||||
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||
btn.addEventListener("click", () => {
|
||||
const from = this.seatIndex;
|
||||
this.close();
|
||||
this.hooks.swapSeats(from, seat);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
||||
subtitle.textContent = bits.join(" · ");
|
||||
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
list.innerHTML = "";
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||
// other action is the end of the interaction.
|
||||
if (action.label === "Move to…") {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,23 @@ const Shortcuts = {
|
||||
escape: "cancel",
|
||||
},
|
||||
|
||||
// What each one does, in a dealer's words, for the legend in the sidebar.
|
||||
//
|
||||
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
|
||||
// buttons they drive, but A and U were on nothing at all — a dealer who learned
|
||||
// the first three from the bar had no way to find out the other two existed. A
|
||||
// shortcut with no line here is a shortcut nobody can find, and there's a test
|
||||
// that says so.
|
||||
LEGEND: [
|
||||
{ keys: ["F"], action: "fold", what: "Fold" },
|
||||
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||
],
|
||||
|
||||
mount(handlers) {
|
||||
this.handlers = handlers;
|
||||
document.addEventListener("keydown", e => this.onKey(e));
|
||||
|
||||
@@ -81,12 +81,69 @@ const HoldemState = {
|
||||
};
|
||||
},
|
||||
|
||||
// Where an unreadable save is kept instead of being written over.
|
||||
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
||||
|
||||
// True once another tab has written to this table. Two tabs each hold their own
|
||||
// copy in memory and each save writes the whole thing, so the second one to act
|
||||
// silently throws the first one's work away — measured: tab A deals and takes a
|
||||
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
||||
// nothing said in either tab.
|
||||
//
|
||||
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
||||
// would be its own hazard; knowing there are two of them is what they need.
|
||||
otherTabActive: false,
|
||||
onOtherTab: null,
|
||||
|
||||
// The browser fires this in every *other* tab, never the one that wrote, so our
|
||||
// own saves can't trip it.
|
||||
watchOtherTabs() {
|
||||
if (typeof window === "undefined" || this.tabWatcher) return;
|
||||
this.tabWatcher = event => {
|
||||
if (event.key !== this.STORAGE_KEY) return;
|
||||
if (this.otherTabActive) return;
|
||||
this.otherTabActive = true;
|
||||
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
||||
};
|
||||
window.addEventListener("storage", this.tabWatcher);
|
||||
},
|
||||
|
||||
// True when there was a saved table and it couldn't be read. Same pair as
|
||||
// saveFailed above, for the same reason: the UI has to be able to say so.
|
||||
loadFailed: false,
|
||||
onLoadFailed: null,
|
||||
|
||||
// A saved table that won't parse becomes an empty table, which is the only thing
|
||||
// it can become — but it used to become one silently, and then the first edit
|
||||
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
||||
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
||||
// a person might have picked out of the raw JSON was gone a tap later.
|
||||
//
|
||||
// So the unreadable value is moved aside first. It is not offered as a restore —
|
||||
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
||||
// an empty one — but it stops being destroyed, and somebody who wants the
|
||||
// standings out of it still can.
|
||||
failedLoad(raw) {
|
||||
if (!this.loadFailed) {
|
||||
this.loadFailed = true;
|
||||
try {
|
||||
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||
} catch {
|
||||
// No room to keep it. Nothing more to try, and the warning below is
|
||||
// the part that actually matters.
|
||||
}
|
||||
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
||||
}
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
|
||||
@@ -118,16 +175,37 @@ const HoldemState = {
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return this.createDefault();
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||
// toast is a moment and this is a fact about the rest of the night.
|
||||
saveFailed: false,
|
||||
|
||||
// Called the first time a save fails. Set by the UI.
|
||||
onSaveFailed: null,
|
||||
|
||||
save(state) {
|
||||
try {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage full/unavailable (private browsing, etc.) — the table still works
|
||||
// for the rest of the session, it just won't survive a refresh.
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Storage full, or private browsing. The table still works for the rest of
|
||||
// the session — it just won't survive a refresh, and nothing used to say
|
||||
// so. That is the same shape of fault the bank check exists for: silent
|
||||
// until it is too late to do anything about it. A dealer who finds out by
|
||||
// refreshing has lost the night's standings, and the only warning they got
|
||||
// was the table being empty.
|
||||
//
|
||||
// Announced once. A dealer tapping a bar thirty times a hand does not need
|
||||
// thirty of these.
|
||||
if (!this.saveFailed) {
|
||||
this.saveFailed = true;
|
||||
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -467,6 +545,21 @@ const HoldemState = {
|
||||
.sort((a, b) => b.net - a.net);
|
||||
},
|
||||
|
||||
// Two players change chairs.
|
||||
//
|
||||
// The whole seat moves — name, stack, cards, what they have in the pot — because
|
||||
// that is what changing chairs is. What stays behind is everything that belongs to
|
||||
// the *position*: the button, the blinds and the acting index all name a chair, and
|
||||
// a player who moves into the big blind's chair is the big blind. That is the same
|
||||
// answer a card room gives.
|
||||
swapSeats(state, a, b) {
|
||||
const seats = state.seats || [];
|
||||
if (a === b) return false;
|
||||
if (!seats[a] || !seats[b]) return false;
|
||||
[seats[a], seats[b]] = [seats[b], seats[a]];
|
||||
return true;
|
||||
},
|
||||
|
||||
// The one check a banker actually needs, and the one nothing here did.
|
||||
//
|
||||
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Dealing around a player who busted and hasn't bought back in.
|
||||
//
|
||||
// A home game produces one of these about once an hour. The table used to deal them
|
||||
// in anyway, promise a blind from a seat with nothing to post it with, take it from
|
||||
// nobody, and then sit on a hand that could never finish.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const postedBy = state =>
|
||||
state.seats
|
||||
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
|
||||
.filter(Boolean);
|
||||
|
||||
test("a seat with no chips and nothing in the pot is broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.deepEqual(Betting.brokeSeats(state), [2]);
|
||||
});
|
||||
|
||||
test("an all-in player is not broke — they have money riding on the hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, 2);
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("a chair nobody is sitting in isn't broke, it's empty", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.deepEqual(Betting.brokeSeats(state), []);
|
||||
});
|
||||
|
||||
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].name = "Cal";
|
||||
state.seats[2].buyIns = 100;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
|
||||
assert.equal(state.seats[2].name, "Cal");
|
||||
assert.equal(state.seats[2].buyIns, 100);
|
||||
});
|
||||
|
||||
test("the blinds skip the broke seat and land on players who can post", () => {
|
||||
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.deepEqual(postedBy(state), [
|
||||
{ seat: 3, amount: state.smallBlind },
|
||||
{ seat: 4, amount: state.bigBlind },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the preview promises what the deal produces", () => {
|
||||
// The fault this whole step exists to prevent: the preview once read a table the
|
||||
// deal was about to change underneath it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.deepEqual(preview.satOut, [1]);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const posted = postedBy(state);
|
||||
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
|
||||
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
|
||||
});
|
||||
|
||||
test("asking for the preview doesn't sit anybody out on its own", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
|
||||
Betting.blindPreview(state);
|
||||
|
||||
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
|
||||
});
|
||||
|
||||
test("with only one player left holding chips there is nothing to preview", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
|
||||
assert.equal(Betting.blindPreview(state), null);
|
||||
});
|
||||
|
||||
test("no chips move when the broke seats are dealt around", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("the board isn't running out before the hand is dealt", () => {
|
||||
// Two busted players and one with chips looks exactly like a table of all-ins to
|
||||
// the run-out test, and the bar was announcing one over an empty felt.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[2].stack = 0;
|
||||
state.bettingOpen = false;
|
||||
|
||||
assert.equal(Betting.isRunningOut(state), false);
|
||||
});
|
||||
|
||||
test("buying back in puts them in the next hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[2].inHand, false);
|
||||
|
||||
// What the dealer does when they hand over cash: chips, and back in.
|
||||
state.seats[2].stack = 100;
|
||||
state.seats[2].inHand = true;
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
});
|
||||
|
||||
test("the table dealt around them counts the right number of players", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 0;
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 4);
|
||||
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
|
||||
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Whether handing a seat chips also puts them back in the game.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
test("a busted player who has been sat out comes back in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].stack = 0;
|
||||
Betting.beginHand(state); // the deal sits them out
|
||||
|
||||
assert.equal(state.seats[1].inHand, false);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
|
||||
});
|
||||
|
||||
test("a chair being bought into for the first time comes in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("a seat sat out while holding chips stays out", () => {
|
||||
// A dealer sat them out on purpose and is topping them up. Dealing them back in
|
||||
// would be the tool guessing at something it wasn't told.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].inHand = false;
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("somebody already in the hand is left exactly where they are", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
|
||||
|
||||
// Including a player who is in but out of chips — they're all-in, not sat out.
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(state.seats[2].inHand, true);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a blank stack field reads as nothing recorded, not as a number", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[3].name = "Dee";
|
||||
state.seats[3].stack = "";
|
||||
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
|
||||
});
|
||||
|
||||
test("it doesn't ask the table what a missing stack would have been", () => {
|
||||
// stackOf substitutes the table's starting stack for a seat that has none, which
|
||||
// would make a seat with nothing recorded look like it was holding chips.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.defaultStack = 500;
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].stack = null;
|
||||
|
||||
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
|
||||
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
state.seats[2].inHand = false;
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
Betting.buyInDealsThemIn(state.seats[2]);
|
||||
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("nothing to decide about a seat that isn't there", () => {
|
||||
assert.equal(Betting.buyInDealsThemIn(null), false);
|
||||
assert.equal(Betting.buyInDealsThemIn(undefined), false);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// The chips to actually push, for an amount.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
// A standard house set.
|
||||
const LEGEND = [
|
||||
{ value: 1, color: "#fff" },
|
||||
{ value: 5, color: "#c0392b" },
|
||||
{ value: 25, color: "#27ae60" },
|
||||
{ value: 100, color: "#2c3e50" },
|
||||
{ value: 500, color: "#8e44ad" },
|
||||
];
|
||||
|
||||
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
|
||||
|
||||
test("a payout comes back as chips, largest first", () => {
|
||||
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
|
||||
});
|
||||
|
||||
test("it uses the fewest chips on a normal set", () => {
|
||||
const { chips } = Money.chipBreakdown(500, LEGEND);
|
||||
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
|
||||
});
|
||||
|
||||
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
|
||||
assert.equal(say(7), "1×$5, 2×$1");
|
||||
});
|
||||
|
||||
test("nothing to pay is nothing to say", () => {
|
||||
assert.equal(say(0), "");
|
||||
assert.equal(say(-5), "");
|
||||
});
|
||||
|
||||
test("no legend means no advice, not a wrong answer", () => {
|
||||
// A table that never set chip colours up gets the amount on its own.
|
||||
assert.equal(say(132, []), "");
|
||||
assert.equal(say(132, null), "");
|
||||
});
|
||||
|
||||
test("a legend with no values in it yet is the same as none", () => {
|
||||
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
|
||||
});
|
||||
|
||||
test("what the smallest chip can't make is reported, not rounded away", () => {
|
||||
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
|
||||
// and they can only solve it if they're told.
|
||||
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
|
||||
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
|
||||
assert.equal(remainder, 0.5);
|
||||
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
|
||||
});
|
||||
|
||||
test("cent chips make cent amounts exactly", () => {
|
||||
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
|
||||
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
|
||||
});
|
||||
|
||||
test("the chips add up to the amount", () => {
|
||||
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
|
||||
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
|
||||
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
|
||||
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a legend in any order still pays largest first", () => {
|
||||
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
|
||||
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
|
||||
});
|
||||
|
||||
test("the colour comes along, so the felt can say it in chips", () => {
|
||||
const { chips } = Money.chipBreakdown(25, LEGEND);
|
||||
assert.equal(chips[0].color, "#27ae60");
|
||||
});
|
||||
|
||||
test("a legend that exists but can't reach the amount does say so", () => {
|
||||
// Different from having no legend at all: this table has told us its chips, and
|
||||
// the answer is that they don't go that low.
|
||||
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// What happens when the saved table can't be read.
|
||||
//
|
||||
// An unreadable save can only become an empty table. What it must not do is become
|
||||
// one silently and then get written over by the first edit.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withStored(value, { readOnly = false } = {}) {
|
||||
const store = new Map();
|
||||
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem(key, val) {
|
||||
if (readOnly) throw new Error("QuotaExceededError");
|
||||
store.set(key, val);
|
||||
},
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.localStorage;
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test("nothing stored is a new table, not a failure", () => {
|
||||
withStored(undefined);
|
||||
const state = HoldemState.load();
|
||||
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
});
|
||||
|
||||
test("a good save loads and reports nothing", () => {
|
||||
const store = withStored(undefined);
|
||||
const original = HoldemState.createDefault();
|
||||
original.handNumber = 34;
|
||||
HoldemState.save(original);
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.equal(state.handNumber, 34);
|
||||
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
|
||||
});
|
||||
|
||||
test("a truncated save is reported rather than silently becoming an empty table", () => {
|
||||
const good = JSON.stringify(HoldemState.createDefault());
|
||||
withStored(good.slice(0, Math.floor(good.length * 0.7)));
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
|
||||
});
|
||||
|
||||
test("valid JSON of the wrong shape counts as unreadable too", () => {
|
||||
withStored(JSON.stringify({ hello: "world" }));
|
||||
|
||||
HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
|
||||
test("the unreadable copy is kept instead of being written over", () => {
|
||||
// This is the half that matters: whatever a person could have picked out of the
|
||||
// raw JSON used to be gone the moment the dealer touched a seat.
|
||||
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
|
||||
const store = withStored(broken);
|
||||
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
});
|
||||
|
||||
test("a later save doesn't touch the copy that was set aside", () => {
|
||||
const broken = '{"seats":[{"name":"Ann"';
|
||||
const store = withStored(broken);
|
||||
|
||||
const state = HoldemState.load();
|
||||
state.handNumber = 2;
|
||||
HoldemState.save(state);
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
|
||||
});
|
||||
|
||||
test("the listener fires once, not on every read", () => {
|
||||
withStored("{not json");
|
||||
let told = 0;
|
||||
HoldemState.onLoadFailed = () => { told++; };
|
||||
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("no room to keep the copy is not a crash", () => {
|
||||
// Storage that refuses writes is exactly the storage most likely to hand back a
|
||||
// half-written value in the first place.
|
||||
withStored("{not json", { readOnly: true });
|
||||
|
||||
let state;
|
||||
assert.doesNotThrow(() => { state = HoldemState.load(); });
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Noticing that the same table is open twice.
|
||||
//
|
||||
// Two tabs each hold their own copy in memory and each save writes the whole thing,
|
||||
// so the second one to act throws the first one's work away. This doesn't merge them
|
||||
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
|
||||
// notices, which is what lets them decide.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withWindow() {
|
||||
const listeners = [];
|
||||
globalThis.window = {
|
||||
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
|
||||
removeEventListener(type, fn) {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
listeners,
|
||||
fire(key) { listeners.forEach(fn => fn({ key })); },
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.window;
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test("a write to this table from elsewhere is noticed", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("somebody else's storage key is not our business", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire("some.other.app.state");
|
||||
win.fire(HoldemState.RECOVERY_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
assert.equal(told, 0);
|
||||
});
|
||||
|
||||
test("told once, however many times the other tab acts", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("watching twice doesn't listen twice", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.equal(win.listeners.length, 1);
|
||||
});
|
||||
|
||||
test("no listener set is not an error", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
});
|
||||
|
||||
test("no window at all is not an error", () => {
|
||||
// The same modules are loaded in Node by the test harness.
|
||||
delete globalThis.window;
|
||||
HoldemState.tabWatcher = null;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// What happens when the browser refuses to save.
|
||||
//
|
||||
// The table keeps working — that was always true and is still the right behaviour.
|
||||
// What was missing is anybody being told, which is the same shape of fault the bank
|
||||
// check exists for: silent until it's too late to act on.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, makeTable } = require("./load");
|
||||
|
||||
function withStorage(behaviour) {
|
||||
const calls = [];
|
||||
globalThis.localStorage = {
|
||||
setItem(key, value) {
|
||||
calls.push({ key, value });
|
||||
if (behaviour === "throw") {
|
||||
const error = new Error("QuotaExceededError");
|
||||
error.name = "QuotaExceededError";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getItem: () => null,
|
||||
removeItem() {},
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.saveFailed = false;
|
||||
HoldemState.onSaveFailed = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.localStorage;
|
||||
HoldemState.saveFailed = false;
|
||||
HoldemState.onSaveFailed = null;
|
||||
});
|
||||
|
||||
test("a save that works says so and leaves no mark", () => {
|
||||
withStorage("ok");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
|
||||
assert.equal(HoldemState.save(state), true);
|
||||
assert.equal(HoldemState.saveFailed, false);
|
||||
});
|
||||
|
||||
test("a save that fails says so rather than throwing", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
|
||||
assert.equal(HoldemState.save(state), false);
|
||||
assert.equal(HoldemState.saveFailed, true);
|
||||
});
|
||||
|
||||
test("the table is told once, not on every action", () => {
|
||||
// A dealer taps the bar thirty times a hand and doesn't need thirty warnings.
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
let told = 0;
|
||||
HoldemState.onSaveFailed = () => { told++; };
|
||||
|
||||
for (let i = 0; i < 20; i++) HoldemState.save(state);
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("the failure is handed the error, so the reason can be reported later", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
let seen = null;
|
||||
HoldemState.onSaveFailed = error => { seen = error; };
|
||||
|
||||
HoldemState.save(state);
|
||||
|
||||
assert.equal(seen && seen.name, "QuotaExceededError");
|
||||
});
|
||||
|
||||
test("a failing save still doesn't stop the table", () => {
|
||||
// The chips have already moved in memory; refusing to continue would be worse
|
||||
// than not persisting.
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].stack = 175;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.save(state));
|
||||
assert.equal(state.seats[0].stack, 175);
|
||||
});
|
||||
|
||||
test("no listener is not an error", () => {
|
||||
withStorage("throw");
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
HoldemState.onSaveFailed = null;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.save(state));
|
||||
assert.equal(HoldemState.saveFailed, true);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Which fields replace their contents when a dealer reaches for them.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
// Enough of an input to answer the question, without a DOM.
|
||||
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
|
||||
|
||||
test("a number box selects", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
|
||||
});
|
||||
|
||||
test("a decimal keypad box selects", () => {
|
||||
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
|
||||
// a number pad without the browser's spinner arrows.
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
|
||||
});
|
||||
|
||||
test("a name does not", () => {
|
||||
// Selecting all of a name on focus is the same mistake pointing the other way:
|
||||
// a name is a thing you edit, not a suggestion you replace.
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
|
||||
});
|
||||
|
||||
test("a colour swatch does not", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
|
||||
});
|
||||
|
||||
test("a checkbox does not", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
|
||||
});
|
||||
|
||||
test("a textarea does not, whatever it is asked", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
|
||||
});
|
||||
|
||||
test("a select does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
|
||||
});
|
||||
|
||||
test("case doesn't matter", () => {
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
|
||||
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
|
||||
});
|
||||
|
||||
test("nothing at all is not a field", () => {
|
||||
assert.equal(Money.selectsOnFocus(null), false);
|
||||
assert.equal(Money.selectsOnFocus(undefined), false);
|
||||
});
|
||||
|
||||
test("a field with no type stated is a text box, so it does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// The legend and the key map must not drift apart.
|
||||
//
|
||||
// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
|
||||
// so a dealer who learned the first three from the action bar had no way to find out
|
||||
// the other two existed. The guard against that happening again is this file.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const Shortcuts = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
|
||||
|
||||
test("every shortcut has a line in the legend", () => {
|
||||
const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
|
||||
const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
|
||||
assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
|
||||
});
|
||||
|
||||
test("the legend doesn't describe shortcuts that don't exist", () => {
|
||||
const real = new Set(Object.values(Shortcuts.KEYS));
|
||||
const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
|
||||
assert.deepEqual(invented, []);
|
||||
});
|
||||
|
||||
test("every legend line names its keys and says what they do", () => {
|
||||
Shortcuts.LEGEND.forEach(entry => {
|
||||
assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
|
||||
assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
|
||||
assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the keys named match the keys bound", () => {
|
||||
// "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
|
||||
const spoken = { space: " ", enter: "enter", esc: "escape" };
|
||||
Shortcuts.LEGEND.forEach(entry => {
|
||||
entry.keys.forEach(label => {
|
||||
const key = spoken[label.toLowerCase()] || label.toLowerCase();
|
||||
assert.equal(Shortcuts.KEYS[key], entry.action,
|
||||
`the legend says ${label} is ${entry.action}, the map disagrees`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("no two lines claim the same key", () => {
|
||||
const seen = new Set();
|
||||
Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
|
||||
assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
|
||||
seen.add(key.toLowerCase());
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Two players changing chairs.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
test("the whole seat moves", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].cards = ["AS", "KD"];
|
||||
state.seats[0].stack = 175;
|
||||
state.seats[4].name = "";
|
||||
|
||||
HoldemState.swapSeats(state, 0, 4);
|
||||
|
||||
assert.equal(state.seats[4].name, "Ann");
|
||||
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
|
||||
assert.equal(state.seats[4].stack, 175);
|
||||
assert.equal(state.seats[0].name, "");
|
||||
});
|
||||
|
||||
test("what they have in the pot goes with them", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
const posted = state.seats.map(p => p.committed);
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
HoldemState.swapSeats(state, 1, 4);
|
||||
|
||||
assert.equal(state.seats[4].committed, posted[1]);
|
||||
assert.equal(state.seats[1].committed, posted[4]);
|
||||
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
|
||||
});
|
||||
|
||||
test("the button stays on the chair, not on the player", () => {
|
||||
// A card room moves the button around the table, not around the people. Somebody
|
||||
// who sits down in the big blind's chair is the big blind.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[0].name = "Ann";
|
||||
|
||||
HoldemState.swapSeats(state, 0, 2);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 0);
|
||||
assert.notEqual(state.seats[0].name, "Ann");
|
||||
});
|
||||
|
||||
test("the blinds follow the chairs too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const before = HoldemState.getBlindSeats(state);
|
||||
|
||||
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
|
||||
const after = HoldemState.getBlindSeats(state);
|
||||
|
||||
assert.equal(after.sbIndex, before.sbIndex);
|
||||
assert.equal(after.bbIndex, before.bbIndex);
|
||||
});
|
||||
|
||||
test("swapping a seat with itself does nothing and says so", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("a chair that isn't there is refused rather than swallowed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
|
||||
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("swapping twice puts everything back", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
const before = JSON.stringify(state);
|
||||
|
||||
HoldemState.swapSeats(state, 1, 3);
|
||||
HoldemState.swapSeats(state, 1, 3);
|
||||
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
|
||||
test("moving into an empty chair leaves an empty chair behind", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
state.seats[1].name = "Ben";
|
||||
|
||||
HoldemState.swapSeats(state, 1, 5);
|
||||
|
||||
assert.equal(state.seats[5].name, "Ben");
|
||||
assert.equal(state.seats[5].inHand, true);
|
||||
assert.equal(state.seats[1].inHand, false);
|
||||
});
|
||||
+40
-3
@@ -180,6 +180,12 @@ const LayoutAudit = {
|
||||
collapsedColumns() {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||||
// Nothing inside something the page has hidden. A hidden element's children
|
||||
// all measure zero wide, so the standings header — which is hidden along
|
||||
// with its table until somebody has bought in — was reported as three
|
||||
// collapsed columns on every empty table. A column nobody can see hasn't
|
||||
// collapsed; it isn't there.
|
||||
if (row.closest("[hidden]")) return;
|
||||
if (getComputedStyle(row).display !== "grid") return;
|
||||
[...row.children].forEach(cell => {
|
||||
if (getComputedStyle(cell).display === "none") return;
|
||||
@@ -343,6 +349,16 @@ const LayoutAudit = {
|
||||
});
|
||||
},
|
||||
|
||||
// Every element between this one and the document, so an opacity set on a
|
||||
// container can be seen from the text inside it.
|
||||
*ancestors(el) {
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
yield node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
},
|
||||
|
||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||
const offenders = [];
|
||||
const unmeasured = [];
|
||||
@@ -359,7 +375,21 @@ const LayoutAudit = {
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.visibility === "hidden" || style.display === "none") return;
|
||||
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
||||
// Faded text used to be skipped outright as "faded on purpose", and that
|
||||
// exemption hid a real fault: the folded and sat-out seats dimmed their
|
||||
// whole box with opacity, so a folded player's stack measured 2.22:1 at
|
||||
// 11.5px and this check never looked at it. Something invisible is still
|
||||
// exempt — there's nothing to read at zero — but anything in between is
|
||||
// reported as unmeasured rather than passed over in silence, because a
|
||||
// number nobody checked is not the same as a number that's fine.
|
||||
const opacity = parseFloat(style.opacity);
|
||||
if (opacity <= 0.02) return;
|
||||
const faded = opacity < 0.99 ||
|
||||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
|
||||
if (faded) {
|
||||
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
|
||||
return;
|
||||
}
|
||||
// Decoration is exempt by definition: the watermark on the felt is meant
|
||||
// to be barely there, and holding it to a reading threshold would mean
|
||||
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
||||
@@ -522,8 +552,15 @@ const LayoutAudit = {
|
||||
const chips = [...document.querySelectorAll(".dealerChip")];
|
||||
const shown = chips.filter(chip => {
|
||||
const style = getComputedStyle(chip);
|
||||
return style.display !== "none" && style.visibility !== "hidden" &&
|
||||
parseFloat(style.opacity) > 0.05;
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
if (parseFloat(style.opacity) <= 0.05) return false;
|
||||
// A hovered seat deliberately fades its empty slot in — that's the "put the
|
||||
// button here" preview, and it is on screen because a pointer is sitting on
|
||||
// that seat. Counting it reports two buttons whenever the mouse happens to
|
||||
// rest on the table, which is most of the time a mouse exists.
|
||||
const seat = chip.closest(".seat");
|
||||
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
|
||||
return true;
|
||||
});
|
||||
const offenders = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Whether putting more in is called a bet or a raise.
|
||||
//
|
||||
// The arithmetic was always right — minRaiseTo returns two big blinds for the big
|
||||
// blind's option and one for an opening bet. This is only what it's called, which for
|
||||
// a tool a dealer announces the action from is not a small thing.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, makeTable } = require("./load");
|
||||
|
||||
// Walks the preflop action round to the big blind, who is owed nothing and gets to
|
||||
// choose between checking and putting more in.
|
||||
function toTheBigBlindsOption() {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
|
||||
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
}
|
||||
return { state, bigBlind };
|
||||
}
|
||||
|
||||
test("the big blind's option is a raise, not a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
|
||||
assert.equal(state.actingSeatIndex, bigBlind);
|
||||
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
|
||||
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
|
||||
});
|
||||
|
||||
test("and the minimum there is a real raise, not a call of the blind", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
|
||||
});
|
||||
|
||||
test("facing the blinds preflop is a raise", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("first in on the flop is a bet", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "flop");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
|
||||
});
|
||||
|
||||
test("after that bet it's a raise again", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||
|
||||
assert.equal(Betting.wagerVerb(state), "raise");
|
||||
});
|
||||
|
||||
test("and a bet again on the next street once it's checked around", () => {
|
||||
const { state, bigBlind } = toTheBigBlindsOption();
|
||||
Betting.check(state, bigBlind);
|
||||
Betting.closeStreet(state);
|
||||
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(state.street, "turn");
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("a table playing without blinds opens with a bet", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.currentBet, 0);
|
||||
assert.equal(Betting.wagerVerb(state), "bet");
|
||||
});
|
||||
|
||||
test("asking doesn't change anything", () => {
|
||||
const { state } = toTheBigBlindsOption();
|
||||
const before = JSON.stringify(state);
|
||||
Betting.wagerVerb(state);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
});
|
||||
Reference in New Issue
Block a user