Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9bc9afcdb | ||
|
|
9f3d0dce4b | ||
|
|
01f5139740 | ||
|
|
c76ebf4d18 | ||
|
|
cc060b29b8 | ||
|
|
3149b48c14 | ||
|
|
cb057b5159 | ||
|
|
ab92259e3e | ||
|
|
65f201cdfd | ||
|
|
d297c1a2c9 | ||
|
|
f0cfd24647 | ||
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 | ||
|
|
4071be5329 | ||
|
|
8a7d7b09bf | ||
|
|
ebf22e3153 | ||
|
|
f0994a8aed | ||
|
|
ddb3907ace | ||
|
|
3fe16fa7f2 | ||
|
|
f199148046 | ||
|
|
38536a5c0c | ||
|
|
286a924fe4 | ||
|
|
3e9e2e899d | ||
|
|
96cd7684f0 | ||
|
|
1af82dfddf | ||
|
|
2cc2c66976 | ||
|
|
a92a389245 | ||
|
|
3d1ce1f45c | ||
|
|
29188d6049 | ||
|
|
4c1ac222a8 | ||
|
|
784315f2ea | ||
|
|
0c6cd55951 | ||
|
|
e01d8e7c49 | ||
|
|
c77161c3e9 | ||
|
|
843ef9cd42 | ||
|
|
e5d5b515ba | ||
|
|
a14087fa7d | ||
|
|
76f1952d61 | ||
|
|
f464e6f2e2 | ||
|
|
f5108de288 | ||
|
|
ee37cf7bd9 | ||
|
|
0d1218fa53 | ||
|
|
a72f199139 | ||
|
|
e0eb0d2079 | ||
|
|
6117886e70 |
@@ -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">
|
||||
@@ -127,11 +127,27 @@
|
||||
<span>Chips in play</span>
|
||||
<span id="bankChipsInPlay"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
|
||||
<span>Taken off the table</span>
|
||||
<span id="bankDiscarded"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
<h3 class="panelSubhead">Settle up</h3>
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div class="handHistoryFilterRow">
|
||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
|
||||
</div>
|
||||
<div id="handHistoryList"></div>
|
||||
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||
|
||||
<div class="handHistoryActions">
|
||||
@@ -147,6 +163,20 @@
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
|
||||
|
||||
<div id="colourUp" hidden>
|
||||
<h3 class="panelSubhead">Stacks in chips</h3>
|
||||
<dl id="colourUpList" class="colourUpList"></dl>
|
||||
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -163,7 +193,7 @@
|
||||
<template id="seatTemplate">
|
||||
<div class="seat" draggable="true">
|
||||
<div class="seatChairNumber"></div>
|
||||
<button class="dealerChip" title="Make this seat the dealer button">D</button>
|
||||
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
|
||||
<span class="blindBadge sbBadge">SB</span>
|
||||
<span class="blindBadge bbBadge">BB</span>
|
||||
<div class="seatPuckName"></div>
|
||||
@@ -288,7 +318,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>
|
||||
@@ -298,7 +330,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,
|
||||
@@ -307,7 +339,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.
|
||||
@@ -315,7 +347,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
|
||||
@@ -330,7 +362,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
|
||||
@@ -340,7 +372,50 @@
|
||||
</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
|
||||
pushes out a single oversized chip without saying anything has
|
||||
<em>called</em> — not raised, however big the chip is. To raise with one
|
||||
chip you have to say "raise" before it lands; say it without an amount
|
||||
and the raise is the most that chip allows.
|
||||
</p>
|
||||
<p>
|
||||
Facing no bet, the same silent chip is a bet of the whole chip. And a
|
||||
clear verbal declaration in turn is binding either way — if words and
|
||||
chips disagree, whichever came first is the action.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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
|
||||
hasn't been one. Anything between a call and that minimum isn't a raise
|
||||
a player can make — it's a call, unless they're putting their last chips
|
||||
in. The raise pad says so if you type one, and won't stop you: your
|
||||
ruling beats the arithmetic.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="Who shows first">
|
||||
<h3>Who turns their hand over first</h3>
|
||||
<p>
|
||||
If there was a bet on the river, the last player to bet or raise shows
|
||||
first. If the river was checked all the way round, it's the first live
|
||||
player left of the button. Nobody has to be asked twice — a player may
|
||||
always turn their hand over early if they want to.
|
||||
</p>
|
||||
<p>
|
||||
When everyone is all-in before the river there was no final round to
|
||||
lead, so it falls to whoever last put money in, on whichever street that
|
||||
was. The action bar names the seat and the reason at every showdown, so
|
||||
the call is already made before anyone asks.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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
|
||||
@@ -350,6 +425,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.
|
||||
@@ -416,6 +507,7 @@
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -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;
|
||||
@@ -308,9 +322,22 @@ button,
|
||||
|
||||
#pokerTableWrap {
|
||||
position: relative;
|
||||
/* The shape is stated once, and both the height and the cap below are derived from
|
||||
it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
|
||||
padding resolves against the *containing block's* width, not the element's own,
|
||||
so narrowing the wrap left it exactly as tall as before. Capping the width was
|
||||
therefore no way to shorten the table — measured, after trying it: the felt went
|
||||
822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
|
||||
the action bar as it had. aspect-ratio ties the two together properly. */
|
||||
--feltAspect: 0.72;
|
||||
width: 100%;
|
||||
padding-bottom: 72%;
|
||||
aspect-ratio: 1 / var(--feltAspect);
|
||||
margin: 0 auto;
|
||||
/* Capping the width is how the felt is told to stay above the action bar.
|
||||
--feltRoomPx is published by actionBar.js with the bar's real height and the
|
||||
bottom seat's overhang already taken out; the fallback leaves the table its full
|
||||
size if that ever fails to arrive. */
|
||||
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
|
||||
}
|
||||
|
||||
|
||||
@@ -330,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;
|
||||
@@ -486,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 {
|
||||
@@ -568,8 +632,19 @@ button,
|
||||
|
||||
.dealerChip {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
/* Inside its own seat, not on the rim.
|
||||
|
||||
It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval
|
||||
of overlapping seats means hanging over the seat *next to it*. Measured on a
|
||||
nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That
|
||||
was always slightly true and the enlarged hit area made it much worse; three of
|
||||
nine seats were losing taps, one of them losing its own middle.
|
||||
|
||||
Tucked inside, the overlap is zero at every size. It reads as a puck in the
|
||||
corner of the seat rather than on its edge, which is a small price for tapping
|
||||
a player and getting that player. */
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
@@ -580,7 +655,6 @@ button,
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.15s ease;
|
||||
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
|
||||
But tapping it hands the button to that seat and re-posts the blinds, and 22px
|
||||
@@ -595,22 +669,56 @@ button,
|
||||
.dealerChip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -7px;
|
||||
bottom: -15px;
|
||||
left: -14px;
|
||||
/* Grows inward, over its own seat, not outward onto the neighbours.
|
||||
|
||||
The chip already sits outside its seat by design — top:-10px, right:-10px — so
|
||||
a pad that also reaches up and right lands on whatever seat is next around the
|
||||
oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1
|
||||
losing 10 of 49 sampled points including its own centre, which meant tapping
|
||||
the middle of seat 1 gave the button to seat 9.
|
||||
|
||||
Down and left is its own seat, which is a large target that can spare the
|
||||
corner. The two pixels of outward reach are just to cover the chip's border. */
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
bottom: -18px;
|
||||
left: -18px;
|
||||
}
|
||||
|
||||
.dealerChip:focus-visible {
|
||||
opacity: 1;
|
||||
/* A poker table has one dealer button. This one drew nine.
|
||||
|
||||
Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
|
||||
were there to be pressed. Measured on a nine-seat table: eight pale discs, all
|
||||
eight showing the letter D, one of them real. Finding the button meant comparing
|
||||
nine identical shapes for the one that's gold, when it should be the only thing
|
||||
of its kind on the felt. The ghosts also sat on top of their neighbours: every
|
||||
remaining "something important is covered" offender on a crowded table was a
|
||||
seat covered by the seat-next-door's ghost puck.
|
||||
|
||||
So a seat that doesn't have the button doesn't draw one. The affordance is still
|
||||
there — hovering a seat fades its slot in, which is a preview of where the button
|
||||
would go — and on touch, where there is no hover, "Give the button" has always
|
||||
been in the seat's long-press menu. Keyboard reaches it by focus.
|
||||
|
||||
pointer-events matters as much as the opacity: an invisible puck that still takes
|
||||
taps would be worse than a visible one. */
|
||||
.dealerChip:not(.active) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.seat:hover .dealerChip {
|
||||
opacity: 1;
|
||||
.seat:hover .dealerChip:not(.active) {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dealerChip:not(.active):focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dealerChip.active {
|
||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||
border-color: #a9782f;
|
||||
@@ -622,6 +730,18 @@ button,
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
|
||||
a gold puck on nobody looks exactly like a button that has drifted and wants
|
||||
dragging back. So it keeps the button's gold, to stay findable as the button, but
|
||||
goes hollow and dashed: the same shape, visibly holding a place rather than
|
||||
marking a player. The deal prompt says why in words; this is so the felt agrees
|
||||
with it at a glance instead of contradicting it. */
|
||||
.dealerChip.active.dealerChipDead {
|
||||
background: rgba(245, 213, 71, 0.14);
|
||||
border-style: dashed;
|
||||
color: #f0d98c;
|
||||
}
|
||||
|
||||
.blindBadge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -638,6 +758,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.
|
||||
@@ -925,9 +1062,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;
|
||||
@@ -992,6 +1144,128 @@ 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;
|
||||
}
|
||||
|
||||
/* Each stack and the chips that make it, laid out like the shortcut legend below —
|
||||
the thing being named on one line, the answer under it, so a dealer with a rack in
|
||||
one hand reads down rather than across. */
|
||||
.colourUpList {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.colourUpWho {
|
||||
color: #ddd;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.colourUpChips {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #9fb8a6;
|
||||
font-size: 0.74rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.colourUpChips:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -1382,7 +1656,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;
|
||||
@@ -1598,6 +1881,46 @@ button,
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
/* The settle-up list is read out loud, one line per payment, so it's laid out like
|
||||
the bank rows above it: name on the left, money hard right on tabular figures so a
|
||||
column of amounts lines up on the decimal point. */
|
||||
.settleUpList {
|
||||
list-style: none;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settleUpRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.18rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settleUpRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settleUpWho {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.settleUpAmount {
|
||||
color: #f2d489;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Same amber the bank check uses when the columns don't agree — it's the same fault
|
||||
being reported twice, and it should look like it. */
|
||||
.fieldHint.settleUpOff {
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
.standingsHeader {
|
||||
color: #8a8a8a;
|
||||
font-size: 0.7rem;
|
||||
@@ -1634,11 +1957,62 @@ button,
|
||||
color: #e07b7b;
|
||||
}
|
||||
|
||||
/* The showdown call, sat above the resolve button. Loud enough to be read across a
|
||||
table at a glance — this is something the dealer says out loud — but not so loud it
|
||||
competes with the button underneath it. */
|
||||
.showdownCall {
|
||||
margin: 0 0 0.45rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: #f0d98c;
|
||||
}
|
||||
|
||||
.handHistoryFilterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.handHistoryFilter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* 16px or iOS zooms the whole page on focus, which on a phone at the table throws
|
||||
the felt off screen mid-hand. Every other field in this panel is held at 16px
|
||||
for the same reason. */
|
||||
font-size: 16px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: #141414;
|
||||
border: 1px solid #2c2c2c;
|
||||
border-radius: 6px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.handHistoryFilter::placeholder {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.handHistoryFilter:focus-visible {
|
||||
outline: 2px solid #d8b46a;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.handHistoryCount {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.72rem;
|
||||
color: #8a8a8a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#handHistoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 260px;
|
||||
/* Was a flat 260px, which is about four and a half rows — the same four and a half
|
||||
whether the dealer is on a phone or a 27" screen with room for twenty. The floor
|
||||
keeps it honest on a short window; the ceiling stops the panel from running away
|
||||
on a tall one and pushing everything below it out of reach. */
|
||||
max-height: clamp(200px, 42vh, 560px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -1920,6 +2294,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;
|
||||
@@ -1927,7 +2320,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;
|
||||
@@ -2029,12 +2422,50 @@ body.hasActionBar #holdemSidebar {
|
||||
color: #f0d9a8;
|
||||
}
|
||||
|
||||
/* Whose turn it is, shown on the table itself as well as in the bar. */
|
||||
/* Whose turn it is, shown on the table itself as well as in the bar.
|
||||
|
||||
Dealers say the biggest single time sink at a home table isn't how fast the cards
|
||||
come out — it's players not noticing the action is on them. So this is the one
|
||||
state that has to be unmistakable from across a table at a glance, and it was
|
||||
distinguished only by a rim colour: a gold border and a 2px ring where a live seat
|
||||
has a green border and a 1px one. Same fill, same size, nothing moving.
|
||||
|
||||
It now reads as lit rather than merely outlined: a solid ring at full strength, a
|
||||
warm glow that separates it from the felt, and a lift of its own. The seat's
|
||||
`transform` positions it around the table, so the lift is box-shadow rather than
|
||||
translate — setting transform here would throw the seat off its spot. */
|
||||
.seat.seatActing {
|
||||
border-color: #d8b46a;
|
||||
border-color: #f0cd85;
|
||||
background: linear-gradient(160deg, #3a3222, #241f16);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(216, 180, 106, 0.45),
|
||||
0 4px 16px rgba(0, 0, 0, 0.55);
|
||||
0 0 0 3px rgba(216, 180, 106, 0.9),
|
||||
0 0 18px 2px rgba(216, 180, 106, 0.35),
|
||||
0 6px 20px rgba(0, 0, 0, 0.6);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* A slow breath, so a dealer glancing up finds it without hunting. Deliberately
|
||||
gentle and deliberately optional — the ring above carries the whole job on its own
|
||||
for anyone who'd rather nothing moved. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.seat.seatActing {
|
||||
animation: seatActingPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes seatActingPulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(216, 180, 106, 0.9),
|
||||
0 0 18px 2px rgba(216, 180, 106, 0.35),
|
||||
0 6px 20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(216, 180, 106, 0.65),
|
||||
0 0 26px 5px rgba(216, 180, 106, 0.5),
|
||||
0 6px 20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seatPuckStack {
|
||||
@@ -2044,6 +2475,23 @@ body.hasActionBar #holdemSidebar {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
|
||||
letters and a warm colour separate it from the grey money above it at a glance —
|
||||
which is the whole job, because the dealer is scanning the ring for who can still
|
||||
be bet at. */
|
||||
.seatPuckStack.seatPuckAllIn {
|
||||
color: #ffcf6b;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* And the seat itself carries a hint of it, so the mark survives being read out of
|
||||
the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
|
||||
fact about a player, not a request for the dealer's attention. */
|
||||
.seat.seatAllIn {
|
||||
border-color: rgba(255, 207, 107, 0.45);
|
||||
}
|
||||
|
||||
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||
the seat next to it. These step the size down so every digit stays on its own
|
||||
@@ -2396,6 +2844,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;
|
||||
@@ -2422,6 +2880,16 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* The narrow layout already had an answer for the bar: it reserves the bar's height
|
||||
as scroll room below the table, and a phone is a thing you scroll. Capping the
|
||||
felt here as well was measured and rejected — at 360x640 it took the table from
|
||||
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
|
||||
covered seats traded for two seats whose centre landed on a neighbour. The cap is
|
||||
for the wide layout, which never consulted the bar's height at all. */
|
||||
#pokerTableWrap {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
||||
.potWizardActions {
|
||||
flex-direction: column-reverse;
|
||||
@@ -2437,8 +2905,9 @@ body.hasActionBar #toastHost {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* One column, so the table has the full width and can afford to be round. */
|
||||
#pokerTableWrap {
|
||||
padding-bottom: 100%;
|
||||
--feltAspect: 1;
|
||||
}
|
||||
|
||||
.seat {
|
||||
@@ -2505,10 +2974,6 @@ body.hasActionBar #toastHost {
|
||||
padding: 0.25rem 0.3rem;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
#potBadge {
|
||||
padding: 0.5rem 1.1rem;
|
||||
}
|
||||
@@ -2749,10 +3214,25 @@ body.hasActionBar #toastHost {
|
||||
against the containing block's width, not the element's own, so narrowing the
|
||||
wrap on its own left it full height regardless. */
|
||||
#pokerTableWrap {
|
||||
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
|
||||
width: min(100%, calc(var(--feltRoom) / 0.72));
|
||||
/* A wide ellipse, which is what a real poker table is.
|
||||
|
||||
Capping the width by the height to hold the portrait aspect ratio was
|
||||
throwing away most of the screen: at 640x360 the felt came out 135px wide
|
||||
inside a 601px column — 22% of the width — leaving 366px of rim for nine
|
||||
seats that need 662px. Adjacent seat centres ended up 13px apart, which is
|
||||
why a seat's centre belonged to its neighbour.
|
||||
|
||||
Landscape has width and no height, so the table takes the width it has and
|
||||
is only as tall as the room left over. */
|
||||
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem);
|
||||
width: 100%;
|
||||
height: var(--feltRoom);
|
||||
padding-bottom: 0;
|
||||
min-height: 7rem;
|
||||
/* This branch sets its own height outright, so neither the aspect ratio nor the
|
||||
cap derived from it has anything to say here — and the cap would only narrow
|
||||
a table that is already as short as the screen allows. */
|
||||
aspect-ratio: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||
@@ -2788,6 +3268,7 @@ body.hasActionBar #toastHost {
|
||||
width: 62px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ---------- Short phones, upright ----------
|
||||
@@ -2899,3 +3380,174 @@ textarea:focus-visible {
|
||||
position: absolute;
|
||||
inset: -7px;
|
||||
}
|
||||
|
||||
/* A note under the raise entry when the amount typed isn't a legal raise. It informs
|
||||
and never blocks: this tool records what the humans at the table actually did, and
|
||||
a dealer overruling the arithmetic is right more often than the arithmetic is.
|
||||
Under-minimum raises are the single most common thing that stops a home game, and
|
||||
the bar used to accept one without a word. */
|
||||
.raiseNote {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.raiseNote.raiseNoteWarn {
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
.raiseNote.raiseNoteInfo {
|
||||
color: #9fc7e8;
|
||||
}
|
||||
|
||||
/* On a short phone the raise pad already makes the bar tall enough to cover the
|
||||
lowest seats, and the note must not make that materially worse. Measured at
|
||||
360x640: bar 122px closed, 178px with the pad open, 217px with a note. Tightened
|
||||
here so the note costs a line rather than a paragraph — the bar is a transient
|
||||
input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
|
||||
@media (max-height: 700px) {
|
||||
.raiseNote {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* The pot breakdown is reference material — which side pots exist and who can win
|
||||
them. While an amount is being typed it isn't what the dealer is reading, and
|
||||
it's the tallest thing competing for the strip. */
|
||||
body.hasActionBar #actionBar:has(.raisePad) .potBreakdown {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- The deal step ----------
|
||||
Between hands the bar shows who will post and waits. Dealing used to happen the
|
||||
instant the previous hand ended, which committed blinds while people were still
|
||||
sitting down — and left the blind badges disagreeing with the money already in. */
|
||||
.dealPrompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dealPromptLine {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #c8c8c8;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dealPromptLine.dealPromptBlocked {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.actionBtn.actionDeal {
|
||||
background: linear-gradient(180deg, #16783a, #0d5225);
|
||||
border-color: #1e8449;
|
||||
color: #ecffef;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
|
||||
would otherwise only discover afterwards. */
|
||||
.dealPromptLine.dealPromptAside {
|
||||
font-size: 0.72rem;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
/* An aside that has something to press. It wraps rather than squeezing the button:
|
||||
on a phone the sentence takes the width it needs and the button drops under it,
|
||||
still at full tap size, instead of being crushed to a sliver at the end of a line. */
|
||||
.dealPromptLine.dealPromptDebt {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.dealPromptDebtBtn {
|
||||
/* Sized down from a full action button — it's an option beside a sentence, not
|
||||
the thing the dealer came here to press. "Post blinds & deal" stays the button
|
||||
that looks like the button. */
|
||||
flex: 0 0 auto;
|
||||
min-height: 32px;
|
||||
padding: 0.24rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
background: rgba(245, 213, 71, 0.1);
|
||||
border: 1px solid rgba(245, 213, 71, 0.45);
|
||||
color: #f0d98c;
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (pointer: coarse) {
|
||||
/* A fingertip needs 44px, and this one moves money. */
|
||||
.dealPromptDebtBtn {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ const ActionBar = {
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
|
||||
this.tankUnknownFor = state.bettingOpen &&
|
||||
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
|
||||
? state.actingSeatIndex
|
||||
: null;
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
},
|
||||
@@ -29,6 +34,7 @@ const ActionBar = {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
@@ -40,9 +46,47 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
// the layout this is about to change. Measuring it would mean shrinking the felt,
|
||||
// moving the seats, and getting a different answer on the next pass.
|
||||
SEAT_OVERHANG_PX: 37,
|
||||
FELT_GAP_PX: 8,
|
||||
FELT_MIN_PX: 220,
|
||||
|
||||
// How tall the felt can be and still leave the whole ring of seats above the bar.
|
||||
//
|
||||
// Publishing the bar's height was only half the job. The wide layout never used it:
|
||||
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
|
||||
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
|
||||
// 117px to 172px the moment there's a side pot to show, which is exactly when the
|
||||
// dealer most needs to see who is still in. Measured with two side pots, the bottom
|
||||
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
|
||||
//
|
||||
// The wrap's offset is taken in document space rather than viewport space, so
|
||||
// scrolling the page doesn't resize the table under the dealer's hands.
|
||||
publishFeltRoom(barHeight) {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap) return;
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
const room = window.innerHeight - barHeight - topInDocument -
|
||||
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
// still holds during setup, because a seated player is a live seat whether or not
|
||||
// the betting has been opened. An empty table stays uncluttered.
|
||||
//
|
||||
// A *named* table is not an empty one, though, and that was the hole: name six
|
||||
// people without dealing any of them in and the bar hid itself, leaving a screen
|
||||
// with six players on it, no way forward and nothing saying why. Anyone with a
|
||||
// name on the felt is somebody the dealer has to be told about.
|
||||
isRunning() {
|
||||
return Betting.liveSeats(this.state).length > 0;
|
||||
return Betting.liveSeats(this.state).length > 0 ||
|
||||
this.state.seats.some(p => (p.name || "").trim());
|
||||
},
|
||||
|
||||
render() {
|
||||
@@ -66,7 +110,9 @@ const ActionBar = {
|
||||
const breakdown = this.renderPotBreakdown();
|
||||
if (breakdown) host.appendChild(breakdown);
|
||||
|
||||
if (Betting.isHandOver(this.state)) {
|
||||
if (!this.state.bettingOpen) {
|
||||
host.appendChild(this.renderDealPrompt());
|
||||
} else if (Betting.isHandOver(this.state)) {
|
||||
host.appendChild(this.renderHandOver());
|
||||
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
|
||||
host.appendChild(this.renderStreetDone());
|
||||
@@ -94,8 +140,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"}`;
|
||||
@@ -137,6 +187,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);
|
||||
}
|
||||
@@ -157,6 +218,14 @@ const ActionBar = {
|
||||
tankStartedAt: null,
|
||||
tankInterval: null,
|
||||
|
||||
// The seat that was already being asked to act when this page loaded, if there was
|
||||
// one. Its clock is unknowable: the action moved to them before this session
|
||||
// started — maybe two minutes ago, maybe last night — and starting from zero would
|
||||
// have the bar say they had just begun thinking. Which it did, every time a dealer
|
||||
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
|
||||
// clock is measuring something it actually saw.
|
||||
tankUnknownFor: null,
|
||||
|
||||
// Restarts only when the action actually moves — a re-render for any other reason
|
||||
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||
syncTankTimer() {
|
||||
@@ -170,6 +239,8 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
if (this.tankSeatIndex !== index) {
|
||||
// Once the action has moved under our own eyes, nothing is unknown any more.
|
||||
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
||||
this.tankSeatIndex = index;
|
||||
this.tankStartedAt = Date.now();
|
||||
}
|
||||
@@ -193,13 +264,33 @@ const ActionBar = {
|
||||
const el = document.getElementById("actionTank");
|
||||
if (!el || this.tankStartedAt === null) return;
|
||||
|
||||
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
|
||||
el.textContent = "—";
|
||||
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
|
||||
el.classList.remove("tankNudge", "tankSlow");
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
el.title = "How long this player has been thinking";
|
||||
el.textContent = this.formatTank(seconds);
|
||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||
},
|
||||
|
||||
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
|
||||
// open on a hand does reach that, and sixty-five minutes written as though it were
|
||||
// a minutes-and-seconds reading is a number the eye gets wrong.
|
||||
formatTank(seconds) {
|
||||
const pad = value => String(value).padStart(2, "0");
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(rest)}`
|
||||
: `${minutes}:${pad(rest)}`;
|
||||
},
|
||||
|
||||
// Shown only once someone is all-in and the money has actually split — a single
|
||||
// undivided pot needs no announcement, and a line that's always there stops being
|
||||
// read.
|
||||
@@ -242,6 +333,8 @@ const ActionBar = {
|
||||
actionAdvance: "↵",
|
||||
raiseConfirm: "↵",
|
||||
raiseCancel: "esc",
|
||||
raiseAllIn: "A",
|
||||
actionUndo: "U",
|
||||
},
|
||||
|
||||
button(label, className, onClick, sublabel) {
|
||||
@@ -254,7 +347,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";
|
||||
@@ -306,7 +402,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();
|
||||
@@ -319,12 +418,27 @@ const ActionBar = {
|
||||
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
||||
// button, and only reaches for digits when the table does something odd.
|
||||
renderRaisePad() {
|
||||
// A toast is transient news about the action just taken; the raise pad is the
|
||||
// dealer deciding the next one. On a short screen they compete for the same
|
||||
// strip: the pad makes the bar 217px tall on a 640px phone, and the toast —
|
||||
// which floats just above the bar — gets pushed up over the table, covering
|
||||
// seats outright. Rather than squeeze both in, the older one gets out of the
|
||||
// way. Nothing is lost: the toast's Undo is also the bar's own undo control.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
const index = this.state.actingSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const maxTo = Betting.round(Betting.committed(player) + stack);
|
||||
const pot = Betting.potSoFar(this.state);
|
||||
const minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
|
||||
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
||||
// what this player can actually put in, which is what the Min button should
|
||||
// offer. `legalMin` is what the rules require — for a short stack the two are
|
||||
// different, and it's the rules one the warnings have to be measured against,
|
||||
// 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";
|
||||
@@ -340,7 +454,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));
|
||||
@@ -357,9 +471,10 @@ 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);
|
||||
});
|
||||
entry.appendChild(input);
|
||||
|
||||
@@ -368,24 +483,285 @@ 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);
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "raiseNote";
|
||||
note.hidden = true;
|
||||
wrap.appendChild(note);
|
||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
// Says something when the amount typed isn't a legal raise. It never blocks —
|
||||
// a dealer overruling the arithmetic is right more often than the arithmetic is,
|
||||
// and the whole tool is built on tapping what the humans actually did.
|
||||
//
|
||||
// But an under-minimum raise from a player with chips behind them is the single
|
||||
// most common thing that stops a home game dead, and until now the bar took it
|
||||
// without a word: type 13 when the minimum is 18 and it was simply accepted. The
|
||||
// engine then treats it as a short raise and correctly doesn't reopen the betting,
|
||||
// which is right for an all-in and wrong for somebody who just misheard the bet.
|
||||
updateRaiseNote(wrap, minTo, maxTo) {
|
||||
const note = wrap.querySelector(".raiseNote");
|
||||
if (!note) return;
|
||||
|
||||
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; }
|
||||
|
||||
// Going all-in for less than a full raise is perfectly legal, and worth saying
|
||||
// out loud because of what follows from it.
|
||||
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
||||
|
||||
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
||||
// 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 = 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;
|
||||
}
|
||||
|
||||
if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; }
|
||||
|
||||
if (typed > maxTo + Betting.EPSILON_CHIPS) {
|
||||
note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`;
|
||||
note.className = "raiseNote raiseNoteInfo";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed <= current) {
|
||||
note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`;
|
||||
note.className = "raiseNote raiseNoteWarn";
|
||||
note.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
note.hidden = true;
|
||||
},
|
||||
|
||||
// The table is set but the cards aren't out. This is the step that used to happen
|
||||
// by itself the instant the previous hand ended, which meant blinds were committed
|
||||
// while people were still sitting down — and the blind badges would drift away from
|
||||
// the money already posted as seats changed. Now the dealer says when.
|
||||
renderDealPrompt() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dealPrompt";
|
||||
|
||||
const preview = Betting.blindPreview(this.state);
|
||||
const seated = Betting.liveSeats(this.state).length;
|
||||
const 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 — 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);
|
||||
|
||||
// Named seats that aren't in the hand. Naming a chair now seats whoever is
|
||||
// in it, so reaching this takes something else — a table where everyone was
|
||||
// sat out, or a save from before that was true — but the way out of it
|
||||
// should still be one tap rather than a hunt through six seat editors.
|
||||
const benched = this.state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
||||
.map(({ index }) => index);
|
||||
|
||||
if (benched.length > 0) {
|
||||
const aside = document.createElement("p");
|
||||
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = benched.length === 1
|
||||
? `${listNames(benched)} is sitting out.`
|
||||
: `${listNames(benched)} are sitting out.`;
|
||||
aside.appendChild(text);
|
||||
aside.appendChild(this.button(
|
||||
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit("dealing everybody in", () => {
|
||||
benched.forEach(index => {
|
||||
this.state.seats[index].inHand = true;
|
||||
this.state.seats[index].folded = false;
|
||||
});
|
||||
})
|
||||
));
|
||||
wrap.appendChild(aside);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
|
||||
// the small blind" out loud is the whole point — a dealer who reads a preview
|
||||
// with a blind missing and isn't told why will assume the tool has lost track.
|
||||
line.textContent = preview.deadSmallBlind
|
||||
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
|
||||
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
wrap.appendChild(line);
|
||||
|
||||
// Why a position is missing. Both of these are the dead button rule doing its
|
||||
// job, and both look like a bug until somebody says otherwise.
|
||||
if (preview.deadButton || preview.deadSmallBlind) {
|
||||
const dead = document.createElement("p");
|
||||
dead.className = "dealPromptLine dealPromptAside";
|
||||
dead.textContent = preview.deadButton
|
||||
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
|
||||
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
|
||||
wrap.appendChild(dead);
|
||||
}
|
||||
|
||||
// Dealing relocates a button sitting on an empty chair. That has to be said
|
||||
// before it happens rather than discovered afterwards — a fresh table starts
|
||||
// the button on seat 1 whether or not anyone is sitting there.
|
||||
if (preview.buttonMoves) {
|
||||
const moved = document.createElement("p");
|
||||
moved.className = "dealPromptLine dealPromptAside";
|
||||
moved.textContent =
|
||||
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
|
||||
wrap.appendChild(moved);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Somebody who has just sat down between the button and the big blind has had
|
||||
// the blinds go past them, and dealing them in there is a free hand in late
|
||||
// position. At a table the answer is that they post the big blind to come in
|
||||
// now, or wait for it to reach them. The tool used to do neither and say
|
||||
// nothing: it dealt them in for free, and the dealer had no way to know it had
|
||||
// happened. Both answers are offered here; neither is forced, because a home
|
||||
// game that doesn't care shouldn't have to argue with its own scoreboard.
|
||||
// One line for however many have walked up, not one each. The bar is docked
|
||||
// over the felt and the felt has already shrunk as far as it will go on a
|
||||
// phone, so every line here is taken out of the ring of seats: measured at
|
||||
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
|
||||
// bottom seat. They post together or they wait together, which is what happens
|
||||
// at a table anyway when two people sit down at once.
|
||||
if (preview.debtors.length > 0) {
|
||||
const ids = preview.debtors.map(i => this.state.seats[i].id);
|
||||
const posting = ids.every(id => preview.posting.includes(id));
|
||||
const who = listNames(preview.debtors);
|
||||
const each = preview.debtors.length > 1 ? " each" : "";
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = posting
|
||||
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
|
||||
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
|
||||
line.appendChild(text);
|
||||
|
||||
line.appendChild(this.button(
|
||||
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit(
|
||||
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
|
||||
() => { this.state.postingIn = posting ? [] : ids; })
|
||||
));
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// "Dee shows first — last to raise." The call the dealer has to make at every
|
||||
// showdown and that nobody at the table volunteers for, said out loud with the
|
||||
// reason attached so it can be repeated to whoever argues with it.
|
||||
showdownCall() {
|
||||
const call = Betting.showdownOrder(this.state);
|
||||
if (!call) return null;
|
||||
|
||||
const who = HoldemState.displayName(this.state.seats[call.index], call.index);
|
||||
const because = {
|
||||
aggressor: "last to bet or raise",
|
||||
// Semicolon, not a second dash: the sentence already spends its dash on
|
||||
// "shows first —", and two of them in one line reads as a stutter.
|
||||
allInAggressor: "last to put money in; nobody could bet the river",
|
||||
leftOfButton: "checked round, first left of the button",
|
||||
}[call.reason];
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "showdownCall";
|
||||
line.textContent = `${who} shows first — ${because}.`;
|
||||
return line;
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
const isRiver = this.state.street === "river";
|
||||
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
||||
|
||||
if (isRiver) {
|
||||
const call = this.showdownCall();
|
||||
if (call) wrap.appendChild(call);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
@@ -393,22 +769,33 @@ const ActionBar = {
|
||||
}
|
||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
return row;
|
||||
return wrap;
|
||||
},
|
||||
|
||||
renderHandOver() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
const wrap = document.createElement("div");
|
||||
const live = Betting.liveSeats(this.state);
|
||||
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
||||
|
||||
// Two or more still in with the betting finished is a showdown however it was
|
||||
// arrived at — including everyone all-in on the flop, where the cards go on
|
||||
// their backs and the order still decides who turns over first.
|
||||
if (!winner) {
|
||||
const call = this.showdownCall();
|
||||
if (call) wrap.appendChild(call);
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
));
|
||||
return row;
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,111 @@ const Betting = {
|
||||
.filter(s => s.player.inHand && !s.player.folded);
|
||||
},
|
||||
|
||||
// Who would post if the hand were dealt right now. Used to preview the blinds
|
||||
// while the table is still being set, so a dealer can see where they'll land
|
||||
// before any money moves.
|
||||
// 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. 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, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
|
||||
if (bbIndex === null) return null;
|
||||
return {
|
||||
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
|
||||
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
|
||||
// Asked of the table as it will be dealt, for the same reason the blinds
|
||||
// are: someone sitting out for having no chips isn't owed a blind, and the
|
||||
// big blind of the hand about to go out isn't either.
|
||||
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
|
||||
posting: (state.postingIn || []).slice(),
|
||||
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);
|
||||
},
|
||||
@@ -92,6 +197,20 @@ const Betting = {
|
||||
return null;
|
||||
},
|
||||
|
||||
// A player still in the hand with chips in the pot and nothing left behind. This is
|
||||
// the same stack test nextToAct uses to pass over them, which is the point: the
|
||||
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
|
||||
// act on again.
|
||||
//
|
||||
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
|
||||
// anything in is a busted player who shouldn't have been dealt in, not a player
|
||||
// who moved all-in, and calling them all-in on the felt would be a lie.
|
||||
isAllIn(state, player) {
|
||||
if (!player || !player.inHand || player.folded) return false;
|
||||
return this.handTotal(player) > this.EPSILON_CHIPS &&
|
||||
this.stackOf(state, player) <= this.EPSILON_CHIPS;
|
||||
},
|
||||
|
||||
// Which chair sits `step` places along in the direction of play. The table owns
|
||||
// this — walking the seats is exactly where the button, the blinds and the action
|
||||
// used to drift apart from each other, so there is one implementation of it.
|
||||
@@ -157,6 +276,11 @@ const Betting = {
|
||||
|
||||
if (newBet > previousBet) {
|
||||
const increase = this.round(newBet - previousBet);
|
||||
// Aggression, whether or not it was a full raise — a short all-in doesn't
|
||||
// reopen the betting, but the player who made it is still the last one who
|
||||
// put money in, and at the showdown they are still the one who shows first.
|
||||
state.streetAggressorIndex = index;
|
||||
state.handAggressorIndex = index;
|
||||
// What a full raise would have had to be: at least as much again as the
|
||||
// last one, or the big blind if there hasn't been a raise yet.
|
||||
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||
@@ -218,6 +342,10 @@ const Betting = {
|
||||
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// Whoever led this street stops being the answer the moment the next one opens.
|
||||
// The hand's aggressor is deliberately left alone: it's the fallback for a
|
||||
// river nobody could bet on.
|
||||
state.streetAggressorIndex = null;
|
||||
|
||||
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||
@@ -264,6 +392,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);
|
||||
},
|
||||
|
||||
@@ -291,10 +424,35 @@ const Betting = {
|
||||
|
||||
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
||||
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||
//
|
||||
// Called when the dealer says the table is ready, not automatically when the
|
||||
// previous hand ends — everything before this point is setup, where seats and the
|
||||
// button can move freely because no money has been committed.
|
||||
beginHand(state) {
|
||||
state.bettingOpen = true;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// Posting a blind isn't aggression — a hand limped and checked all the way
|
||||
// down has no aggressor at all, which is exactly the case the showdown rule
|
||||
// sends to the seat left of the button.
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
|
||||
// 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
|
||||
@@ -310,16 +468,46 @@ const Betting = {
|
||||
const bb = Number(state.bigBlind) || 0;
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
|
||||
if (sbIndex === null || bbIndex === null) {
|
||||
// A null big blind means there aren't enough players to post at all. A null
|
||||
// small blind with a big blind behind it is the dead small blind, which is a
|
||||
// hand that gets dealt like any other — it just has one blind in it.
|
||||
if (bbIndex === null) {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.postedBlinds = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sb > 0) this.putIn(state, sbIndex, sb);
|
||||
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
|
||||
if (bb > 0) this.putIn(state, bbIndex, bb);
|
||||
|
||||
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
||||
// Anyone the dealer said would post to come in past the blinds. It's a big
|
||||
// blind like any other — it buys them a hand they'd otherwise have to wait for
|
||||
// — so it goes in the middle the same way, before the count below reads it.
|
||||
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
|
||||
if (bb > 0) {
|
||||
state.seats.forEach((player, index) => {
|
||||
if (index === sbIndex || index === bbIndex) return;
|
||||
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
|
||||
});
|
||||
}
|
||||
// Deliberately not cleared here. repostBlinds takes every posted chip back and
|
||||
// deals again through this same function, so a post that cleared itself on the
|
||||
// way in would be refunded and never retaken — the dealer moves the button and
|
||||
// somebody's post quietly vanishes. startNextHand is what ends it.
|
||||
|
||||
// What the next hand's rotation is worked out from. Recorded here, where the
|
||||
// blinds are actually posted, because by the time it's needed the seats have
|
||||
// moved on and the answer can no longer be read off the table.
|
||||
state.postedBlinds = { sbIndex, bbIndex };
|
||||
|
||||
// Who got cards. Read next hand to tell a player who has been here all along
|
||||
// from one who has just walked up and had the blinds go past them.
|
||||
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
|
||||
|
||||
state.currentBet = this.round(Math.max(
|
||||
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
|
||||
this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
|
||||
// Heads-up the button is the small blind and acts first before the flop; with
|
||||
@@ -330,6 +518,45 @@ const Betting = {
|
||||
: this.nextToAct(state, bbIndex);
|
||||
},
|
||||
|
||||
// Who turns their hand over first.
|
||||
//
|
||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
|
||||
// the table volunteers for — everyone would rather see a hand than show one. The
|
||||
// tool knew who last raised and who sits left of the button and said neither, so
|
||||
// the dealer was working it out from memory while five people waited.
|
||||
//
|
||||
// Three cases, in the order the rule asks them:
|
||||
// * somebody bet or raised on the final round — they show first;
|
||||
// * the round was checked through — first live player left of the button;
|
||||
// * nobody could bet, because the chips were already in — the last player who
|
||||
// did put money in, on whichever round that was.
|
||||
//
|
||||
// Returns null when there's nothing to call: one player left, or none.
|
||||
showdownOrder(state) {
|
||||
const live = this.liveSeats(state);
|
||||
if (live.length < 2) return null;
|
||||
|
||||
const seatOf = index =>
|
||||
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
|
||||
|
||||
const streetAggressor = seatOf(state.streetAggressorIndex);
|
||||
if (streetAggressor !== null) {
|
||||
return { index: streetAggressor, reason: "aggressor" };
|
||||
}
|
||||
|
||||
// Nobody could have bet this round, so "no bet on the final round" isn't a
|
||||
// choice anybody made and the seat left of the button hasn't earned the
|
||||
// obligation. It falls to whoever last put money in.
|
||||
if (!this.canStillBet(state)) {
|
||||
const handAggressor = seatOf(state.handAggressorIndex);
|
||||
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
|
||||
}
|
||||
|
||||
const order = HoldemState.liveOrderFromButton(state)
|
||||
.filter(index => live.some(s => s.index === index));
|
||||
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
|
||||
},
|
||||
|
||||
// Winners get their share put back in front of them.
|
||||
creditWinnings(state, payouts) {
|
||||
payouts.forEach(payout => {
|
||||
|
||||
@@ -19,7 +19,7 @@ const HandHistoryExport = {
|
||||
// text, so the one thing anybody opens this file to do — total a column and see
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = []) {
|
||||
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
[...handHistory].reverse().forEach(entry => {
|
||||
@@ -41,7 +41,12 @@ const HandHistoryExport = {
|
||||
rows.push(["Session Standings"]);
|
||||
// Bought in and chips now are what a cash-out is settled from; paid in and
|
||||
// won describe the hands. Both belong in the file the dealer hands over.
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
|
||||
//
|
||||
// Notes go last so the money columns keep the shape a spreadsheet expects.
|
||||
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
|
||||
// somebody settles up from, and it was only ever visible inside that seat's
|
||||
// own editor.
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
@@ -51,8 +56,41 @@ const HandHistoryExport = {
|
||||
Money.forSheet(row.boughtIn || 0),
|
||||
Money.forSheet(row.chipsNow || 0),
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
row.notes || "",
|
||||
]);
|
||||
});
|
||||
|
||||
// And the answer the standings are read to get to. Whoever opens this file
|
||||
// wants the payment list, not to redo the arithmetic in a spreadsheet.
|
||||
const plan = SettleUp.plan(standings);
|
||||
if (plan.payments.length > 0) {
|
||||
rows.push([]);
|
||||
rows.push(["Settle Up"]);
|
||||
rows.push(["Pays", "Receives", "Amount"]);
|
||||
plan.payments.forEach(payment => {
|
||||
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
|
||||
});
|
||||
if (!plan.balanced) {
|
||||
// The file is the paper trail, so the caveat travels with it rather
|
||||
// than living only in a sidebar nobody exported.
|
||||
rows.push([
|
||||
plan.shortBy > 0
|
||||
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
|
||||
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The table's own notes — side bets, IOUs, the promo running until midnight.
|
||||
// The dealer typed them because they matter to the night, and the export is the
|
||||
// thing that outlives the browser tab; leaving them behind meant the one place
|
||||
// the context was written down was the one place it couldn't be handed over.
|
||||
// csvField quotes the line breaks, so a multi-line note survives as one cell.
|
||||
if (notes && notes.trim()) {
|
||||
rows.push([]);
|
||||
rows.push(["Table Notes"]);
|
||||
rows.push([notes.trim()]);
|
||||
}
|
||||
|
||||
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||
@@ -65,8 +103,8 @@ const HandHistoryExport = {
|
||||
return `holdem-hand-history-${stamp}.csv`;
|
||||
},
|
||||
|
||||
download(handHistory, standings = []) {
|
||||
const csv = this.toCsv(handHistory, standings);
|
||||
download(handHistory, standings = [], options = {}) {
|
||||
const csv = this.toCsv(handHistory, standings, options);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
@@ -96,13 +96,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
},
|
||||
|
||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
|
||||
state.dealerSeatIndex = index;
|
||||
// Before anyone has acted the blinds are still just sitting there, so
|
||||
// moving the button moves them too — a home game passes it round several
|
||||
// times before the cards come out, and the posted chips have to follow.
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
// 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
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
// the dealer gets told rather than finding out later.
|
||||
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||
|
||||
seatAction(index, willRepost
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
: `Button on ${nameOf(index)}.`, () => {
|
||||
state.dealerSeatIndex = index;
|
||||
// Putting the button somewhere by hand is the dealer overruling the
|
||||
// rotation, dead positions included. Leaving them set would hold a
|
||||
// small blind dead at a table whose button has moved out from under it.
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
assignBlind: (index, which) => {
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
@@ -132,11 +158,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;
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -159,8 +201,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// hand's stacks are already right.
|
||||
Betting.creditWinnings(state, payouts);
|
||||
}
|
||||
// Settling a pot ends the hand; it doesn't start the next one. The
|
||||
// table goes back to setup so people can sit down or cash out before
|
||||
// any blinds are committed.
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -222,7 +266,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return true;
|
||||
},
|
||||
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
|
||||
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||
// streets and showdown without reaching for the trackpad.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
||||
undo: () => pressInBar(".actionUndo"),
|
||||
cancel: () => pressInBar(".raiseCancel"),
|
||||
});
|
||||
@@ -335,19 +381,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
// Say where the button landed so the dealer can confirm the rotation without
|
||||
// hunting for the badge. Asked of the rotation itself rather than guessed at as
|
||||
// "the next chair along" — which was only ever right at a table with nobody
|
||||
// missing, and is the exact thing the dead button makes untrue.
|
||||
const rotation = HoldemState.nextButtonRotation(state);
|
||||
const nextDealer = rotation.buttonIndex !== null
|
||||
? rotation.buttonIndex
|
||||
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
|
||||
const where = rotation.deadButton
|
||||
? `dead button on seat ${nextDealer + 1}`
|
||||
: state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => {
|
||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||
// Written down as well as announced. The toast is a moment; the bank check
|
||||
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||
// and reported them as missing.
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
|
||||
}
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,6 +425,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);
|
||||
|
||||
[
|
||||
@@ -395,7 +476,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();
|
||||
@@ -427,13 +591,42 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!ok) return;
|
||||
|
||||
HoldemState.clearHistory(state);
|
||||
Render.historyFilter = "";
|
||||
document.getElementById("handHistoryFilter").value = "";
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
// The filter is how the dealer looks at the log, not something about the table, so
|
||||
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||
document.getElementById("handHistoryFilter").addEventListener("input", e => {
|
||||
Render.historyFilter = e.target.value;
|
||||
Render.renderHandHistory();
|
||||
});
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
// Always the whole night. A filter is for reading the panel; an export that
|
||||
// quietly dropped the hands scrolled past would be a record with holes in it,
|
||||
// and nothing on the CSV would say so.
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
|
||||
{ notes: state.notes });
|
||||
if (Render.historyFilter) {
|
||||
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
|
||||
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
|
||||
{ duration: 3500 });
|
||||
}
|
||||
});
|
||||
|
||||
// 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() {
|
||||
@@ -163,6 +173,12 @@ const Render = {
|
||||
layer.innerHTML = "";
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
// A button on an empty chair normally means the table has drifted and wants
|
||||
// fixing. Once it's the dead button it means the opposite — it's exactly where
|
||||
// it belongs — so it gets marked rather than looking like the thing to correct.
|
||||
const deadButtonHere = this.state.deadButton &&
|
||||
HoldemState.deadPositionsApply(this.state) &&
|
||||
!this.state.seats[dealerIndex].inHand;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -171,11 +187,34 @@ 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");
|
||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Every one of these announced itself as "D", so a screen reader heard a row
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
// The label now says whose it is, or what pressing it would do.
|
||||
const who = (player.name || "").trim() || `seat ${index + 1}`;
|
||||
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
|
||||
const chipLabel = hasButton
|
||||
? (deadButtonHere
|
||||
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
|
||||
: `${who} has the dealer button`)
|
||||
: `Give the dealer button to ${who}`;
|
||||
dealerChip.setAttribute("aria-label", chipLabel);
|
||||
dealerChip.title = chipLabel;
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
@@ -269,11 +308,18 @@ const Render = {
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
|
||||
// A player with nothing behind used to leave this line blank, so the felt drew
|
||||
// an all-in exactly like a seat whose stack nobody had entered. On a table with
|
||||
// side pots that's the one distinction the dealer is working from — who can
|
||||
// still be bet at, and who is already committed for everything they have.
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||
const allIn = Betting.isAllIn(this.state, player);
|
||||
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
|
||||
stackEl.classList.toggle("seatPuckAllIn", allIn);
|
||||
this.fitAmount(stackEl);
|
||||
|
||||
node.classList.toggle("seatAllIn", allIn);
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||
@@ -478,13 +524,41 @@ const Render = {
|
||||
this.updatePotBadge();
|
||||
this.renderSeatTotal(form, player);
|
||||
this.refreshPuck(index);
|
||||
// The editor deliberately refreshes narrowly rather than redrawing everything —
|
||||
// a full render on every keystroke would take the name field out from under the
|
||||
// dealer mid-word. But the action bar answers "is there a table here at all",
|
||||
// and seating somebody is exactly what changes that answer. Without this it
|
||||
// stayed hidden until something else happened to redraw: two players seated, a
|
||||
// dealable hand, and a blank bar until the page was reloaded.
|
||||
//
|
||||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
||||
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
const nameInput = form.querySelector(".seatNameInput");
|
||||
nameInput.value = player.name;
|
||||
nameInput.addEventListener("input", () => {
|
||||
// Putting a name on an empty chair is somebody sitting down, so they get
|
||||
// dealt in. It used to be two separate acts, and the second one was easy to
|
||||
// miss: a dealer setting up typed six names, got six seats that still
|
||||
// weren't in the hand, and the action bar hid itself because by its reckoning
|
||||
// nobody was playing. Six people at the table and a blank screen.
|
||||
//
|
||||
// Only for a chair that was empty — no name and not dealt in. Renaming
|
||||
// somebody who is sitting out is a correction, not an arrival, and dealing
|
||||
// them back in over a typo would be the tool overruling the dealer.
|
||||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||||
player.name = nameInput.value;
|
||||
if (wasEmpty && player.name.trim()) {
|
||||
player.inHand = true;
|
||||
player.folded = false;
|
||||
const inHandBox = form.querySelector(".inHandCheckbox");
|
||||
if (inHandBox) inHandBox.checked = true;
|
||||
const foldBox = form.querySelector(".foldCheckbox");
|
||||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
||||
}
|
||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
@@ -630,6 +704,49 @@ const Render = {
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
this.renderColourUp();
|
||||
},
|
||||
|
||||
// Each stack as the chips that make it.
|
||||
//
|
||||
// The legend has only ever read chips *into* an amount — counting what's in front
|
||||
// of somebody. Colouring up at the end of a level is the same question backwards,
|
||||
// and it's the one the dealer has to answer with a rack in one hand: $205 is four
|
||||
// fifties and a five. So is counting a stack out for a player who's leaving.
|
||||
//
|
||||
// Only when there's a legend to work from and a stack to break down; a table that
|
||||
// never set its chip colours up gets nothing, which is what it had before.
|
||||
renderColourUp() {
|
||||
const host = document.getElementById("colourUp");
|
||||
const list = document.getElementById("colourUpList");
|
||||
if (!host || !list) return;
|
||||
|
||||
const legend = this.state.chipLegend || [];
|
||||
const rows = this.state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
|
||||
.map(({ player, index }) => ({
|
||||
name: HoldemState.displayName(player, index),
|
||||
stack: Betting.round(Number(player.stack) || 0),
|
||||
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
|
||||
}))
|
||||
.filter(row => row.chips);
|
||||
|
||||
host.hidden = rows.length === 0;
|
||||
if (host.hidden) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
rows.forEach(row => {
|
||||
const term = document.createElement("dt");
|
||||
term.className = "colourUpWho";
|
||||
term.textContent = `${row.name} — ${Money.format(row.stack)}`;
|
||||
const detail = document.createElement("dd");
|
||||
detail.className = "colourUpChips";
|
||||
detail.textContent = row.chips;
|
||||
list.appendChild(term);
|
||||
list.appendChild(detail);
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Pot breakdown ----
|
||||
@@ -713,6 +830,98 @@ 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
|
||||
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
|
||||
// and it's the part of the night people actually get wrong.
|
||||
renderSettleUp(standings) {
|
||||
const host = document.getElementById("settleUp");
|
||||
if (!host) return;
|
||||
|
||||
const plan = SettleUp.plan(standings);
|
||||
const note = document.getElementById("settleUpNote");
|
||||
const list = document.getElementById("settleUpList");
|
||||
|
||||
// Nothing to show before anyone is up or down. A panel that says "no payments"
|
||||
// to a table that hasn't played a hand is just noise in the sidebar.
|
||||
host.hidden = plan.payments.length === 0 && plan.balanced;
|
||||
if (host.hidden) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
plan.payments.forEach(payment => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "settleUpRow";
|
||||
|
||||
const who = document.createElement("span");
|
||||
who.className = "settleUpWho";
|
||||
// An arrow rather than "pays": the direction is the whole content of the
|
||||
// line, and it survives being read at a glance from across the table.
|
||||
who.textContent = `${payment.from} → ${payment.to}`;
|
||||
row.appendChild(who);
|
||||
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "settleUpAmount";
|
||||
amount.textContent = Money.format(payment.amount);
|
||||
row.appendChild(amount);
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
if (plan.balanced) {
|
||||
const count = plan.payments.length;
|
||||
note.textContent = count === 1
|
||||
? "One payment and the night is square."
|
||||
: `${count} payments and the night is square.`;
|
||||
} else if (plan.shortBy > 0) {
|
||||
note.textContent =
|
||||
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
||||
} else {
|
||||
// If a pot was thrown away on purpose, that's where the gap went, and the
|
||||
// dealer already answered a dialog about it. Telling them to go and fix it
|
||||
// would be sending them after their own decision.
|
||||
const discarded = Math.max(0, Number(this.state.discarded) || 0);
|
||||
note.textContent = discarded > 0
|
||||
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
|
||||
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||
}
|
||||
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||
},
|
||||
|
||||
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||
@@ -729,9 +938,22 @@ const Render = {
|
||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
||||
|
||||
// An abandoned pot the dealer confirmed throwing away is money that left the
|
||||
// table on purpose. It gets its own line so the two columns still add up in
|
||||
// front of them, rather than looking like a shortfall they have to explain.
|
||||
const discardedRow = document.getElementById("bankDiscardedRow");
|
||||
if (discardedRow) {
|
||||
discardedRow.hidden = !(bank.discarded > 0);
|
||||
if (bank.discarded > 0) {
|
||||
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
||||
verdict.textContent = bank.discarded > 0
|
||||
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
||||
: "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
@@ -773,9 +995,50 @@ const Render = {
|
||||
newest ? newest.handNumber : "",
|
||||
newest ? newest.timestamp : "",
|
||||
newest ? newest.pot : "",
|
||||
// Typing in the filter changes the list without changing the history, and
|
||||
// the redraw is skipped when the signature matches — leave this out and the
|
||||
// box takes input and does nothing.
|
||||
this.historyFilter || "",
|
||||
].join("|");
|
||||
},
|
||||
|
||||
// What the dealer opens the log for is one hand: the one somebody is arguing
|
||||
// about. A night runs to sixty or more, five at a time in the panel, so finding it
|
||||
// meant scrolling and reading every row. A name, an amount, or a hand number
|
||||
// narrows it instead.
|
||||
//
|
||||
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
|
||||
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
|
||||
// remembering an amount shouldn't have to know which of those they mean; "#12"
|
||||
// pins it to the hand number, for when they do.
|
||||
handMatchesFilter(entry, query) {
|
||||
if (!query) return true;
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
|
||||
const handNumber = String(entry.handNumber || "");
|
||||
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
|
||||
|
||||
// Only what the row itself shows. Matching the hand's full ledger as well was
|
||||
// the first attempt, on the reasoning that a dealer might hunt for the hand
|
||||
// that broke somebody by the loser's name — but the ledger holds everyone who
|
||||
// put a chip in, so at a table where nobody folds it matched every hand.
|
||||
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
|
||||
// panel doesn't display is a filter that looks broken even when it's right.
|
||||
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
|
||||
if (labels.includes(term)) return true;
|
||||
|
||||
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
|
||||
const digits = term.replace(/[^0-9.]/g, "");
|
||||
if (digits) {
|
||||
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
|
||||
.map(a => String(Money.round(Number(a) || 0)));
|
||||
if (amounts.some(a => a.startsWith(digits))) return true;
|
||||
if (handNumber === digits) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||
@@ -794,10 +1057,29 @@ const Render = {
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
const query = this.historyFilter || "";
|
||||
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
|
||||
|
||||
// How much of the night is being hidden. Without it a filter that matches one
|
||||
// hand looks exactly like a table that only played one, which is the sort of
|
||||
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
|
||||
const count = document.getElementById("handHistoryCount");
|
||||
if (count) {
|
||||
count.hidden = !query || history.length === 0;
|
||||
count.textContent = `${shown.length} of ${history.length}`;
|
||||
}
|
||||
const noMatch = document.getElementById("handHistoryNoMatch");
|
||||
if (noMatch) {
|
||||
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
|
||||
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
|
||||
}
|
||||
const filterBox = document.getElementById("handHistoryFilter");
|
||||
if (filterBox) filterBox.hidden = history.length === 0;
|
||||
|
||||
// Built off-document so the panel is laid out once instead of once per row.
|
||||
const fragment = document.createDocumentFragment();
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
shown.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
||||
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Who hands cash to whom at the end of the night.
|
||||
//
|
||||
// The standings panel already works out what everyone is up or down. It then stopped,
|
||||
// which leaves the dealer with six signed numbers at 2am and the actual job still to
|
||||
// do: turning them into payments. That's the step people get wrong — not the poker.
|
||||
//
|
||||
// Everything is done in cents. A settlement that has to come out exactly right is the
|
||||
// last place to let a float land on 41.99999999.
|
||||
const SettleUp = {
|
||||
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
|
||||
// and for this problem that's the right trade: finding the true minimum number of
|
||||
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
|
||||
// than there are people, and each payment it does emit clears somebody out of the
|
||||
// list entirely. A dealer wants a short list they can read down, not a proof.
|
||||
//
|
||||
// Payers are the outer loop so that everything one person owes is printed together
|
||||
// — the list is read out loud, one player at a time.
|
||||
plan(standings) {
|
||||
const owed = [];
|
||||
const owing = [];
|
||||
|
||||
(standings || []).forEach(row => {
|
||||
const cents = Money.toCents(row.cashPosition);
|
||||
if (cents > 0) owed.push({ name: row.name, cents });
|
||||
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
|
||||
});
|
||||
|
||||
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
|
||||
|
||||
const payments = [];
|
||||
let creditor = 0;
|
||||
owing.forEach(payer => {
|
||||
while (payer.cents > 0 && creditor < owed.length) {
|
||||
const taker = owed[creditor];
|
||||
const amount = Math.min(payer.cents, taker.cents);
|
||||
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
|
||||
payer.cents -= amount;
|
||||
taker.cents -= amount;
|
||||
if (taker.cents === 0) creditor++;
|
||||
}
|
||||
});
|
||||
|
||||
// What's left over when the chips on the table don't match the cash that came
|
||||
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
|
||||
// is always the thing the bank check is already warning about: a rebuy nobody
|
||||
// wrote down, or a miscount. Reported rather than quietly spread across the
|
||||
// payments, because inventing a transfer to make the columns agree is how a
|
||||
// real person ends up short.
|
||||
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
|
||||
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
|
||||
|
||||
return {
|
||||
payments,
|
||||
// Only one of these can be non-zero: either there are more chips than cash
|
||||
// or less, never both.
|
||||
shortBy: unpaid,
|
||||
overBy: unspent,
|
||||
balanced: unpaid === 0 && unspent === 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = SettleUp;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -40,6 +57,22 @@ const Shortcuts = {
|
||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||
},
|
||||
|
||||
// Enter and Space are how a keyboard presses the control it is already sitting on.
|
||||
// Claiming them for "advance" didn't just block that press — it ran a different
|
||||
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
|
||||
// the next hand. Space on the same button worked, because "advance" happened to
|
||||
// decline that time and the browser's own activation got through. A control that
|
||||
// does something else every other press is worse than one that does nothing.
|
||||
//
|
||||
// Only these two keys are handed back. The letter shortcuts stay live with a
|
||||
// button focused, because a button does nothing with "f" and the dealer's hand
|
||||
// shouldn't have to leave them.
|
||||
focusedControlOwnsKey(key) {
|
||||
if (key !== "enter" && key !== " ") return false;
|
||||
const el = document.activeElement;
|
||||
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
@@ -47,6 +80,8 @@ const Shortcuts = {
|
||||
const action = this.KEYS[key];
|
||||
if (!action) return;
|
||||
|
||||
if (this.focusedControlOwnsKey(key)) return;
|
||||
|
||||
// Escape is the exception: it should still close the raise pad even if focus
|
||||
// happens to be sitting in its amount field.
|
||||
if (this.isTyping() && !(action === "cancel")) return;
|
||||
|
||||
@@ -51,6 +51,9 @@ const HoldemState = {
|
||||
notes: "",
|
||||
handNumber: 1,
|
||||
handHistory: [],
|
||||
// Chips taken off the table on purpose, so the bank check can tell them
|
||||
// apart from chips that have gone missing.
|
||||
discarded: 0,
|
||||
smallBlind: 1,
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
@@ -58,32 +61,152 @@ const HoldemState = {
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
// Whether the betting has actually been opened for this hand.
|
||||
//
|
||||
// Dealing and betting used to be one button: "Next Hand" moved the button
|
||||
// and posted the blinds in the same breath. That meant the moment between
|
||||
// hands — the one where people are still sitting down, standing up and
|
||||
// arguing about whose turn it is — was spent with money already committed,
|
||||
// and the blind badges recomputing off `inHand` while the posted chips
|
||||
// stayed frozen from when the hand began. Seat somebody after pressing it
|
||||
// and the table would show a big blind who had posted nothing.
|
||||
//
|
||||
// So the dealer says when. False means the table is being set up: nothing
|
||||
// is committed, the badges are a preview, and seating or moving the button
|
||||
// costs nothing.
|
||||
bettingOpen: false,
|
||||
// Set when the dealer names the blinds by hand instead of letting them
|
||||
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||
// ("you had it last time", someone arriving late owing a blind), and the
|
||||
// button order is the wrong answer often enough that it has to be
|
||||
// overridable. Cleared at the start of every hand.
|
||||
blindOverride: null,
|
||||
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
|
||||
// The next hand's rotation is worked out from this rather than from the
|
||||
// seating as it stands, because by then the people it needs to know about
|
||||
// are exactly the ones who have got up and gone.
|
||||
postedBlinds: null,
|
||||
// Who last bet or raised, on this street and anywhere in the hand. Kept
|
||||
// apart because the showdown rule needs both: normally the aggressor of the
|
||||
// final betting round shows first, but a round nobody could bet in — chips
|
||||
// already all-in — falls back to the last one who could.
|
||||
streetAggressorIndex: null,
|
||||
handAggressorIndex: null,
|
||||
// Player ids that were dealt in the last hand actually dealt. What makes a
|
||||
// seat recognisable as having *just* joined — new to the table, or back
|
||||
// from sitting out — which is the whole question behind owing a blind.
|
||||
dealtLastHand: [],
|
||||
// Ids the dealer has said will buy in past the blinds this hand, by posting
|
||||
// the big blind rather than waiting for it. Cleared every hand: it's a
|
||||
// decision about one deal, not a standing arrangement.
|
||||
postingIn: [],
|
||||
// Set when somebody leaves out of the blinds and the rotation has to hold
|
||||
// to the seats to keep the big blind moving one player at a time. The
|
||||
// button then sits on the chair they vacated, and no small blind is posted.
|
||||
deadButton: false,
|
||||
deadSmallBlind: false,
|
||||
};
|
||||
},
|
||||
|
||||
// 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 = [];
|
||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||
|
||||
// Tables saved before live betting existed are missing its fields; fill
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||
"actingSeatIndex", "blindOverride"]
|
||||
"actingSeatIndex", "blindOverride", "postedBlinds",
|
||||
"streetAggressorIndex", "handAggressorIndex"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
|
||||
// A table saved before the dead button existed has neither flag. False is
|
||||
// the right guess for both: it means the rotation carries on the ordinary
|
||||
// way, which is what that table was already doing.
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
// honest answer — it reads as "this table hasn't played yet", so the first
|
||||
// hand after reopening treats nobody as having just walked up, rather than
|
||||
// asking the whole table to post.
|
||||
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
||||
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
// guessing false would show "deal the hand" over a hand already in
|
||||
// progress. Anything already committed, or anyone already on the clock,
|
||||
// means the betting was open when they closed the tab.
|
||||
if (parsed.bettingOpen === undefined) {
|
||||
parsed.bettingOpen =
|
||||
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
|
||||
parsed.seats.some(p =>
|
||||
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
|
||||
}
|
||||
parsed.seats.forEach(player => {
|
||||
if (player.stack === undefined) player.stack = null;
|
||||
if (player.committed === undefined) player.committed = 0;
|
||||
@@ -93,16 +216,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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -220,6 +364,10 @@ const HoldemState = {
|
||||
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
||||
// supposed to come round to each player at the same rate, and they don't if the
|
||||
// button is rotating past chairs nobody is sitting in.
|
||||
//
|
||||
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
|
||||
// rotates by; this is for the cases it has nothing to work from — a fresh table,
|
||||
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
|
||||
advanceButton(state) {
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
@@ -248,15 +396,29 @@ const HoldemState = {
|
||||
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||
// whether or not anybody is sitting there, and a table that empties out around it
|
||||
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||
ensureButtonOnPlayer(state) {
|
||||
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
|
||||
// Where the button will be when the hand is dealt — without moving it.
|
||||
//
|
||||
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
|
||||
// to ask the question without answering it. It couldn't, and so the preview lied:
|
||||
// with the button parked on an empty chair it promised one pair of blinds and
|
||||
// dealing produced another, because beginHand relocates the button first and then
|
||||
// works out who posts.
|
||||
buttonSeatForDeal(state) {
|
||||
const seated = state.seats[state.dealerSeatIndex];
|
||||
if (seated && seated.inHand) return state.dealerSeatIndex;
|
||||
// A button left on an empty chair on purpose is the dead button, and moving it
|
||||
// to a player is precisely what it's there to stop. Only a button that has been
|
||||
// stranded by accident gets rescued.
|
||||
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
|
||||
for (let step = 0; step < state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) {
|
||||
state.dealerSeatIndex = index;
|
||||
return;
|
||||
}
|
||||
if (state.seats[index].inHand) return index;
|
||||
}
|
||||
return state.dealerSeatIndex;
|
||||
},
|
||||
|
||||
ensureButtonOnPlayer(state) {
|
||||
state.dealerSeatIndex = this.buttonSeatForDeal(state);
|
||||
},
|
||||
|
||||
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||
@@ -270,6 +432,13 @@ const HoldemState = {
|
||||
return o;
|
||||
},
|
||||
|
||||
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
|
||||
// players the button *is* the small blind and the blinds simply alternate, so
|
||||
// whatever the last rotation left behind stops applying.
|
||||
deadPositionsApply(state) {
|
||||
return state.seats.filter(p => p.inHand).length >= 3;
|
||||
},
|
||||
|
||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||
// small blind, which is the opposite of the usual order.
|
||||
getBlindSeats(state) {
|
||||
@@ -301,15 +470,101 @@ const HoldemState = {
|
||||
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
||||
}
|
||||
|
||||
// The small blind is dead: whoever would have moved into it has left the table,
|
||||
// and the seat stays empty for a hand rather than dragging the big blind
|
||||
// forward an extra place. One player gets a free hand out of position for it;
|
||||
// that's the price of nobody being charged two big blinds in an orbit.
|
||||
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
|
||||
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
|
||||
}
|
||||
|
||||
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
||||
},
|
||||
|
||||
// Seats about to be dealt a hand they haven't paid for.
|
||||
//
|
||||
// Somebody who walks up and sits down between the button and the big blind has had
|
||||
// the blinds go past them. Dealing them in there is a free hand in late position —
|
||||
// which is exactly what posting is for, and it comes out of everybody else's night
|
||||
// a couple of dollars at a time. At a real table they either post the big blind to
|
||||
// come straight in, or they wait for it to reach them.
|
||||
//
|
||||
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
|
||||
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
|
||||
// and nobody has been skipped.
|
||||
blindDebtors(state, bbIndex) {
|
||||
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
|
||||
if (!played) return [];
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player, index }) =>
|
||||
player.inHand &&
|
||||
index !== bbIndex &&
|
||||
!state.dealtLastHand.includes(player.id))
|
||||
.map(({ index }) => index);
|
||||
},
|
||||
|
||||
// Where the button goes for the next hand, and whether anything is left dead.
|
||||
//
|
||||
// Ordinarily it follows the small blind round — the next player along, which is
|
||||
// what it has always done. The case that needs care is somebody getting up out of
|
||||
// the blinds. Passing the button to the next *player* then pulls the whole blind
|
||||
// order forward a place: the player after the empty chair is never asked for a big
|
||||
// blind at all, and the one after them is asked an orbit early. Holding the
|
||||
// rotation to the seats instead is the dead button, and getting it wrong costs a
|
||||
// real player a real big blind.
|
||||
//
|
||||
// Worked out from who actually posted last hand, not from the seating as it stands,
|
||||
// because by now the people it needs to know about are the ones who have gone.
|
||||
nextButtonRotation(state) {
|
||||
const posted = state.postedBlinds;
|
||||
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
|
||||
const here = i => inRange(i) && state.seats[i].inHand;
|
||||
|
||||
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
|
||||
// the blinds were named by hand. And heads-up has its own rule — with two
|
||||
// players the button is the small blind, so there is no position to leave dead.
|
||||
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
|
||||
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
|
||||
}
|
||||
|
||||
// Both still sitting there is the ordinary case, and this comes out as the next
|
||||
// player along — the same answer as passing the button, arrived at from the
|
||||
// blinds so that the unusual case doesn't need a separate path.
|
||||
return {
|
||||
buttonIndex: posted.sbIndex,
|
||||
deadButton: !here(posted.sbIndex),
|
||||
deadSmallBlind: !here(posted.bbIndex),
|
||||
};
|
||||
},
|
||||
|
||||
startNextHand(state) {
|
||||
this.advanceButton(state);
|
||||
const rotation = this.nextButtonRotation(state);
|
||||
if (rotation.buttonIndex === null) {
|
||||
this.advanceButton(state);
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
} else {
|
||||
state.dealerSeatIndex = rotation.buttonIndex;
|
||||
state.deadButton = rotation.deadButton;
|
||||
state.deadSmallBlind = rotation.deadSmallBlind;
|
||||
}
|
||||
// Spent. Pressing Next Hand again without dealing is a dealer passing the
|
||||
// button along, not a second rotation off the same hand.
|
||||
state.postedBlinds = null;
|
||||
// Cards aren't out and nothing is posted until the dealer says so.
|
||||
state.bettingOpen = false;
|
||||
state.actingSeatIndex = null;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||
// the next one would freeze the blinds on two players for the rest of the
|
||||
// night, which is the opposite of what naming them once is for.
|
||||
state.blindOverride = null;
|
||||
// Posting to come in is a decision about one deal. Carrying it forward would
|
||||
// charge somebody an extra big blind every hand for the rest of the night.
|
||||
state.postingIn = [];
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -413,6 +668,14 @@ const HoldemState = {
|
||||
// window is almost always.
|
||||
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
||||
|
||||
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
|
||||
// "Venmo: @ann". It's carried here so the export can hand it over with
|
||||
// the money it belongs to; the standings panel itself doesn't show it.
|
||||
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
|
||||
.filter(field => (field.label || "").trim() || (field.value || "").trim())
|
||||
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
...t,
|
||||
in: Money.round(t.in),
|
||||
@@ -421,11 +684,27 @@ const HoldemState = {
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
})
|
||||
.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
|
||||
@@ -447,10 +726,19 @@ const HoldemState = {
|
||||
0
|
||||
);
|
||||
|
||||
const difference = Money.round(chipsInPlay - cashIn);
|
||||
// Chips the dealer deliberately took off the table — an abandoned pot they
|
||||
// were asked about and confirmed. They are gone, so they can't be counted as
|
||||
// in play, but they aren't missing either: the box knows exactly where they
|
||||
// went. Without this the check blamed the tool's own confirmed action on
|
||||
// "someone has paid for chips they haven't been given", which sends a dealer
|
||||
// hunting for a miscount that never happened.
|
||||
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Who the felt marks "ALL IN".
|
||||
//
|
||||
// The mark's whole value is that a dealer can scan the ring and see who can still be
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
|
||||
assert.equal(state.seats[3].stack, 0);
|
||||
});
|
||||
|
||||
test("a player with chips behind is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("a busted player who was dealt in but has put nothing in is not", () => {
|
||||
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
|
||||
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 0;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("a folded player with an empty stack is not", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[1].committed = 5;
|
||||
state.seats[1].stack = 0;
|
||||
state.seats[1].folded = true;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
|
||||
});
|
||||
|
||||
test("an empty chair is not", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
|
||||
});
|
||||
|
||||
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
|
||||
});
|
||||
|
||||
test("the seats marked are exactly the ones the action passes over", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[1].stack = 8;
|
||||
state.seats[3].stack = 30;
|
||||
Betting.beginHand(state);
|
||||
|
||||
// Everyone in, two of them for less than the others can cover.
|
||||
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
|
||||
|
||||
const marked = state.seats
|
||||
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
|
||||
.filter(index => index !== null);
|
||||
const skipped = state.seats
|
||||
.map((player, index) =>
|
||||
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
|
||||
.filter(index => index !== null);
|
||||
|
||||
assert.deepEqual(marked, skipped);
|
||||
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
|
||||
});
|
||||
|
||||
test("nobody is marked once the next hand starts", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[2].stack = 5;
|
||||
Betting.beginHand(state);
|
||||
Betting.allIn(state, state.actingSeatIndex);
|
||||
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
|
||||
|
||||
// The short stack got there by winning nothing; give them chips back the way a
|
||||
// dealer would before the next deal.
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[2].stack = 50;
|
||||
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
|
||||
});
|
||||
|
||||
test("marking nobody doesn't move a chip", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
Betting.beginHand(state);
|
||||
const before = chipsOnTable(state);
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
@@ -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,268 @@
|
||||
// The button when somebody gets up out of the blinds.
|
||||
//
|
||||
// Passing the button to the next *player* is right until a chair between the button
|
||||
// and the blinds empties. Then it pulls the whole blind order forward a place: the
|
||||
// player after the empty chair is never asked for a big blind, and the one after them
|
||||
// is asked an orbit early. The dead button rule holds the rotation to the seats for
|
||||
// one hand so the big blind still advances exactly one player.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const nameOf = (state, index) =>
|
||||
index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
|
||||
|
||||
const blinds = state => {
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
|
||||
};
|
||||
|
||||
const leave = (state, index) => {
|
||||
state.seats[index].name = "";
|
||||
state.seats[index].inHand = false;
|
||||
state.seats[index].stack = 0;
|
||||
state.seats[index].buyIns = 0;
|
||||
};
|
||||
|
||||
describe("somebody gets up out of the blinds", () => {
|
||||
test("the small blind leaves: dead button, and the big blind still moves one player", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
|
||||
|
||||
leave(state, 2); // Cal takes their chips and goes
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
|
||||
assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
|
||||
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
|
||||
"Dee moves from big to small and Ann posts the big blind, one player along");
|
||||
});
|
||||
|
||||
test("and Ann is not skipped, which is the money in it", () => {
|
||||
// The old rotation passed the button to the next player, which made the blinds
|
||||
// Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
|
||||
// early. That is a real big blind out of a real player's stack.
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
|
||||
});
|
||||
|
||||
test("the big blind leaves: no small blind is posted this hand", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
|
||||
leave(state, 3); // Dee goes
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
|
||||
assert.equal(sbIndex, null, "the small blind is dead");
|
||||
assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
|
||||
assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
|
||||
});
|
||||
|
||||
test("a dead small blind still deals a hand, with one blind in the middle", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.bettingOpen, true, "the hand goes ahead");
|
||||
const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
|
||||
assert.equal(state.currentBet, state.bigBlind);
|
||||
assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
|
||||
});
|
||||
|
||||
test("somebody leaving from anywhere else changes nothing", () => {
|
||||
const state = table(1); // button Ben, small Cal, big Dee
|
||||
Betting.beginHand(state);
|
||||
|
||||
leave(state, 0); // Ann, who was under the gun
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
|
||||
assert.equal(state.deadButton, false);
|
||||
assert.equal(state.deadSmallBlind, false);
|
||||
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ordinary rotation is untouched", () => {
|
||||
test("a full table passes the button one player at a time", () => {
|
||||
const state = table(0);
|
||||
const seen = [];
|
||||
for (let hand = 0; hand < 8; hand++) {
|
||||
Betting.beginHand(state);
|
||||
seen.push(blinds(state).bb);
|
||||
HoldemState.startNextHand(state);
|
||||
}
|
||||
assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
|
||||
"two clean orbits, everybody paying one big blind per orbit");
|
||||
assert.equal(state.deadButton, false);
|
||||
assert.equal(state.deadSmallBlind, false);
|
||||
});
|
||||
|
||||
test("players spread thinly around a big table don't create dead positions", () => {
|
||||
// Three players in nine chairs. The chairs between them were never in the
|
||||
// rotation, so holding to seats there would post blinds to nobody hand after
|
||||
// hand — which is why the button follows players, not chairs, by default.
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
|
||||
["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
|
||||
|
||||
const seen = [];
|
||||
for (let hand = 0; hand < 6; hand++) {
|
||||
Betting.beginHand(state);
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
|
||||
assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
|
||||
seen.push(nameOf(state, bbIndex));
|
||||
HoldemState.startNextHand(state);
|
||||
}
|
||||
assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
|
||||
});
|
||||
|
||||
test("pressing next hand twice without dealing just passes the button along", () => {
|
||||
const state = table(0);
|
||||
Betting.beginHand(state);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.dealerSeatIndex, 1);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rule knows where it stops", () => {
|
||||
test("down to two players, heads-up takes over and nothing is left dead", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
leave(state, 3); // both blinds walk at once
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
|
||||
});
|
||||
|
||||
test("a button stranded by accident is still rescued", () => {
|
||||
// Not the dead button: nobody left out of the blinds, the chair the button is
|
||||
// on just emptied. That one does want putting back on a player.
|
||||
const state = table(3);
|
||||
state.seats[3].inHand = false;
|
||||
HoldemState.ensureButtonOnPlayer(state);
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("moving the button by hand clears the dead positions", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 2);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.deadButton, true);
|
||||
|
||||
// What the seat menu's "Give the button" does.
|
||||
state.dealerSeatIndex = 0;
|
||||
state.deadButton = false;
|
||||
state.deadSmallBlind = false;
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
|
||||
});
|
||||
|
||||
test("naming a blind by hand overrules a dead one", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.deadSmallBlind, true);
|
||||
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 0 };
|
||||
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(manual, true);
|
||||
assert.equal(sbIndex, 1, "the dealer's word beats the rule");
|
||||
assert.equal(bbIndex, 0);
|
||||
});
|
||||
|
||||
test("a table saved before any of this reads as nothing dead", () => {
|
||||
const state = table(0);
|
||||
const parsed = JSON.parse(JSON.stringify(state));
|
||||
delete parsed.deadButton;
|
||||
delete parsed.deadSmallBlind;
|
||||
delete parsed.postedBlinds;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.equal(loaded.deadButton, false);
|
||||
assert.equal(loaded.deadSmallBlind, false);
|
||||
assert.equal(loaded.postedBlinds, null);
|
||||
|
||||
HoldemState.startNextHand(loaded);
|
||||
assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
});
|
||||
|
||||
describe("chips are not invented or lost by any of it", () => {
|
||||
test("posting one blind instead of two moves chips and doesn't make them", () => {
|
||||
// Measured across the deal, which is the step that touches stacks. A hand with
|
||||
// a dead small blind has one blind in the middle instead of two — the danger is
|
||||
// a null small blind being treated as a zero-cost posting somewhere, or the big
|
||||
// blind quietly covering for it.
|
||||
const state = table(1);
|
||||
const chips = s => s.seats.reduce(
|
||||
(sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
|
||||
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3); // the big blind goes, small blind dies
|
||||
|
||||
for (let hand = 0; hand < 4; hand++) {
|
||||
HoldemState.startNextHand(state);
|
||||
const before = chips(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a dead small blind takes exactly one blind out of one stack", () => {
|
||||
const state = table(1);
|
||||
Betting.beginHand(state);
|
||||
leave(state, 3);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
|
||||
const before = stacks();
|
||||
Betting.beginHand(state);
|
||||
const after = stacks();
|
||||
|
||||
const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
|
||||
assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// Dealing and betting are two decisions, and fusing them meant the table guessed.
|
||||
//
|
||||
// The moment between hands is when people sit down, stand up and argue about whose
|
||||
// turn it is. With blinds posted the instant the previous hand ended, the blind
|
||||
// badges recomputed off `inHand` while the committed chips stayed frozen — so seating
|
||||
// somebody produced a big blind who had posted nothing.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const postedBy = state => state.seats
|
||||
.map((p, i) => (Betting.committed(p) > 0 ? i : null))
|
||||
.filter(i => i !== null);
|
||||
|
||||
describe("the table waits to be dealt", () => {
|
||||
test("finishing a hand leaves the next one un-dealt", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.bettingOpen, true);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "the next hand hasn't been dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is on the clock");
|
||||
assert.deepEqual(postedBy(state), [], "and no blind has been committed");
|
||||
});
|
||||
|
||||
test("no money moves until the dealer deals", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
assert.equal(HoldemState.totalPot(state), 0);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.bettingOpen, true);
|
||||
assert.equal(postedBy(state).length, 2, "now the blinds are in");
|
||||
assert.equal(chipsOnTable(state), start, "and nothing was created doing it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setting the table costs nothing", () => {
|
||||
test("seating a player before the deal changes who posts", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const before = Betting.blindPreview(state);
|
||||
state.seats[3].inHand = true;
|
||||
state.seats[3].stack = 100;
|
||||
const after = Betting.blindPreview(state);
|
||||
|
||||
assert.notDeepEqual(
|
||||
[before.sbIndex, before.bbIndex],
|
||||
[after.sbIndex, after.bbIndex],
|
||||
"the preview follows the new table"
|
||||
);
|
||||
assert.deepEqual(postedBy(state), [], "and still nothing is committed");
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(postedBy(state).sort(), [after.sbIndex, after.bbIndex].sort(),
|
||||
"the money lands where the preview said it would");
|
||||
});
|
||||
|
||||
test("removing a player before the deal can't strand their blind", () => {
|
||||
// The old failure: take out the big blind after the hand had begun and their
|
||||
// chips stayed in the pot while the badge moved to somebody who'd posted
|
||||
// nothing. Before the deal there is nothing to strand.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const doomed = Betting.blindPreview(state).bbIndex;
|
||||
state.seats[doomed].inHand = false;
|
||||
|
||||
assert.deepEqual(postedBy(state), [], "nobody has posted, so nothing is stranded");
|
||||
|
||||
Betting.beginHand(state);
|
||||
const posted = postedBy(state);
|
||||
assert.ok(!posted.includes(doomed), "the player who left posts nothing");
|
||||
posted.forEach(i => assert.ok(state.seats[i].inHand, "only players in the hand post"));
|
||||
});
|
||||
|
||||
test("moving the button before the deal just moves it", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 2, 4, 6], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
[2, 4, 6, 0].forEach(seat => {
|
||||
state.dealerSeatIndex = seat;
|
||||
assert.deepEqual(postedBy(state), [], `passing the button to seat ${seat + 1} posted something`);
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
});
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(chipsOnTable(state), start, "dealing moves chips between stacks, not into thin air");
|
||||
});
|
||||
|
||||
test("what the preview promises is what gets posted", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [1, 3, 6, 8], dealerSeatIndex: 1 });
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||
});
|
||||
|
||||
test("there's nothing to preview without two players", () => {
|
||||
const alone = makeTable({ seatCount: 6, seated: [2] });
|
||||
HoldemState.startNextHand(alone);
|
||||
assert.equal(Betting.blindPreview(alone), null);
|
||||
|
||||
const empty = makeTable({ seatCount: 6, seated: [] });
|
||||
HoldemState.startNextHand(empty);
|
||||
assert.equal(Betting.blindPreview(empty), null);
|
||||
});
|
||||
|
||||
test("a hand-picked blind shows up in the preview", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
HoldemState.startNextHand(state);
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.manual, true);
|
||||
assert.equal(preview.sbIndex, 3);
|
||||
assert.equal(preview.bbIndex, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table saved mid-hand comes back mid-hand", () => {
|
||||
const loadWith = saved => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = { getItem: () => JSON.stringify(saved), setItem: () => {}, removeItem: () => {} };
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
return state;
|
||||
};
|
||||
|
||||
test("chips already committed mean the betting was open", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", name: "Ann", inHand: true, committed: 2 }, { id: "b", name: "Ben", inHand: true, committed: 0 }],
|
||||
handNumber: 3,
|
||||
});
|
||||
assert.equal(state.bettingOpen, true, "otherwise it would offer to deal over a live hand");
|
||||
});
|
||||
|
||||
test("somebody on the clock means the betting was open", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||
actingSeatIndex: 1, handNumber: 2,
|
||||
});
|
||||
assert.equal(state.bettingOpen, true);
|
||||
});
|
||||
|
||||
test("a quiet table comes back waiting to be dealt", () => {
|
||||
const state = loadWith({
|
||||
seats: [{ id: "a", inHand: true }, { id: "b", inHand: true }],
|
||||
actingSeatIndex: null, handNumber: 5,
|
||||
});
|
||||
assert.equal(state.bettingOpen, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the preview tells the truth about the button", () => {
|
||||
test("a button parked on an empty chair is answered for where it will be", () => {
|
||||
// The preview used to read the button where it sat, while dealing relocated it
|
||||
// first — so it promised one pair of blinds and produced another.
|
||||
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
assert.equal(state.seats[0].inHand, false, "the button is on an empty chair");
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.buttonMoves, true, "and the preview knows it will move");
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind);
|
||||
assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind);
|
||||
assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said");
|
||||
});
|
||||
|
||||
test("asking where the button will be doesn't move it", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
|
||||
Betting.blindPreview(state);
|
||||
Betting.blindPreview(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action");
|
||||
});
|
||||
|
||||
test("a button already on a player is left alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.bettingOpen = false;
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.equal(preview.buttonMoves, false);
|
||||
assert.equal(preview.buttonIndex, 0);
|
||||
});
|
||||
|
||||
test("buttonSeatForDeal copes with a table nobody is sitting at", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [] });
|
||||
assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Chips the dealer took off the table on purpose.
|
||||
//
|
||||
// Starting the next hand with money still in the middle destroys it — the tool asks
|
||||
// first and says so afterwards. What it didn't do was remember, so the bank check saw
|
||||
// only that chips were gone and reported them as missing.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a table with nothing thrown away balances as before", () => {
|
||||
const bank = HoldemState.bankReconciliation(table());
|
||||
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.discarded, 0);
|
||||
});
|
||||
|
||||
test("chips that went missing are still reported", () => {
|
||||
// The check exists for this: cash in the box with no chips to match it.
|
||||
const state = table();
|
||||
state.seats[0].stack = 150;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("an abandoned pot is accounted for rather than counted as missing", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 198;
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.chipsInPlay, 795);
|
||||
assert.equal(bank.discarded, 5);
|
||||
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
|
||||
});
|
||||
|
||||
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a nonsense discarded value can't move the answer", () => {
|
||||
const state = table();
|
||||
state.discarded = -100;
|
||||
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||
|
||||
state.discarded = "not a number";
|
||||
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||
});
|
||||
|
||||
test("a table saved before this existed reads as nothing thrown away", () => {
|
||||
const saved = JSON.stringify(table());
|
||||
const parsed = JSON.parse(saved);
|
||||
delete parsed.discarded;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.equal(loaded.discarded, 0);
|
||||
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
|
||||
test("the amounts still add up the way the panel shows them", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 199;
|
||||
state.seats[2].stack = 198;
|
||||
state.seats[3].stack = 198;
|
||||
state.discarded = 5;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// The things the dealer wrote down, in the file they hand over.
|
||||
//
|
||||
// The export already carried the hands, the standings and the settle-up. It didn't
|
||||
// carry either of the two places a dealer types context — the per-seat fields and the
|
||||
// table's own notes — so the one place an IOU was written down was the one place it
|
||||
// couldn't leave.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const tableWithNotes = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
state.seats[0].customFields = [
|
||||
{ label: "Seat rent", value: "paid to 2am" },
|
||||
{ label: "Venmo", value: "@ann" },
|
||||
];
|
||||
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a seat's own fields ride along with its standings row", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
|
||||
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
|
||||
assert.match(csv, /IOU: \$20 to Ann/);
|
||||
});
|
||||
|
||||
test("the money columns keep their order, with notes on the end", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
|
||||
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
|
||||
});
|
||||
|
||||
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||
|
||||
assert.ok(calRow, "Cal should have a row");
|
||||
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
|
||||
assert.ok(!/undefined|null/.test(calRow));
|
||||
});
|
||||
|
||||
test("a blank label or value doesn't produce a stray colon", () => {
|
||||
const state = tableWithNotes();
|
||||
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||
|
||||
assert.match(calRow, /Note/);
|
||||
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
|
||||
});
|
||||
|
||||
test("the table's notes are in the file", () => {
|
||||
const state = tableWithNotes();
|
||||
state.notes = "High-hand promo until midnight.";
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
|
||||
|
||||
assert.match(csv, /Table Notes/);
|
||||
assert.match(csv, /High-hand promo until midnight\./);
|
||||
});
|
||||
|
||||
test("a multi-line note survives as one cell", () => {
|
||||
// csvField quotes anything with a line break in it, so the note doesn't turn into
|
||||
// rows a spreadsheet reads as more players.
|
||||
const state = tableWithNotes();
|
||||
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
|
||||
|
||||
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
|
||||
});
|
||||
|
||||
test("no notes means no section, not an empty heading", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
|
||||
|
||||
assert.ok(!/Table Notes/.test(csv));
|
||||
});
|
||||
|
||||
test("the export still works with no standings at all", () => {
|
||||
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
|
||||
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
|
||||
assert.match(csv, /Table Notes/);
|
||||
});
|
||||
|
||||
test("asking for the standings doesn't change the seats", () => {
|
||||
const state = tableWithNotes();
|
||||
const before = JSON.stringify(state.seats);
|
||||
HoldemState.sessionStandings(state);
|
||||
assert.equal(JSON.stringify(state.seats), before);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Finding one hand in a night of them.
|
||||
//
|
||||
// What the log is actually opened for is a single hand — the one somebody at the rail
|
||||
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
|
||||
// there was no way to ask for one: the dealer scrolled and read every row.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const hand = (handNumber, pot, payouts, ledger = []) => ({
|
||||
handNumber, pot, timestamp: Date.now(), payouts, ledger,
|
||||
});
|
||||
|
||||
// A night as the tool records it: labels carry the cards the wizard was shown.
|
||||
const NIGHT = [
|
||||
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
|
||||
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
|
||||
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
|
||||
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
|
||||
];
|
||||
|
||||
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
|
||||
|
||||
describe("no filter", () => {
|
||||
test("shows the whole night", () => {
|
||||
assert.deepEqual(found(""), [1, 2, 12, 120]);
|
||||
assert.deepEqual(found(" "), [1, 2, 12, 120]);
|
||||
assert.deepEqual(found(null), [1, 2, 12, 120]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("by name", () => {
|
||||
test("finds the hand somebody won", () => {
|
||||
assert.deepEqual(found("Fay"), [2]);
|
||||
});
|
||||
|
||||
test("case doesn't matter — nobody types a capital at 2am", () => {
|
||||
assert.deepEqual(found("fay"), [2]);
|
||||
assert.deepEqual(found("FAY"), [2]);
|
||||
});
|
||||
|
||||
test("a name inside a label with cards on it still matches", () => {
|
||||
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
|
||||
// rather than the whole cell.
|
||||
assert.deepEqual(found("ann"), [1]);
|
||||
});
|
||||
|
||||
test("matches what the row shows, not everyone who was in the hand", () => {
|
||||
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
|
||||
// and searching text the panel doesn't display is a filter that looks broken
|
||||
// even when it's right. Measured on a seven-handed night where nobody folded,
|
||||
// matching the ledger returned 60 of 60 for every name at the table.
|
||||
assert.deepEqual(found("gus"), []);
|
||||
});
|
||||
|
||||
test("a split pot is found by either winner", () => {
|
||||
assert.deepEqual(found("cal"), [120]);
|
||||
assert.deepEqual(found("dee"), [120]);
|
||||
});
|
||||
|
||||
test("a name nobody has finds nothing rather than everything", () => {
|
||||
assert.deepEqual(found("zed"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("by amount", () => {
|
||||
test("finds a pot", () => {
|
||||
assert.deepEqual(found("190"), [2]);
|
||||
});
|
||||
|
||||
test("a dollar sign and commas are ignored", () => {
|
||||
assert.deepEqual(found("$190"), [2]);
|
||||
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
|
||||
});
|
||||
|
||||
test("finds a payout that isn't the whole pot", () => {
|
||||
assert.deepEqual(found("38"), [120], "half of a chopped $76");
|
||||
});
|
||||
|
||||
test("a partial amount narrows rather than missing", () => {
|
||||
// Half remembering "something around forty" should still get there.
|
||||
assert.deepEqual(found("4"), [1], "$40");
|
||||
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
|
||||
});
|
||||
|
||||
test("a bare number is an exact hand number, not a prefix of one", () => {
|
||||
// Amounts match on their leading digits, because half a remembered figure is
|
||||
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
|
||||
// knows which hand they want, and dragging 120 and 128 along with it is noise.
|
||||
// "#12" is there for when they do want the prefix.
|
||||
assert.ok(!found("12").includes(120));
|
||||
});
|
||||
});
|
||||
|
||||
describe("by hand number", () => {
|
||||
test("a bare number matches the hand number as well as amounts", () => {
|
||||
assert.ok(found("12").includes(12));
|
||||
});
|
||||
|
||||
test("a hash pins it to the hand number and nothing else", () => {
|
||||
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
|
||||
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
|
||||
});
|
||||
|
||||
test("a hash with a number nobody played finds nothing", () => {
|
||||
assert.deepEqual(found("#7"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("odd input can't break it", () => {
|
||||
test("punctuation on its own doesn't match everything", () => {
|
||||
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
|
||||
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
|
||||
});
|
||||
|
||||
test("an entry with pieces missing doesn't throw", () => {
|
||||
const broken = { handNumber: 5, pot: 10 };
|
||||
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
|
||||
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
|
||||
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const Money = load("money");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const SettleUp = load("settleUp");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
@@ -28,6 +29,10 @@ globalThis.Render = Render;
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
|
||||
// actionBar.js after state, since its own module-level code reaches for neither but
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -77,8 +82,10 @@ module.exports = {
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
SettleUp,
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||
});
|
||||
|
||||
// Standings rows: every column after the name has to be numeric.
|
||||
// Standings rows: the money columns have to be numeric. Named rather than
|
||||
// "everything after the first", which is what this used to say — the row now
|
||||
// ends with a Notes column carrying whatever the dealer typed against the seat,
|
||||
// and that is text by definition. The point of this test is that a column
|
||||
// somebody totals adds up, so it checks the columns somebody totals.
|
||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||
const header = splitRow(lines[standingsStart]);
|
||||
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
|
||||
.map(name => {
|
||||
const index = header.indexOf(name);
|
||||
assert.ok(index > 0, `the standings header should still have a ${name} column`);
|
||||
return index;
|
||||
});
|
||||
|
||||
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||
splitRow(line).slice(1).forEach(cell => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||
const cells = splitRow(line);
|
||||
moneyColumns.forEach(index => {
|
||||
const cell = cells[index];
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
|
||||
`standings cell "${cell}" in column ${header[index]} isn't a number`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,238 @@
|
||||
// Somebody who walks up and sits down after the blinds have gone past them.
|
||||
//
|
||||
// Dealing them straight in is a free hand in late position — which is what posting
|
||||
// exists to stop, and it comes out of everybody else's night a couple of dollars at a
|
||||
// time. The tool used to do it silently. It now says so, and takes the post if the
|
||||
// dealer says to.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
return state;
|
||||
};
|
||||
|
||||
// Eve walks up and takes seat 5 while the table is running.
|
||||
const seatEve = state => {
|
||||
state.seats[4].name = "Eve";
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 200;
|
||||
state.seats[4].buyIns = 200;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const debtors = state => {
|
||||
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||
return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
|
||||
};
|
||||
|
||||
describe("who is owed a blind", () => {
|
||||
test("nobody, at a table that hasn't dealt a hand yet", () => {
|
||||
// Everyone is new on the first hand. Asking the whole table to post would be
|
||||
// the tool mistaking the start of the night for five people walking up late.
|
||||
const state = table();
|
||||
assert.deepEqual(debtors(state), []);
|
||||
});
|
||||
|
||||
test("nobody, on an ordinary hand where the same people are still sitting there", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(debtors(state), []);
|
||||
});
|
||||
|
||||
test("the player who has just sat down", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
seatEve(state);
|
||||
|
||||
assert.deepEqual(debtors(state), ["Eve"]);
|
||||
});
|
||||
|
||||
test("not once they've been dealt a hand", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
seatEve(state);
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.deepEqual(debtors(state), [], "she's one of the regulars now");
|
||||
});
|
||||
|
||||
test("not somebody who is the big blind this hand — they're paying already", () => {
|
||||
const state = table(0); // blinds Ben and Cal
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state); // blinds move to Cal and Dee
|
||||
|
||||
// Dee sits out for a hand and comes straight back into her own big blind.
|
||||
state.seats[3].inHand = false;
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[3].inHand = true;
|
||||
|
||||
const { bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
|
||||
"the big blind is never asked to post on top of their blind");
|
||||
});
|
||||
|
||||
test("somebody back from sitting out is owed one just like a newcomer", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
state.seats[3].inHand = false; // Dee stands up for a hand
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
state.seats[3].inHand = true; // and sits back down
|
||||
|
||||
assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
|
||||
});
|
||||
|
||||
test("a seat sat out for having no chips isn't owed anything", () => {
|
||||
// They aren't being dealt in, so there's no free hand to charge for.
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
state.seats[eve].stack = 0;
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting to come in", () => {
|
||||
test("the post goes in the middle and buys a hand", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
const before = Betting.stackOf(state, state.seats[eve]);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
|
||||
assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
|
||||
"out of her own stack, not conjured");
|
||||
assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
|
||||
});
|
||||
|
||||
test("not posting leaves her stack alone", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
const before = Betting.stackOf(state, state.seats[eve]);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), 0);
|
||||
assert.equal(Betting.stackOf(state, state.seats[eve]), before);
|
||||
});
|
||||
|
||||
test("the pot is what the blinds and the post add up to", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
|
||||
const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
|
||||
assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
|
||||
});
|
||||
|
||||
test("it's spent after the hand, not a standing charge", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
|
||||
Betting.beginHand(state);
|
||||
assert.ok(Betting.committed(state.seats[eve]) === 0 ||
|
||||
state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
|
||||
"she pays nothing extra on the next hand");
|
||||
});
|
||||
|
||||
test("moving the button after a post doesn't lose it", () => {
|
||||
// repostBlinds takes every posted chip back and deals again. A post that
|
||||
// cleared itself on the way in would be refunded and never retaken.
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
Betting.beginHand(state);
|
||||
const chips = () => state.seats.reduce(
|
||||
(sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
|
||||
const before = chips();
|
||||
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
|
||||
assert.equal(chips(), before, "and no chips made or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("a post from somebody who isn't owed one is not taken", () => {
|
||||
// Only seats actually being dealt in can post, and only for their own chips.
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[3].id];
|
||||
state.seats[3].inHand = false;
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the dealer is shown", () => {
|
||||
test("the prompt has something to say about it, and something to press", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const eve = seatEve(state);
|
||||
|
||||
const preview = Betting.blindPreview(state);
|
||||
assert.deepEqual(preview.debtors, [eve]);
|
||||
assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
|
||||
|
||||
state.postingIn = [state.seats[eve].id];
|
||||
assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
|
||||
});
|
||||
|
||||
test("an old save reads as a table nobody has just walked up to", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const parsed = JSON.parse(JSON.stringify(state));
|
||||
delete parsed.dealtLastHand;
|
||||
delete parsed.postingIn;
|
||||
|
||||
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
|
||||
const loaded = HoldemState.load();
|
||||
assert.deepEqual(loaded.dealtLastHand, []);
|
||||
assert.deepEqual(loaded.postingIn, []);
|
||||
assert.deepEqual(debtors(loaded), [],
|
||||
"reopening the tab doesn't ask the whole table to post");
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
});
|
||||
@@ -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,134 @@
|
||||
// Sitting people down, which is the first thing anybody does with the tool.
|
||||
//
|
||||
// Naming a chair and dealing that chair in were two separate acts and the second was
|
||||
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
|
||||
// in the hand, and the action bar hid itself because by its reckoning nobody was
|
||||
// playing: six people at the table and a blank screen with no way forward.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
|
||||
|
||||
const emptyTable = (seatCount = 10) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seatCount);
|
||||
return state;
|
||||
};
|
||||
|
||||
const bar = state => Object.assign(Object.create(ActionBar), { state });
|
||||
|
||||
describe("the bar knows the difference between an empty table and a named one", () => {
|
||||
test("a table nobody has touched gets no bar", () => {
|
||||
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
|
||||
});
|
||||
|
||||
test("a table with names on it gets one, dealt in or not", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[1].name = "Ben";
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
|
||||
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
|
||||
});
|
||||
|
||||
test("whitespace isn't a player", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = " ";
|
||||
assert.equal(bar(state).isRunning(), false);
|
||||
});
|
||||
|
||||
test("a table that is playing still gets one", () => {
|
||||
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming an empty chair sits somebody down", () => {
|
||||
// What the seat editor's name field does, as the rule it applies rather than
|
||||
// through the DOM: a chair with no name and nobody in it is empty, and putting a
|
||||
// name on it is somebody arriving.
|
||||
const nameSeat = (player, name) => {
|
||||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||||
player.name = name;
|
||||
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
|
||||
};
|
||||
|
||||
test("an empty chair gets dealt in", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].inHand, true);
|
||||
assert.equal(state.seats[0].folded, false);
|
||||
});
|
||||
|
||||
test("six names is six players, not six benched seats", () => {
|
||||
const state = emptyTable();
|
||||
state.defaultStack = 200; // a table that has told the tool what a buy-in is
|
||||
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
|
||||
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 6);
|
||||
assert.equal(bar(state).isRunning(), true);
|
||||
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
|
||||
});
|
||||
|
||||
test("renaming somebody who is sitting out doesn't deal them back in", () => {
|
||||
// A correction, not an arrival. Dealing them in over a typo would be the tool
|
||||
// overruling a dealer who sat them out on purpose.
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].inHand = false;
|
||||
|
||||
nameSeat(state.seats[0], "Anne");
|
||||
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
|
||||
});
|
||||
|
||||
test("clearing a name doesn't sit anybody down", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
nameSeat(state.seats[0], "");
|
||||
assert.equal(state.seats[0].name, "");
|
||||
});
|
||||
|
||||
test("a chair already in the hand is left alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0] });
|
||||
state.seats[0].folded = true;
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the way back from a table where everybody is sat out", () => {
|
||||
test("there are named seats to offer", () => {
|
||||
// Can't be reached by naming any more, but a table where everyone was sat out
|
||||
// — or a save from before naming seated people — still lands here.
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
|
||||
|
||||
const benched = state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
||||
.map(({ index }) => index);
|
||||
|
||||
assert.deepEqual(benched, [0, 1, 2]);
|
||||
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
|
||||
});
|
||||
|
||||
test("dealing them all in makes a hand possible", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
|
||||
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
|
||||
});
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(Betting.blindPreview(state), "the table can be dealt");
|
||||
});
|
||||
|
||||
test("an unnamed chair is never swept in by it", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; }
|
||||
});
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
|
||||
});
|
||||
});
|
||||
@@ -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,124 @@
|
||||
// Turning the standings into "who hands cash to whom".
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
|
||||
|
||||
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
|
||||
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
|
||||
const paidTo = (plan, name) =>
|
||||
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
const paidBy = (plan, name) =>
|
||||
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
test("one loser, one winner", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("everybody flat means nothing to do", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("nobody at all means nothing to do", () => {
|
||||
const plan = SettleUp.plan([]);
|
||||
assert.deepEqual(plan.payments, []);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("each player ends up square", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
|
||||
table.forEach(row => {
|
||||
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
|
||||
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
|
||||
});
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("at most one payment fewer than there are people", () => {
|
||||
const table = standings([
|
||||
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
|
||||
]);
|
||||
const plan = SettleUp.plan(table);
|
||||
assert.ok(plan.payments.length <= table.length - 1,
|
||||
`${plan.payments.length} payments for ${table.length} players`);
|
||||
});
|
||||
|
||||
test("a player who owes two people has both lines together", () => {
|
||||
// The list gets read out one player at a time, so a payer's obligations must not
|
||||
// be split up by somebody else's.
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
|
||||
});
|
||||
|
||||
test("cents survive", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
|
||||
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
|
||||
assert.equal(plan.balanced, true);
|
||||
});
|
||||
|
||||
test("more chips than cash leaves somebody unpaid, and says so", () => {
|
||||
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
|
||||
// cannot all be paid, and pretending otherwise is how a real person ends up short.
|
||||
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
|
||||
assert.equal(plan.shortBy, 25);
|
||||
assert.equal(plan.overBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("less chips than cash leaves a payer with nowhere to send it", () => {
|
||||
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
|
||||
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
|
||||
assert.equal(plan.overBy, 25);
|
||||
assert.equal(plan.shortBy, 0);
|
||||
assert.equal(plan.balanced, false);
|
||||
});
|
||||
|
||||
test("no payment is ever for nothing", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.amount > 0));
|
||||
assert.equal(plan.payments.length, 1);
|
||||
});
|
||||
|
||||
test("nobody pays themselves", () => {
|
||||
const plan = SettleUp.plan(standings([
|
||||
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
|
||||
]));
|
||||
assert.ok(plan.payments.every(p => p.from !== p.to));
|
||||
});
|
||||
|
||||
test("the payments total what the winners are owed", () => {
|
||||
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
|
||||
const plan = SettleUp.plan(table);
|
||||
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
assert.equal(Money.round(moved), 60);
|
||||
});
|
||||
|
||||
test("it settles a real table's standings, not just hand-made numbers", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
|
||||
state.seats[0].buyIns = 100;
|
||||
state.seats[1].buyIns = 100;
|
||||
state.seats[2].buyIns = 100;
|
||||
state.seats[0].stack = 175;
|
||||
state.seats[1].stack = 90;
|
||||
state.seats[2].stack = 35;
|
||||
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
const plan = SettleUp.plan(rows);
|
||||
|
||||
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
assert.equal(plan.balanced, true);
|
||||
rows.forEach(row => {
|
||||
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
|
||||
});
|
||||
});
|
||||
@@ -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,110 @@
|
||||
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
|
||||
//
|
||||
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
|
||||
// rather than a whole DOM: the only things it asks about are what has focus and
|
||||
// whether an overlay is open.
|
||||
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"));
|
||||
|
||||
// A stand-in for a focused element. `matches` answers the same selector list the
|
||||
// real one would, which is the whole of what focusedControlOwnsKey looks at.
|
||||
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
|
||||
return {
|
||||
tagName,
|
||||
isContentEditable,
|
||||
matches: selector => selectors.some(s => selector.includes(s)),
|
||||
};
|
||||
}
|
||||
|
||||
const BUTTON = element("BUTTON", { selectors: ["button"] });
|
||||
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
|
||||
const LINK = element("A", { selectors: ["a[href]"] });
|
||||
const DIV = element("DIV");
|
||||
const TEXT_FIELD = element("INPUT");
|
||||
|
||||
// Runs one keypress against a fresh Shortcuts and reports which action fired.
|
||||
function press(key, { focused = DIV, overlays = [] } = {}) {
|
||||
const fired = [];
|
||||
let defaultPrevented = false;
|
||||
|
||||
globalThis.document = {
|
||||
activeElement: focused,
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => overlays,
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
for (const action of Object.values(Shortcuts.KEYS)) {
|
||||
handlers[action] = () => { fired.push(action); return true; };
|
||||
}
|
||||
Shortcuts.handlers = handlers;
|
||||
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
|
||||
|
||||
return { fired: fired[0] || null, defaultPrevented };
|
||||
}
|
||||
|
||||
test.after(() => { delete globalThis.document; });
|
||||
|
||||
test("Enter on a focused button is left to the button", () => {
|
||||
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("Space on a focused button is left to the button", () => {
|
||||
assert.equal(press(" ", { focused: BUTTON }).fired, null);
|
||||
});
|
||||
|
||||
test("a key left to the control is not swallowed either", () => {
|
||||
// preventDefault would stop the browser activating the button, which is the
|
||||
// half of the bug that turned "wrong action" into "no action".
|
||||
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
|
||||
});
|
||||
|
||||
test("summary and links own Enter and Space too", () => {
|
||||
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
|
||||
assert.equal(press(" ", { focused: LINK }).fired, null);
|
||||
});
|
||||
|
||||
test("Space still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press(" ", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("Enter still advances when nothing pressable has focus", () => {
|
||||
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
|
||||
});
|
||||
|
||||
test("the letter shortcuts stay live with a button focused", () => {
|
||||
// A button does nothing with "f", so there is no press to hand back — and a
|
||||
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
|
||||
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
|
||||
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
|
||||
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
|
||||
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
|
||||
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
|
||||
});
|
||||
|
||||
test("typing still silences everything but Escape", () => {
|
||||
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
|
||||
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
|
||||
});
|
||||
|
||||
test("an open overlay still owns the keyboard", () => {
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
|
||||
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
|
||||
});
|
||||
|
||||
test("a focused control wins before the overlay check, not after", () => {
|
||||
// Both would decline here; the point is that neither path fires an action.
|
||||
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
|
||||
});
|
||||
|
||||
test("modified keys are never shortcuts", () => {
|
||||
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
|
||||
const fired = [];
|
||||
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
|
||||
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
|
||||
assert.deepEqual(fired, []);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// Who turns their hand over first.
|
||||
//
|
||||
// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
|
||||
// table volunteers for — everyone would rather see a hand than show one. The tool knew
|
||||
// who last raised and who sits left of the button and said neither.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
|
||||
const table = (dealerSeatIndex = 0, stack = 200) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
|
||||
NAMES.forEach((name, i) => { state.seats[i].name = name; });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const call = state => {
|
||||
const answer = Betting.showdownOrder(state);
|
||||
return answer && { who: state.seats[answer.index].name, reason: answer.reason };
|
||||
};
|
||||
|
||||
// Everybody left just calls whatever is in front of them, then the street closes.
|
||||
const checkRound = state => {
|
||||
let guard = 0;
|
||||
while (state.actingSeatIndex !== null && guard++ < 12) {
|
||||
const index = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
|
||||
else Betting.check(state, index);
|
||||
}
|
||||
Betting.closeStreet(state);
|
||||
};
|
||||
|
||||
describe("a bet on the river", () => {
|
||||
test("the last player to raise shows first", () => {
|
||||
const state = table(0); // blinds Ben and Cal
|
||||
checkRound(state); // pre-flop
|
||||
checkRound(state); // flop
|
||||
checkRound(state); // turn
|
||||
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20);
|
||||
const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
|
||||
assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
|
||||
});
|
||||
|
||||
test("a re-raise moves it to the last one, not the first", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
const first = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, first, 20);
|
||||
const second = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, second, 60);
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
|
||||
assert.equal(call(state).who, state.seats[second].name);
|
||||
});
|
||||
|
||||
test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
|
||||
// The rule about reopening is about who may raise. It says nothing about who
|
||||
// shows, and the player who shoved is still the last one who put money in.
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
const bettor = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, bettor, 40);
|
||||
const shortStack = state.actingSeatIndex;
|
||||
// Over the $40 but only by $15, where a full raise would need $40 more. Going
|
||||
// *under* the bet would be a short call, not a short raise, and wouldn't make
|
||||
// them the aggressor at all.
|
||||
state.seats[shortStack].stack = 55;
|
||||
Betting.allIn(state, shortStack);
|
||||
assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
|
||||
assert.equal(call(state).who, state.seats[shortStack].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a river checked through", () => {
|
||||
test("the first live player left of the button shows first", () => {
|
||||
const state = table(0); // button Ann
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
|
||||
assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
|
||||
});
|
||||
|
||||
test("an earlier street's raiser doesn't carry over to the river", () => {
|
||||
// The rule is about the *final* round. Leading the flop and then checking the
|
||||
// river doesn't oblige anyone.
|
||||
const state = table(0);
|
||||
checkRound(state); // pre-flop
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
checkRound(state); // turn
|
||||
checkRound(state); // river
|
||||
|
||||
assert.equal(call(state).reason, "leftOfButton");
|
||||
});
|
||||
|
||||
test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
|
||||
const state = table(0); // button Ann, so Ben would be first
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
Betting.fold(state, 1); // Ben gets out on the river
|
||||
while (state.actingSeatIndex !== null) {
|
||||
const i = state.actingSeatIndex;
|
||||
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
|
||||
}
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(call(state).who, "Cal", "the next one round who is still in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("everyone all-in before the river", () => {
|
||||
test("the last player who could put money in shows first", () => {
|
||||
// There was no final round to lead, so "checked round" isn't a choice anybody
|
||||
// made and the seat left of the button hasn't earned the obligation.
|
||||
const state = table(0, 30);
|
||||
checkRound(state); // pre-flop
|
||||
|
||||
const shover = state.actingSeatIndex;
|
||||
Betting.allIn(state, shover);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
|
||||
assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
|
||||
});
|
||||
|
||||
test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
|
||||
// Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
|
||||
// and calling is not aggression. Nobody chose to lead, so nobody is on the hook
|
||||
// and it goes back to the seat left of the button.
|
||||
const state = table(0, 2);
|
||||
checkRound(state);
|
||||
|
||||
assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
|
||||
assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
|
||||
const answer = call(state);
|
||||
assert.ok(answer, "there is still a showdown to order");
|
||||
assert.equal(answer.reason, "leftOfButton");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there is nothing to call", () => {
|
||||
test("one player left isn't a showdown", () => {
|
||||
const state = table(0);
|
||||
[1, 2, 3].forEach(i => Betting.fold(state, i));
|
||||
assert.equal(Betting.showdownOrder(state), null);
|
||||
});
|
||||
|
||||
test("an aggressor who has since folded doesn't get named", () => {
|
||||
// Can't happen in play — you don't fold after raising unopposed — but the
|
||||
// index is a number on the state and a reset table could hold a stale one.
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
state.streetAggressorIndex = 1;
|
||||
state.seats[1].folded = true;
|
||||
|
||||
const answer = call(state);
|
||||
assert.notEqual(answer.who, "Ben");
|
||||
assert.equal(answer.reason, "leftOfButton");
|
||||
});
|
||||
|
||||
test("an index pointing at no seat at all is ignored", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
|
||||
state.streetAggressorIndex = 99;
|
||||
assert.equal(call(state).reason, "leftOfButton");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tracking resets when it should", () => {
|
||||
test("a new hand starts with nobody on the hook", () => {
|
||||
const state = table(0);
|
||||
checkRound(state); checkRound(state); checkRound(state);
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 20);
|
||||
assert.notEqual(state.handAggressorIndex, null);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.streetAggressorIndex, null);
|
||||
assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
|
||||
});
|
||||
|
||||
test("posting a blind is not aggression", () => {
|
||||
const state = table(0);
|
||||
assert.equal(state.streetAggressorIndex, null);
|
||||
assert.equal(state.handAggressorIndex, null);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// How the think-clock reads.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const ActionBar = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
|
||||
|
||||
test("seconds under a minute", () => {
|
||||
assert.equal(ActionBar.formatTank(0), "0:00");
|
||||
assert.equal(ActionBar.formatTank(7), "0:07");
|
||||
assert.equal(ActionBar.formatTank(59), "0:59");
|
||||
});
|
||||
|
||||
test("minutes and seconds", () => {
|
||||
assert.equal(ActionBar.formatTank(60), "1:00");
|
||||
assert.equal(ActionBar.formatTank(241), "4:01");
|
||||
assert.equal(ActionBar.formatTank(3599), "59:59");
|
||||
});
|
||||
|
||||
test("past the hour it says the hour", () => {
|
||||
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
|
||||
// as minutes and seconds when it isn't.
|
||||
assert.equal(ActionBar.formatTank(3600), "1:00:00");
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(45296), "12:34:56");
|
||||
});
|
||||
|
||||
test("the minutes are padded once there are hours, and not before", () => {
|
||||
// "1:5:03" would be wrong; "05:03" on its own would be odd.
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(303), "5:03");
|
||||
});
|
||||
|
||||
test("a day is still a readable number rather than a wrapped one", () => {
|
||||
assert.equal(ActionBar.formatTank(90000), "25:00:00");
|
||||
});
|
||||
+139
-9
@@ -133,7 +133,18 @@ const LayoutAudit = {
|
||||
if (el.contains(top) || top === el || top.contains(el)) return;
|
||||
// Seats ring a round table and overlap each other at the rim by
|
||||
// design; that is crowding, not an overlay hiding a control.
|
||||
if (top.closest && top.closest(".seat")) return;
|
||||
//
|
||||
// But only the seat's *body* is harmless. A neighbouring seat's own
|
||||
// controls sitting on top of this one is a control being stolen, and
|
||||
// exempting the whole seat subtree hid exactly that: an enlarged
|
||||
// dealer-chip hit area was taking the centre of the seat three
|
||||
// places round the table, and this check called it crowding and
|
||||
// moved on.
|
||||
const neighbourSeat = top.closest && top.closest(".seat");
|
||||
if (neighbourSeat) {
|
||||
const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']");
|
||||
if (!isControl) return;
|
||||
}
|
||||
blocked++;
|
||||
if (!blockedBy) blockedBy = this.describe(top);
|
||||
});
|
||||
@@ -169,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;
|
||||
@@ -332,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 = [];
|
||||
@@ -341,19 +368,50 @@ const LayoutAudit = {
|
||||
: [...document.querySelectorAll("*")];
|
||||
|
||||
nodes.forEach(el => {
|
||||
if (el.children.length > 0) return; // leaf text only
|
||||
const text = (el.textContent || "").trim();
|
||||
// An element's *own* text, not its descendants' — they get measured on
|
||||
// their own turn, against their own colour.
|
||||
//
|
||||
// This used to be "leaf elements only", which quietly stopped measuring
|
||||
// anything the moment a child was added to it. The undo control went that
|
||||
// way the day a <kbd> was appended to it: its label became invisible to
|
||||
// this check, with no sign that anything had changed.
|
||||
const text = [...el.childNodes]
|
||||
.filter(node => node.nodeType === 3)
|
||||
.map(node => node.textContent)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!text) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
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
|
||||
// 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
|
||||
// too, which is the same claim expressed where it also does some good.
|
||||
if (el.closest("[aria-hidden='true']")) return;
|
||||
// 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;
|
||||
}
|
||||
// aria-hidden used to exempt text here, on the grounds that decoration
|
||||
// marked for screen readers was decoration for everyone. Those are two
|
||||
// different questions and the answer differs: the bet chips on the felt are
|
||||
// aria-hidden because their amounts are already announced by the seat and
|
||||
// the action bar, and repeating them would be noise — but they are exactly
|
||||
// what a sighted dealer reads the felt for, and this check had stopped
|
||||
// looking at them. Hiding something from one audience is not a claim about
|
||||
// whether the other audience can read it.
|
||||
//
|
||||
// Nothing is exempt now. If something genuinely decorative turns up, it
|
||||
// should say so in its own words rather than borrowing an attribute that
|
||||
// means something else.
|
||||
|
||||
const fgRaw = this.parseColour(style.color);
|
||||
if (!fgRaw) return;
|
||||
@@ -468,6 +526,76 @@ const LayoutAudit = {
|
||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
||||
},
|
||||
|
||||
// Tapping the middle of a seat must give you that seat.
|
||||
//
|
||||
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
|
||||
// outward from a chip that already hung over its seat's corner — and therefore
|
||||
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
|
||||
// handed the button to seat 9. The occlusion check missed it because it treated
|
||||
// anything inside a `.seat` as harmless crowding.
|
||||
//
|
||||
// Reported separately from occlusion because the cause is different: not an
|
||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||
seatOwnership() {
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
const offenders = [];
|
||||
|
||||
seats.forEach((seat, index) => {
|
||||
const rect = seat.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
|
||||
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit || seat.contains(hit) || hit === seat) return;
|
||||
|
||||
const other = hit.closest(".seat");
|
||||
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
|
||||
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
|
||||
offenders.push(
|
||||
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
|
||||
);
|
||||
});
|
||||
|
||||
return { name: "a seat's centre belongs to that seat", offenders };
|
||||
},
|
||||
|
||||
// A poker table has one dealer button. Nine pucks all showing "D", one of them
|
||||
// gold, turns the most-read marker on the felt into a spot-the-difference — and
|
||||
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
|
||||
// Both halves are checked here: what's drawn, and what it announces itself as.
|
||||
oneDealerButton() {
|
||||
const chips = [...document.querySelectorAll(".dealerChip")];
|
||||
const shown = chips.filter(chip => {
|
||||
const style = getComputedStyle(chip);
|
||||
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 = [];
|
||||
|
||||
if (shown.length > 1) {
|
||||
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
|
||||
}
|
||||
|
||||
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
|
||||
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
|
||||
if (duplicated.length) {
|
||||
offenders.push(
|
||||
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { name: "the felt shows one dealer button", offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
runAll(options = {}) {
|
||||
@@ -480,6 +608,8 @@ const LayoutAudit = {
|
||||
this.textSpill(),
|
||||
this.horizontalOverflow(),
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
];
|
||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
||||
|
||||
|
||||
@@ -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