Compare commits

..
44 changed files with 268 additions and 7821 deletions
-26
View File
@@ -1,26 +0,0 @@
# Unit tests for the Texas Hold'em dealer's money logic.
#
# The betting engine, the side-pot calculator and the table state are plain script-tag
# modules with no build step, so they run directly under Node's built-in test runner —
# no package.json, no install step, nothing to keep up to date.
name: Hold'em tests
on:
pull_request:
branches: [ "master" ]
push:
branches: [ "master" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Hold'em unit tests
run: node --test tests/holdem/*.test.js
+46 -210
View File
@@ -25,19 +25,15 @@
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="tableWatermark">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
</p>
</section>
<aside id="holdemSidebar">
@@ -85,65 +81,12 @@
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
</details>
</div>
</details>
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
</div>
<div id="standingsList"></div>
</div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
<span id="bankCashIn"></span>
</div>
<div class="bankReconcileRow">
<span>Chips in play</span>
<span id="bankChipsInPlay"></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 id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</p>
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
</div>
</details>
@@ -152,14 +95,42 @@
<div class="panelBody">
<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>
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
<details class="sidebarPanel">
<summary>Session Standings</summary>
<div class="panelBody">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Hand History</summary>
<div class="panelBody">
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
</aside>
@@ -169,7 +140,7 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<button class="dealerChip" 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>
@@ -203,7 +174,6 @@
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
@@ -219,8 +189,8 @@
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<input type="color" class="chipColorInput" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
@@ -246,7 +216,6 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -261,135 +230,6 @@
</div>
</template>
<div id="addChipsOverlay" hidden>
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle"></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
<div class="addChipsEntry">
<span class="addChipsCurrency">$</span>
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
The conventions most rooms follow, for the calls that come up mid-hand.
Your house rules always win — and settle a dispute before the next hand,
not during it.
</p>
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
<section class="ruling">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
<section class="ruling">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</section>
<section class="ruling">
<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
raise was $8 — does not give the players behind them another go. Anyone
who has already acted owes the difference or folds; they don't get to
raise again because a short stack happened to land between them.
</p>
<p>
A player who hadn't acted yet keeps full rights and may still raise, and
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</section>
<section class="ruling">
<h3>Chips nobody called come back</h3>
<p>
Bet more than anyone was able to call and the excess is returned to you
before the showdown — it was never contested, so it isn't part of the
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
a side pot you could win.
</p>
</section>
<section class="ruling">
<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">
<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">
<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
player left of the button, not to whoever the dealer happened to tick
first. Pot payouts here order the winners that way, so the leftover lands
on the right seat every time.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
</p>
</div>
</div>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@@ -398,7 +238,7 @@
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
@@ -408,7 +248,7 @@
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
@@ -424,17 +264,17 @@
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div id="potWizardModal">
<div class="potWizardHeader">
<h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
<h2>Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
</div>
@@ -449,16 +289,12 @@
<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>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -11,11 +11,6 @@
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
</div>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
@@ -23,18 +18,7 @@
<div id="splitDustNote" hidden></div>
</div>
<div id="unevenResult" hidden aria-live="polite">
<div id="unevenPots"></div>
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
</div>
<section class="splitField unevenOnly" hidden>
<span class="splitLabel">What each player put in</span>
<div id="unevenRows"></div>
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
</section>
<section class="splitField evenOnly">
<section class="splitField">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
@@ -44,12 +28,12 @@
</div>
</section>
<section class="splitField evenOnly">
<section class="splitField">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField evenOnly">
<section class="splitField">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
@@ -59,30 +43,12 @@
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote evenOnly">
If somebody was all-in for less than the others, switch to
<strong>Someone's all-in</strong> above.
</p>
<p class="splitFootnote unevenOnly" hidden>
Pots are listed with who can win each. Award them by hand strength, best hand
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
keeps a log.
<p class="splitFootnote">
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
<a href="/casino/texasholdem">full table</a>.
</p>
</div>
<template id="unevenRowTemplate">
<div class="unevenRow">
<span class="unevenRowLabel"></span>
<div class="unevenAmountWrap">
<span class="unevenCurrency">$</span>
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
</div>
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
</div>
</template>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@@ -90,6 +56,5 @@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
File diff suppressed because it is too large Load Diff
+11 -251
View File
@@ -9,58 +9,6 @@ body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Same touch handling as the dealer table — see the note there. This page is the one
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
whether a tap was a double-tap-to-zoom is the last thing it needs. */
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
@media (prefers-reduced-motion: reduce) {
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: none;
}
}
#splitApp {
max-width: 480px;
margin: 0 auto;
@@ -97,171 +45,6 @@ button,
font-size: 0.85rem;
}
/* ---------- Mode toggle ---------- */
#modeToggle {
margin-bottom: 1.1rem;
}
/* ---------- Uneven amounts ---------- */
#unevenRows {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 0.6rem;
}
.unevenRow {
display: flex;
align-items: center;
gap: 0.45rem;
}
.unevenRowLabel {
flex: none;
width: 2rem;
color: #8f8f8f;
font-size: 0.8rem;
font-weight: 600;
}
.unevenAmountWrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.3rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 0 0.6rem;
}
.unevenAmountWrap:focus-within {
border-color: #d8b46a;
}
.unevenCurrency {
color: #777;
}
.unevenAmount {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
padding: 0.6rem 0;
}
.unevenFoldBtn {
flex: none;
min-height: 44px;
padding: 0 0.6rem;
border-radius: 9px;
border: 1px solid #3a3a3a;
background: #232323;
color: #8f8f8f;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.unevenFoldBtn.selected {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
border: none;
background: none;
color: #6a6a6a;
font-size: 0.85rem;
cursor: pointer;
}
.unevenRemoveBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#unevenResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 0.9rem 1rem;
margin-bottom: 1.5rem;
}
#unevenResult[hidden] {
display: none;
}
.unevenPot {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.unevenPotLabel {
flex: none;
color: #d8b46a;
font-weight: 600;
}
.unevenPotAmount {
flex: none;
color: #f2f2f2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.unevenPotWho {
flex: 1;
min-width: 0;
text-align: right;
color: #9a9a9a;
font-size: 0.78rem;
}
.unevenPotReturn .unevenPotLabel,
.unevenPotReturn .unevenPotAmount {
color: #9a9a9a;
font-weight: 500;
}
.unevenPotTotal {
margin-top: 0.15rem;
border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
}
.unevenPotTotal .unevenPotLabel {
color: #8f9d92;
font-weight: 500;
}
.unevenEmpty {
margin: 0;
color: #8f9d92;
font-size: 0.82rem;
line-height: 1.45;
}
/* ---------- The answer ---------- */
#splitResult {
@@ -321,12 +104,6 @@ button,
margin-bottom: 1.35rem;
}
/* A `display` declaration outranks the browser's own [hidden] rule, so anything
given one has to opt back out explicitly or toggling `hidden` does nothing. */
[hidden] {
display: none !important;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
@@ -388,11 +165,9 @@ button,
cursor: pointer;
}
@media (hover: hover) {
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
/* ---------- Tap targets ---------- */
@@ -418,11 +193,9 @@ button,
transition: all 0.15s ease;
}
@media (hover: hover) {
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.choiceChip.selected {
@@ -457,10 +230,8 @@ button,
cursor: pointer;
}
@media (hover: hover) {
.stepBtn:hover {
background: #333;
}
.stepBtn:hover {
background: #333;
}
.waysReadout {
@@ -497,11 +268,9 @@ button,
transition: all 0.15s ease;
}
@media (hover: hover) {
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.splitBtnGhost {
@@ -516,15 +285,6 @@ button,
text-align: center;
}
/* This footnote names a control the dealer is being told to reach for, so the name
itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
the surrounding sentence is deliberately set in. Emphasised text carrying the
instruction gets its own colour rather than the aside's. */
.splitFootnote strong {
color: #a8a8a8;
font-weight: 600;
}
.splitFootnote a {
color: #a9782f;
}
@@ -11,69 +11,10 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.watchHeight();
this.render();
},
// The bar is fixed to the bottom of the screen, so everything else has to know how
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
// constant either: opening the raise pad grows it. Publishing the measured height
// as a custom property lets the layout and the toast both stay clear of it without
// anyone guessing a number.
watchHeight() {
const host = document.getElementById("actionBar");
if (!host || this.heightWatcher) return;
const publish = () => {
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") {
this.heightWatcher = new ResizeObserver(publish);
this.heightWatcher.observe(host);
}
window.addEventListener("resize", publish);
window.addEventListener("orientationchange", publish);
publish();
},
// 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.
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
isRunning() {
return Betting.liveSeats(this.state).length > 0;
},
@@ -86,9 +27,6 @@ const ActionBar = {
if (!this.isRunning()) {
host.hidden = true;
document.body.classList.remove("hasActionBar");
document.documentElement.style.setProperty("--actionBarHeight", "0px");
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
host.hidden = false;
@@ -99,9 +37,7 @@ const ActionBar = {
const breakdown = this.renderPotBreakdown();
if (breakdown) host.appendChild(breakdown);
if (!this.state.bettingOpen) {
host.appendChild(this.renderDealPrompt());
} else if (Betting.isHandOver(this.state)) {
if (Betting.isHandOver(this.state)) {
host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
host.appendChild(this.renderStreetDone());
@@ -110,8 +46,6 @@ const ActionBar = {
} else {
host.appendChild(this.renderActions());
}
this.syncTankTimer();
},
renderStatus() {
@@ -128,28 +62,6 @@ const ActionBar = {
pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`;
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. 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"}`;
row.appendChild(players);
// Nobody is being asked to act because nobody can — say so, or the bar just
// looks like it's waiting for something.
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
const runout = document.createElement("span");
runout.className = "actionRunout";
const stillIn = Betting.liveSeats(this.state).length;
runout.textContent = `All-in — running out the board (${stillIn} players)`;
row.appendChild(runout);
}
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
@@ -157,101 +69,15 @@ const ActionBar = {
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
}
// A standing control rather than a toast per action: a dealer taps this bar
// constantly, and a toast after every fold would be noise they'd learn to
// ignore — which is exactly when they'd miss the one that mattered.
const undoLabel = this.hooks.lastActionLabel();
if (undoLabel) {
const undo = document.createElement("button");
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
return row;
},
// ---- Think timer ----
//
// A player who hasn't noticed the action is on them costs more time than anything
// else a dealer does, so the bar shows how long the current player has been in the
// tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just
// tells the dealer when it's worth a nudge.
NUDGE_AFTER_S: 30,
SLOW_AFTER_S: 60,
tankSeatIndex: null,
tankStartedAt: null,
tankInterval: 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() {
const index = this.state.actingSeatIndex;
const running = index !== null && index !== undefined && !Betting.isHandOver(this.state);
if (!running) {
this.stopTankTimer();
this.tankSeatIndex = null;
return;
}
if (this.tankSeatIndex !== index) {
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
if (!this.tankInterval) {
this.tankInterval = setInterval(() => this.paintTank(), 1000);
}
this.paintTank();
},
stopTankTimer() {
if (this.tankInterval) {
clearInterval(this.tankInterval);
this.tankInterval = null;
}
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb.
paintTank() {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) 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.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// 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.
renderPotBreakdown() {
// Only worth showing once someone is actually all-in. Mid-street the amounts in
// front of players are unequal all the time — the blinds alone are unequal —
// and layering that would announce a side pot on every hand before anyone had
// even acted.
const someoneAllIn = this.state.seats.some(
player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0
);
if (!someoneAllIn) return null;
const contributions = this.state.seats.map((player, index) => ({
id: player.id,
index,
@@ -270,19 +96,6 @@ const ActionBar = {
);
},
// Which key presses each button. Shown on the button itself and hidden on touch
// devices — a shortcut nobody can discover may as well not exist, and a key badge
// on a phone is just clutter.
KEY_HINTS: {
actionFold: "F",
actionCheck: "C",
actionCall: "C",
actionRaise: "R",
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
},
button(label, className, onClick, sublabel) {
const btn = document.createElement("button");
btn.type = "button";
@@ -293,14 +106,6 @@ const ActionBar = {
main.textContent = label;
btn.appendChild(main);
const hint = this.KEY_HINTS[className];
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
kbd.textContent = hint;
btn.appendChild(kbd);
}
if (sublabel) {
const sub = document.createElement("span");
sub.className = "actionBtnSub";
@@ -312,11 +117,7 @@ const ActionBar = {
return btn;
},
// Every action taken from the bar goes through here, which is also where it gets
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
// tool a dealer can't talk their way out of.
commit(label, mutate) {
this.hooks.beginAction(label);
commit(mutate) {
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
@@ -327,28 +128,24 @@ const ActionBar = {
const player = this.state.seats[index];
const owed = Betting.toCall(this.state, player);
const stack = Betting.stackOf(this.state, player);
const who = HoldemState.displayName(player, index);
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
row.appendChild(this.button("Fold", "actionFold", () => this.commit(() => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
row.appendChild(this.button("Check", "actionCheck", () => this.commit(() => Betting.check(this.state, index))));
} else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number
// they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(() => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
row.appendChild(this.button("Call", "actionCall", () => this.commit(() => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
// 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", () => {
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
@@ -361,27 +158,12 @@ 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);
// 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 minTo = Math.min(Betting.minRaiseTo(this.state), maxTo);
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -414,10 +196,9 @@ const ActionBar = {
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
this.updateRaiseNote(wrap, legalMin, maxTo);
});
entry.appendChild(input);
@@ -426,164 +207,13 @@ const ActionBar = {
this.render();
}));
// "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", () => {
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
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));
this.commit(() => 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;
},
// 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);
return wrap;
}
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
wrap.appendChild(line);
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
if (preview.buttonMoves) {
const moved = document.createElement("p");
moved.className = "dealPromptLine dealPromptAside";
moved.textContent =
`Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`;
wrap.appendChild(moved);
}
// Dealing sits out anyone with nothing behind. Said before it happens, for the
// same reason the button move is: a dealer shouldn't find out from the felt.
if (preview.satOut.length > 0) {
const out = document.createElement("p");
out.className = "dealPromptLine dealPromptAside";
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
wrap.appendChild(out);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
return wrap;
},
@@ -599,7 +229,7 @@ const ActionBar = {
this.hooks.resolvePot();
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
this.commit(() => Betting.closeStreet(this.state));
}));
return row;
@@ -1,92 +0,0 @@
// "Add chips" — a rebuy, a top-up, or the first buy-in of the night.
//
// Quick amounts come off the table's starting stack, because a rebuy is almost
// always one buy-in, sometimes half or two. The keypad is there for the times it
// isn't. Recorded as cash in as well as chips, so at the end of the night the
// dealer can say what each player actually handed over.
const AddChips = {
state: null,
onConfirm: null,
seatIndex: null,
draft: "",
open(state, seatIndex, onConfirm) {
this.state = state;
this.seatIndex = seatIndex;
this.onConfirm = onConfirm;
this.draft = String(this.baseAmount());
this.render();
document.getElementById("addChipsOverlay").hidden = false;
},
close() {
document.getElementById("addChipsOverlay").hidden = true;
},
// The table's starting stack if there is one, otherwise something sane to nudge
// rather than an empty box.
baseAmount() {
const configured = Number(this.state.defaultStack);
if (!Number.isNaN(configured) && configured > 0) return configured;
const bb = Number(this.state.bigBlind) || 0;
return bb > 0 ? bb * 100 : 100;
},
quickAmounts() {
const base = this.baseAmount();
return [
{ label: "½ buy-in", value: Money.round(base / 2) },
{ label: "Buy-in", value: base },
{ label: "2 buy-ins", value: base * 2 },
];
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
const stack = Betting.stackOf(this.state, player);
const boughtIn = Number(player.buyIns) || 0;
document.getElementById("addChipsSubtitle").textContent = boughtIn > 0
? `${Money.format(stack)} in front · ${Money.format(boughtIn)} in so far`
: `${Money.format(stack)} in front`;
const quick = document.getElementById("addChipsQuick");
quick.innerHTML = "";
this.quickAmounts().forEach(option => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value);
const label = document.createElement("span");
label.className = "addChipsQuickLabel";
label.textContent = option.label;
const amount = document.createElement("span");
amount.className = "addChipsQuickAmount";
amount.textContent = Money.format(option.value);
btn.appendChild(label);
btn.appendChild(amount);
btn.addEventListener("click", () => {
this.draft = String(option.value);
this.render();
});
quick.appendChild(btn);
});
const input = document.getElementById("addChipsInput");
input.value = this.draft;
},
confirm() {
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
this.close();
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = AddChips;
}
+22 -272
View File
@@ -18,7 +18,7 @@ const Betting = {
},
round(amount) {
return Money.round(amount);
return Math.round((Number(amount) || 0) * 100) / 100;
},
// ---- Reading the table ----
@@ -29,105 +29,6 @@ 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 } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
};
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
// their chips and stayed sat out, silently skipped hand after hand until somebody
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
// read "Ben added $100".
//
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
// somebody out on purpose and is topping them up, and dealing them back in would be
// the tool guessing at something it wasn't told. Only a seat with no stack of its
// own: busted at zero, or a chair being bought into for the first time.
//
// Deliberately reads player.stack rather than stackOf(), which substitutes the
// table's starting stack for a seat that has none — the question here is what this
// seat was actually recorded as holding.
buyInDealsThemIn(player) {
if (!player || player.inHand) return false;
const own = player.stack;
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
return !recorded || Number(own) <= this.EPSILON_CHIPS;
},
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
// raise. The bar decided this from what the acting player still owes, which gets
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
// their own blind is a live bet and putting more in is a raise. It gets the other
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
// and wrote "Ann's raise to $10" into the undo control for it.
//
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet). This is only what it's called, which
// for a tool a dealer announces the action from is not a small thing.
wagerVerb(state) {
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -138,16 +39,11 @@ const Betting = {
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
},
// Never below zero. A seat can't have negative chips in front of it, and a typo in
// the stack field used to come back as-is — which then flowed into putIn(), where
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
stackOf(state, player) {
const own = player.stack;
if (own !== null && own !== undefined && String(own).trim() !== "") {
return Math.max(0, this.round(own));
}
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
const fallback = Number(state.defaultStack);
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
return Number.isNaN(fallback) ? 0 : this.round(fallback);
},
toCall(state, player) {
@@ -174,42 +70,15 @@ const Betting = {
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded) return index;
if (player.inHand && !player.folded && this.stackOf(state, player) >= 0) return index;
}
return null;
},
// Who the dealer asks next. A player who is all-in is still in the hand and still
// wins pots, but has no decision left, so the action passes over them.
nextToAct(state, fromIndex) {
const count = state.seatCount;
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded && this.stackOf(state, player) > this.EPSILON_CHIPS) return index;
}
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.
seatAt(state, fromIndex, step) {
return HoldemState.nextSeatIndex(state, fromIndex, step);
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
return ((fromIndex + delta) % count + count) % count;
},
// A street is done when everyone still live has either matched the bet or has
@@ -266,33 +135,14 @@ const Betting = {
const delta = this.round(target - this.committed(player));
const moved = this.putIn(state, index, delta);
const newBet = this.committed(player);
const previousBet = state.currentBet || 0;
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
// 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);
const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS;
if (newBet > (state.currentBet || 0)) {
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
state.currentBet = newBet;
if (isFullRaise) {
state.lastRaiseSize = increase;
// A real raise reopens the action: everyone else owes a response again.
state.seats.forEach((p, i) => {
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
});
}
// Otherwise this is a short all-in — somebody with less than a full raise
// left shoving it in. The most misread rule in poker: it does *not* reopen
// the betting to anyone who has already acted. They owe the difference or
// they fold; they don't get to raise again because a short stack happened
// to be between them. Players yet to act keep their full rights, which
// they do here because their hasActed is already false.
//
// lastRaiseSize deliberately isn't lowered either, or the next legal raise
// would be computed off the short amount and come out too small.
// A raise reopens the action: everyone else owes a response again.
state.seats.forEach((p, i) => {
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
});
}
player.hasActed = true;
@@ -306,7 +156,7 @@ const Betting = {
},
passAction(state, index) {
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
},
// ---- Street and hand boundaries ----
@@ -334,111 +184,22 @@ const Betting = {
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
// It takes two players with chips to have a bet. Once everyone left is all-in
// the rest of the board is just dealt out.
if (!this.canStillBet(state)) {
state.actingSeatIndex = null;
return;
}
// After the flop the first live player left of the button acts — which heads-up
// is the big blind, since the button is the small blind. Naming the big blind
// directly also covers a button parked on a seat nobody is sitting in, and it
// still has to be someone with chips left to bet.
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
const bigBlindCanAct =
headsUp &&
bbIndex !== null &&
!state.seats[bbIndex].folded &&
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
state.actingSeatIndex = bigBlindCanAct
? bbIndex
: this.nextToAct(state, state.dealerSeatIndex);
state.actingSeatIndex = this.liveSeats(state).length > 1
? this.nextLiveIndex(state, state.dealerSeatIndex)
: null;
},
isHandOver(state) {
return this.liveSeats(state).length <= 1;
},
// Players still in the hand who have chips left to put in. Fewer than two of them
// means the betting is finished even though the hand isn't.
seatsWithChips(state) {
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
},
EPSILON_CHIPS: Money.EPSILON,
canStillBet(state) {
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
},
// 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);
},
// True while nothing has happened in this hand except the blinds going in. Up to
// that point the dealer can still move the button or rename the blinds and have
// it cost nothing — which is the state a home game spends a lot of time in,
// arguing about whose turn it is before the cards come out.
onlyBlindsIn(state) {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
// times before anybody is happy with it.
repostBlinds(state) {
state.seats.forEach(player => {
const back = this.committed(player);
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
player.committed = 0;
player.hasActed = false;
});
this.beginHand(state);
},
// 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;
// Deal around anyone with no chips, before anything else reads the table.
//
// A busted player who hasn't rebought used to be dealt in anyway, and the
// result was a hand nobody could finish: the table promised a small blind from
// a seat with nothing to post it with, took it from nobody, put the action on
// the big blind with only their own money in front of them, and announced
// "3 players — all-in, running out the board" before a card was dealt.
//
// Sitting them out is what a dealer does. Seat, name and buy-in record are
// untouched, so dealing them back in is one tap when they buy chips.
this.brokeSeats(state).forEach(index => {
state.seats[index].inHand = false;
state.seats[index].folded = false;
});
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
// brand-new table starts on.
HoldemState.ensureButtonOnPlayer(state);
state.seats.forEach(player => {
player.committed = 0;
player.hasActed = false;
@@ -446,26 +207,15 @@ const Betting = {
const sb = Number(state.smallBlind) || 0;
const bb = Number(state.bigBlind) || 0;
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
if (sbIndex === null || bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
return;
}
if (sb > 0) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
state.currentBet = this.round(Math.max(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
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
},
// Winners get their share put back in front of them.
@@ -15,10 +15,6 @@ const HandHistoryExport = {
}
},
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
// 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 = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
@@ -29,7 +25,7 @@ const HandHistoryExport = {
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.format(entry.pot),
payouts,
]);
});
@@ -39,41 +35,10 @@ const HandHistoryExport = {
if (standings.length > 0) {
rows.push([]);
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"]);
rows.push(["Player", "Paid In", "Won", "Net"]);
standings.forEach(row => {
rows.push([
row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
]);
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
});
// 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`,
]);
}
}
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
+22 -253
View File
@@ -1,27 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
// `hidden` attribute they all already toggle.
Modal.mount();
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at
// the old object and quietly ignoring the undo.
const snapshotState = () => JSON.parse(JSON.stringify(state));
const restoreState = snapshot => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
};
// A gesture on a seat is a shortcut for something the dealer could do the long
// way, so each one is undoable the same way the header buttons are.
const seatAction = (index, message, mutate) => {
const snapshot = snapshotState();
const snapshot = JSON.parse(JSON.stringify(state));
mutate(state.seats[index], index);
HoldemState.save(state);
Render.renderAll();
@@ -29,7 +12,10 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
restoreState(snapshot);
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
duration: 6000,
@@ -56,7 +42,6 @@ document.addEventListener("DOMContentLoaded", () => {
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
onGiveButton: index => seatMenuHooks.makeDealer(index),
};
const seatMenuHooks = {
@@ -64,105 +49,14 @@ document.addEventListener("DOMContentLoaded", () => {
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
toggleDealtIn: index => {
const player = state.seats[index];
const dealtIn = player.inHand;
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
const alreadyIn = Betting.handTotal(player);
// Somebody walking away mid-hand doesn't get their chips back — they're
// dead money the winner collects. Say so, because the pot total pointedly
// does not go down and a dealer needs to know that's deliberate.
const message = dealtIn
? (alreadyIn > 0
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
: `${nameOf(index)} sits out.`)
: (handRunning
? `${nameOf(index)} is in from the next hand.`
: `${nameOf(index)} is dealt in.`);
seatAction(index, message, p => {
const dealtIn = state.seats[index].inHand;
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
p.inHand = !dealtIn;
if (!p.inHand) {
// Their chips stay put; only the fold flag is cleared, since being
// out of the hand already covers not winning it.
p.folded = false;
if (state.actingSeatIndex === index) Betting.passAction(state, index);
} else if (handRunning) {
// Joining a hand already in progress means sitting this one out —
// you can't buy into a pot you haven't been paying for. `folded`
// is exactly that state, and the next hand clears it.
p.folded = true;
}
if (!p.inHand) p.folded = false;
});
},
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
// money disagree — but that's a real thing happening to real chips, and
// the dealer gets told rather than finding out later.
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
seatAction(index, willRepost
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
if (willRepost) Betting.repostBlinds(state);
});
},
assignBlind: (index, which) => {
const blinds = HoldemState.getBlindSeats(state);
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
// Naming one blind pins the other where it already is, so the dealer only
// has to state the part they're changing. If that would land both on one
// player, the other blind moves to the next seat along.
let partner = other !== null && other !== undefined && other !== index
? other
: Betting.nextLiveIndex(state, index);
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
const label = which === "sb" ? "small blind" : "big blind";
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
state.blindOverride = which === "sb"
? { sbIndex: index, bbIndex: partner }
: { sbIndex: partner, bbIndex: index };
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
});
},
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
state.blindOverride = null;
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
}),
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
// 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.
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;
}
});
}),
};
Render.mount(state, hooks);
@@ -183,76 +77,16 @@ 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);
});
});
};
// A short history of actions taken from the bar, so a misclicked Fold can be taken
// back — and the one before it, since a dealer usually notices a beat late. Kept in
// memory only: this is for the mistake you just made, not last night's hand.
const MAX_UNDO_STEPS = 12;
const actionHistory = [];
ActionBar.mount(state, {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
restoreState(step.snapshot);
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
},
});
// Anything that ends the hand makes the per-action history meaningless — the
// snapshots refer to a hand that no longer exists.
const clearActionHistory = () => { actionHistory.length = 0; };
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
// calling the betting engine themselves — there's no second code path to drift out
// of step with what the bar is actually offering right now.
const pressInBar = selector => {
const btn = document.querySelector(`#actionBar ${selector}`);
if (!btn || btn.disabled) return false;
btn.click();
return true;
};
const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
Shortcuts.mount({
fold: () => pressInBar(".actionFold"),
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
raise: () => pressInBar(".actionRaise"),
allIn: () => {
// Open the raise pad first if it isn't already, then take its All-in amount.
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
const quick = quickRaiseButton("All-in");
if (!quick) return false;
quick.click();
return true;
},
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
// 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"),
});
// Moving to the next hand used to ask "are you sure?" every single time —
@@ -261,55 +95,27 @@ document.addEventListener("DOMContentLoaded", () => {
// would notice a misclick. The snapshot is deliberately in-memory only: an
// undo is for the mistake you just made, not one from last night's session.
const runUndoable = (message, mutate) => {
const snapshot = snapshotState();
const snapshot = JSON.parse(JSON.stringify(state));
mutate();
// Starting or settling a hand invalidates the per-action history: those
// snapshots describe a hand that's over. The whole-step Undo below replaces it.
clearActionHistory();
hooks.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
restoreState(snapshot);
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
hooks.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
});
};
// A seat that has anything of a player in it — a name, chips, cash in, or a hand
// in progress. Shrinking the table throws these away permanently, and it also
// orphans them in the session standings, where they keep their winnings but lose
// the stack and buy-in the settle-up is worked out from.
const seatHasSomeoneIn = player =>
!!(player.name && player.name.trim()) ||
player.inHand ||
(Number(player.stack) || 0) > 0 ||
(Number(player.buyIns) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0;
document.getElementById("seatCountInput").addEventListener("change", async e => {
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
const losing = state.seats.slice(requested).filter(seatHasSomeoneIn);
if (losing.length > 0) {
const who = losing
.map((p, i) => HoldemState.displayName(p, requested + state.seats.slice(requested).indexOf(p)))
.join(", ");
const ok = await Dialog.confirm({
title: `Remove ${losing.length} ${losing.length === 1 ? "player" : "players"}?`,
message: `Going down to ${requested} seats takes ${who} off the table along with their chips and buy-ins, and their session totals stop adding up.`,
confirmLabel: "Remove them",
danger: true,
});
if (!ok) {
e.target.value = state.seatCount;
return;
}
}
runUndoable(`Table set to ${requested} seats.`, () => HoldemState.resizeSeats(state, requested));
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
Render.renderAll();
});
document.getElementById("directionSelect").addEventListener("change", e => {
@@ -346,35 +152,17 @@ document.addEventListener("DOMContentLoaded", () => {
Render.renderAll();
});
document.getElementById("nextHandBtn").addEventListener("click", async () => {
// Moving on wipes every seat's contribution, so an unresolved pot is simply
// destroyed — measured at a four-handed table, pressing this with $8 in the
// middle took the table from 800 chips to 792, and the toast said nothing
// about it. Chips only ever leave this tool because a dealer meant them to.
const unresolved = HoldemState.unresolvedContributions(state);
if (unresolved > Betting.EPSILON_CHIPS) {
const ok = await Dialog.confirm({
title: `Throw away ${Money.format(unresolved)}?`,
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
confirmLabel: "Throw it away",
danger: true,
});
if (!ok) return;
}
document.getElementById("nextHandBtn").addEventListener("click", () => {
// 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}`;
const discarded = unresolved > Betting.EPSILON_CHIPS
? ` ${Money.format(unresolved)} taken off the table.`
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
HoldemState.startNextHand(state);
Betting.beginHand(state);
});
});
@@ -389,7 +177,6 @@ document.addEventListener("DOMContentLoaded", () => {
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
clearActionHistory();
Render.mount(state, hooks);
Toast.show("Table reset.", { duration: 2500 });
});
@@ -410,24 +197,6 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
document.getElementById("addChipsOverlay").addEventListener("click", e => {
if (e.target.id === "addChipsOverlay") AddChips.close();
});
document.getElementById("addChipsInput").addEventListener("keydown", e => {
if (e.key === "Enter") AddChips.confirm();
});
// 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; });
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
@@ -1,147 +0,0 @@
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
//
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
// confirm — opened and closed by five different modules, and each one was missing
// the same three things: focus never moved into the dialog, Tab walked straight out
// of it into the page behind the scrim, and Escape did nothing (only the rulings
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
// 1 of 5 closed on Escape.
//
// Rather than patch that into five modules, this watches the `hidden` attribute every
// overlay already toggles. Nothing else had to change, and an overlay added later
// gets the same behaviour without anyone remembering to wire it up — which is the
// failure this is fixing in the first place.
const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop.
FOCUSABLE: [
"a[href]",
"button:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(","),
open: [],
mount() {
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
// table-level shortcut sees them.
document.addEventListener("keydown", e => this.onKeyDown(e), true);
},
watch(overlay) {
const sync = () => {
const isOpen = !overlay.hidden;
const known = this.open.some(entry => entry.overlay === overlay);
if (isOpen && !known) this.onOpened(overlay);
else if (!isOpen && known) this.onClosed(overlay);
};
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
sync();
},
sheetOf(overlay) {
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
},
focusableIn(overlay) {
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
if (el.closest("[hidden]")) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
},
onOpened(overlay) {
const sheet = this.sheetOf(overlay);
// aria-modal is only true while the thing is actually open, so it's set here
// rather than baked into the markup.
if (sheet) sheet.setAttribute("aria-modal", "true");
this.open.push({ overlay, returnFocus: document.activeElement });
// Focus the first real control. A dialog that leaves focus on the button
// behind it reads, to a screen reader, as though nothing happened at all.
// Content-only sheets get focus on the sheet so the title is announced.
const focusable = this.focusableIn(overlay);
const target = focusable[0];
if (target) {
target.focus({ preventScroll: true });
} else if (sheet) {
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
sheet.focus({ preventScroll: true });
}
},
onClosed(overlay) {
const index = this.open.findIndex(entry => entry.overlay === overlay);
if (index === -1) return;
const [entry] = this.open.splice(index, 1);
const sheet = this.sheetOf(overlay);
if (sheet) sheet.removeAttribute("aria-modal");
// Put the dealer back where they were. Without this, closing the pot wizard
// drops focus to the top of the document and the next Tab starts from the
// page header.
const back = entry.returnFocus;
if (back && back.isConnected && typeof back.focus === "function") {
back.focus({ preventScroll: true });
}
},
topmost() {
return this.open.length ? this.open[this.open.length - 1] : null;
},
// Escape presses the dialog's own cancel/close button rather than just hiding the
// overlay, so whatever that button does — resolving the confirm promise, clearing
// the add-chips draft — still happens. Only if there isn't one does it hide the
// overlay directly.
dismiss(overlay) {
const closer = overlay.querySelector("[data-modal-close]");
if (closer) closer.click();
else overlay.hidden = true;
},
onKeyDown(event) {
const top = this.topmost();
if (!top) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismiss(top.overlay);
return;
}
if (event.key !== "Tab") return;
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
// mouse, so letting the keyboard wander back there is a trap with no way out.
const focusable = this.focusableIn(top.overlay);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
event.preventDefault();
first.focus({ preventScroll: true });
}
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Modal;
}
@@ -1,51 +1,10 @@
// Shared money handling for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull it in without dragging the whole
// seat/chip UI along with it.
//
// Everything the dealer touches is money, and money is decimal, so every module used
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
// sixteen times in six files, which is sixteen chances for one of them to drift.
// It lives here now, and the modules that do arithmetic call these instead.
// Shared money formatting for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull in the formatter without dragging
// the whole seat/chip UI along with it.
const Money = {
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
// exact `> 0` would treat that player as still having chips to bet.
EPSILON: 0.004,
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
},
fromCents(cents) {
return cents / 100;
},
round(amount) {
return this.toCents(amount) / 100;
},
// Cents only when there are cents.
//
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
// is two characters that say nothing — measured at ~22px, against the 64px a
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
// 25c-blind game reads exactly as it did.
format(amount) {
const value = Number(amount) || 0;
const hasCents = this.toCents(value) % 100 !== 0;
return `$${value.toLocaleString(undefined, {
minimumFractionDigits: hasCents ? 2 : 0,
maximumFractionDigits: 2,
})}`;
},
// The same amount for a spreadsheet rather than for a person: no currency symbol
// and no thousands separators, because those are what make a column of money
// import as text and refuse to add up.
forSheet(amount) {
return (this.toCents(amount) / 100).toFixed(2);
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
@@ -4,7 +4,7 @@
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
// that player put in the pot and is whittled down round by round as money gets paid out.
const PotCalculator = {
EPSILON: Money.EPSILON,
EPSILON: 0.004,
// Builds the initial entry list from raw contributions, keeping folded players in
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
@@ -33,39 +33,24 @@ const PotCalculator = {
// to their own contribution, and only players who reached that height can win it.
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
layers(players) {
// Uncalled chips were never played for, so they come off the top before any
// pot is built. Deriving the pots from what's left is what keeps this function
// and uncalledReturns() from disagreeing about the same money — they used to
// decide "uncontested" separately, and an overbet that its own bettor was
// still live for got announced as a side pot only they could win.
const returned = new Map(this.uncalledReturns(players).map(r => [r.id, Money.toCents(r.amount)]));
const live = players
.map(p => ({ ...p, cents: Money.toCents(p.contribution) - (returned.get(p.id) || 0) }))
.filter(p => p.cents > 0);
const live = players.filter(p => p.contribution > this.EPSILON);
if (live.length === 0) return [];
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
const layers = [];
let floor = 0;
heights.forEach(height => {
const band = height - floor;
const payers = live.filter(p => p.cents >= height);
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
const amount = band * payers.length;
const eligible = payers.filter(p => !p.folded).map(p => p.id);
if (amount > 0) {
if (eligible.length > 0) {
layers.push({ amount: Money.fromCents(amount), eligible });
} else if (layers.length > 0) {
// Everyone who reached this height folded, so it isn't a pot of its
// own — it's dead money owed to whoever wins the layer underneath.
// Dropping it would quietly shrink the announced pot.
const below = layers[layers.length - 1];
below.amount = Money.round(below.amount + Money.fromCents(amount));
}
// With no layer underneath either, nobody in the hand is live at all.
// That's a fold flag the dealer needs to fix, not a payout to guess at.
// A band nobody can win isn't a pot — it's an overbet that never got
// called, and it goes back to whoever put it in. Announcing it as a side
// pot would overstate what's actually being played for.
if (amount > 0 && eligible.length > 0) {
layers.push({ amount: amount / 100, eligible });
}
floor = height;
});
@@ -81,7 +66,7 @@ const PotCalculator = {
previous.eligible.every(id => layer.eligible.includes(id));
if (sameEligibility) {
previous.amount = Money.round(previous.amount + layer.amount);
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
} else {
merged.push({ ...layer });
}
@@ -94,28 +79,21 @@ const PotCalculator = {
},
// Chips nobody could call, which the dealer pushes back rather than into the pot.
//
// A bet is only called up to what somebody *else* was willing to put in, so the
// ceiling for each player is the largest contribution among everyone but them.
// Measuring against the whole table instead — including the bettor's own chips —
// makes the biggest bet its own yardstick, so the one case this exists for (you
// shove, a short stack calls for less) comes back empty.
// Anything above the biggest live contribution was never contested — most often an
// overbet by someone who then folded.
uncalledReturns(players) {
const contributing = players.filter(p => p.contribution > this.EPSILON);
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
// Nobody live at all means the fold flags are wrong, not that every chip on
// the table was uncalled. That's a correction for the dealer to make, so say
// nothing here and let the wizard ask them to fix it.
if (contributing.filter(p => !p.folded).length === 0) return [];
if (liveCents.length === 0) return [];
const cents = contributing.map(p => Money.toCents(p.contribution));
const cap = Math.max(...liveCents);
return contributing
.map((p, index) => {
const others = cents.filter((_, i) => i !== index);
const cap = others.length > 0 ? Math.max(...others) : 0;
return { id: p.id, amount: Money.fromCents(cents[index] - cap) };
})
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
.filter(r => r.amount > this.EPSILON);
},
@@ -135,12 +113,12 @@ const PotCalculator = {
if (e.remaining <= this.EPSILON) return e;
const used = Math.min(e.remaining, cap);
payoutPool += used;
return { ...e, remaining: Money.round(e.remaining - used) };
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
});
payoutPool = Money.round(payoutPool);
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length;
payoutPool = Math.round(payoutPool * 100) / 100;
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
const perWinner = winnerIds
.filter(id => winners.some(w => w.id === id))
@@ -155,7 +133,7 @@ const PotCalculator = {
i++;
}
const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) }));
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
const remainingContenders = this.getContenders(updatedEntries);
return {
@@ -18,22 +18,10 @@ const PotWizard = {
this.playerLabelById = new Map(
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
);
this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
// The label carries name and cards together for the log and the payout lines;
// the choice rows want them separately so each can be styled and read on its own.
this.nameById = new Map(
players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
);
this.cardsById = new Map(
players.map(p => [p.id, (p.cards || []).filter(Boolean)])
);
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
// A seat switched off mid-hand still has its chips in the pot, but it
// can no more win them than a folded one can — same dead money, and the
// wizard must not offer it as a winner.
folded: p.folded || HoldemState.isDeadMoney(p),
folded: p.folded,
}));
this.entries = PotCalculator.buildEntries(contributions);
// Kept so the wizard can show the whole breakdown up front, not just the layer
@@ -61,20 +49,6 @@ const PotWizard = {
this.totals.set(id, (this.totals.get(id) || 0) + amount);
},
// A chopped pot rarely divides exactly, and the odd chip goes to the first player
// left of the button — not to whoever the dealer happened to tick first. The
// calculator hands leftovers out in the order it's given the winners, so ordering
// them here is what makes the payout match the rule.
orderFromButton(ids) {
const distance = new Map();
for (let step = 1; step <= this.state.seatCount; step++) {
distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
}
return [...ids].sort((a, b) =>
(distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
);
},
renderStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
@@ -131,46 +105,13 @@ const PotWizard = {
contenders.forEach(c => {
const label = document.createElement("label");
label.className = "potWizardChoice";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
});
label.appendChild(checkbox);
const text = document.createElement("span");
text.className = "potWizardChoiceText";
const name = document.createElement("span");
name.className = "potWizardChoiceName";
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
text.appendChild(upTo);
text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
label.appendChild(checkbox);
label.appendChild(text);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
const cards = this.cardsById.get(c.id) || [];
if (cards.length > 0) {
const cardsEl = document.createElement("span");
cardsEl.className = "potWizardChoiceCards";
cards.forEach((card, i) => {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (/[HD]$/i.test(card)) span.className = "cardRed";
span.textContent = Render.formatCard(card);
cardsEl.appendChild(span);
});
label.appendChild(cardsEl);
}
choices.appendChild(label);
});
round.appendChild(choices);
@@ -186,7 +127,7 @@ const PotWizard = {
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(selected));
const result = PotCalculator.resolveRound(this.entries, selected);
this.recordRound(result);
this.entries = result.updatedEntries;
this.renderStep();
@@ -272,25 +213,11 @@ const PotWizard = {
});
actions.appendChild(applyBtn);
// This button cancels — it does not pay anybody. Under a heading reading
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
// something already done, and a dealer who taps it walks away believing the
// pot has been pushed. It hasn't: the chips are still in the middle and the
// hand isn't logged. So it says what it does.
const cancelBtn = document.createElement("button");
cancelBtn.className = "holdemBtn holdemBtnGhost";
cancelBtn.textContent = "Cancel — don't pay yet";
cancelBtn.addEventListener("click", () => this.close());
actions.appendChild(cancelBtn);
if (this.totals.size > 0) {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(actions);
wrap.appendChild(note);
return wrap;
}
const closeBtn = document.createElement("button");
closeBtn.className = "holdemBtn holdemBtnGhost";
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => this.close());
actions.appendChild(closeBtn);
wrap.appendChild(actions);
return wrap;
@@ -15,28 +15,12 @@ const QuickSplit = {
ways: 2,
chip: 1,
// "even" is the common case — one pot, everyone matched. "uneven" is the one this
// screen used to send people back to the full table for: somebody all-in for less,
// so the money splits into a main pot and side pots.
mode: "even",
entries: [],
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
this.entries = [{ amount: "", folded: false }, { amount: "", folded: false }, { amount: "", folded: false }];
this.renderWayChoices();
this.renderChipChoices();
this.renderUnevenRows();
document.getElementById("modeEvenBtn").addEventListener("click", () => this.setMode("even"));
document.getElementById("modeUnevenBtn").addEventListener("click", () => this.setMode("uneven"));
document.getElementById("addPlayerBtn").addEventListener("click", () => {
this.entries.push({ amount: "", folded: false });
this.renderUnevenRows();
this.update();
});
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
@@ -143,112 +127,7 @@ const QuickSplit = {
});
},
setMode(mode) {
this.mode = mode;
document.getElementById("modeEvenBtn").classList.toggle("selected", mode === "even");
document.getElementById("modeUnevenBtn").classList.toggle("selected", mode === "uneven");
document.querySelectorAll(".evenOnly").forEach(el => { el.hidden = mode !== "even"; });
document.querySelectorAll(".unevenOnly").forEach(el => { el.hidden = mode !== "uneven"; });
document.getElementById("splitResult").hidden = mode !== "even";
document.getElementById("unevenResult").hidden = mode !== "uneven";
this.update();
},
renderUnevenRows() {
const host = document.getElementById("unevenRows");
const template = document.getElementById("unevenRowTemplate");
host.innerHTML = "";
this.entries.forEach((entry, index) => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".unevenRowLabel").textContent = `P${index + 1}`;
const amount = row.querySelector(".unevenAmount");
amount.value = entry.amount;
amount.setAttribute("aria-label", `Player ${index + 1} amount`);
amount.addEventListener("input", () => {
entry.amount = amount.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
if (entry.amount !== amount.value) amount.value = entry.amount;
this.update();
});
// Folded money stays in the pot as dead money, but its owner can't win any
// of it — which is exactly the distinction that makes side pots tricky.
const fold = row.querySelector(".unevenFoldBtn");
fold.classList.toggle("selected", entry.folded);
fold.setAttribute("aria-pressed", String(entry.folded));
fold.addEventListener("click", () => {
entry.folded = !entry.folded;
this.renderUnevenRows();
this.update();
});
const remove = row.querySelector(".unevenRemoveBtn");
remove.disabled = this.entries.length <= 2;
remove.addEventListener("click", () => {
this.entries.splice(index, 1);
this.renderUnevenRows();
this.update();
});
host.appendChild(row);
});
},
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
.filter(p => p.contribution > 0);
const pots = PotCalculator.layers(players);
const returns = PotCalculator.uncalledReturns(players);
const host = document.getElementById("unevenPots");
const empty = document.getElementById("unevenEmpty");
host.innerHTML = "";
if (pots.length === 0 && returns.length === 0) {
empty.hidden = false;
empty.textContent = players.length === 0
? "Enter what each player put in."
: "Nobody left who can win — un-fold whoever should take it.";
return;
}
empty.hidden = true;
const addRow = (label, amount, who, className) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
name.className = "unevenPotLabel";
name.textContent = label;
const value = document.createElement("span");
value.className = "unevenPotAmount";
value.textContent = Money.format(amount);
const eligible = document.createElement("span");
eligible.className = "unevenPotWho";
eligible.textContent = who;
row.appendChild(name);
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
addRow("On the table", Money.round(total), "", "unevenPotTotal");
},
update() {
if (this.mode === "uneven") {
this.updateUneven();
return;
}
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
+24 -288
View File
@@ -16,10 +16,6 @@ const Render = {
this.state = state;
this.hooks = hooks;
this.selectedSeatIndex = null;
// A reset hands over a different table entirely, so nothing cached about the
// last one still applies.
this.lastHistorySignature = null;
this.handTimeCache.clear();
this.renderAll();
},
@@ -73,85 +69,13 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
return this.positionOnRing(index, count, 44, 42);
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
betPosition(index, count) {
return this.positionOnRing(index, count, 27, 25);
},
positionOnRing(index, count, rx, ry) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
return {
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
},
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
// gets bets that look like the chips actually being pushed out.
chipColorFor(amount) {
const legend = [...(this.state.chipLegend || [])]
.filter(chip => Number(chip.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
const match = legend.find(chip => amount >= Number(chip.value));
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
},
// ---- Bets on the felt ----
makeBetChip(bet) {
const chip = document.createElement("div");
chip.className = "betChip";
const disc = document.createElement("span");
disc.className = "betChipDisc";
disc.style.background = this.chipColorFor(bet);
const amount = document.createElement("span");
amount.className = "betChipAmount";
amount.textContent = Money.format(bet);
chip.appendChild(disc);
chip.appendChild(amount);
return chip;
},
// A phone's felt has no room for a third ring: measured every radius between the
// seats and the pot badge and each one collides with one or the other. So on a
// narrow screen the bet clings to its own puck instead — same chip, no ring —
// sitting on whichever side faces the middle of the table.
renderBets() {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
const pos = this.betPosition(index, this.state.seatCount);
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
const seatPos = this.seatPosition(index, this.state.seatCount);
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
seat.appendChild(chip);
return;
}
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
layer.appendChild(chip);
});
const rx = 44;
const ry = 42;
const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians);
return { left, top };
},
puckLabel(player, seatIndex) {
@@ -162,7 +86,7 @@ const Render = {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -171,51 +95,20 @@ 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");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
dealerChip.classList.toggle("active", index === dealerIndex);
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}`;
const chipLabel = hasButton
? `${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.
dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.hooks.onGiveButton(index);
this.state.dealerSeatIndex = index;
this.hooks.save(this.state);
this.renderSeats();
});
const sbBadge = node.querySelector(".sbBadge");
const bbBadge = node.querySelector(".bbBadge");
sbBadge.classList.toggle("visible", index === sbIndex);
bbBadge.classList.toggle("visible", index === bbIndex);
// A blind the dealer named by hand isn't where the button would have put
// it, so it's marked — otherwise the table looks like it's just wrong.
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
// blinds are positional, and classList.toggle with an undefined second
// argument *flips* the class instead of clearing it, so the marker
// appeared and disappeared on alternate renders.
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -225,8 +118,6 @@ const Render = {
layer.appendChild(node);
});
this.renderBets();
},
isRedSuit(card) {
@@ -277,30 +168,18 @@ const Render = {
span.textContent = this.formatCard(card);
cardsEl.appendChild(span);
});
// Always this player's total for the hand. What they've just pushed out on this
// street is shown as a chip on the felt instead — the puck used to flip between
// the two meanings depending on the moment, which is a bad thing for a number
// sitting next to a stack size to do.
const handTotal = Betting.handTotal(player);
const total = handTotal > 0
? handTotal
// What's in front of them this street reads as the live number during betting;
// between hands it falls back to their total for the hand.
const streetBet = Betting.committed(player);
const total = streetBet > 0
? streetBet
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
const totalEl = node.querySelector(".seatPuckTotal");
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
// 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);
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);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
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);
@@ -308,17 +187,6 @@ const Render = {
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
// because the number has no spaces to break on, it doesn't wrap or ellipsise
// either — it just draws straight out over the neighbouring seat. Stepping the
// font down keeps every digit, which matters: a dealer misreading a stack is a
// worse outcome than a small one, so this shrinks rather than abbreviating.
fitAmount(el) {
const length = (el.textContent || "").length;
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
el.classList.toggle("puckAmountHuge", length >= 10);
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
@@ -547,13 +415,6 @@ const Render = {
});
});
// What they've handed over tonight, shown next to what's in front of them so a
// cash-out is one subtraction rather than a memory test.
const boughtIn = Number(player.buyIns) || 0;
const boughtInEl = form.querySelector(".seatBuyIns");
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
boughtInEl.hidden = boughtIn <= 0;
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
@@ -728,153 +589,30 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
// What they'd actually be handed if the night ended now: chips in front of
// them less the cash they gave the banker. This was computed and written to
// the CSV all along, but never shown to the person doing the paying out.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
cashEl.classList.toggle("netDown", row.cashPosition < 0);
list.appendChild(node);
});
this.renderBankReconciliation();
this.renderSettleUp(standings);
},
// 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 {
note.textContent =
`${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
// it's the question that decides whether the last two players can be paid.
renderBankReconciliation() {
const host = document.getElementById("bankReconcile");
if (!host) return;
const bank = HoldemState.bankReconciliation(this.state);
// Nothing to reconcile before anybody has handed over money or taken a chip.
host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
if (host.hidden) return;
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
verdict.textContent = "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.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
},
// ---- Hand counter + history ----
// toLocaleTimeString is the single most expensive call in the history panel, and a
// logged hand's timestamp never changes, so the answer is worth keeping.
handTimeCache: new Map(),
formatHandTime(timestamp) {
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
let formatted = "";
try {
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
formatted = "";
return "";
}
this.handTimeCache.set(timestamp, formatted);
return formatted;
},
// Cheap stand-in for "has the log changed". Entries are immutable once written and
// only ever added at the front, so the length, the counter and the newest row
// between them catch a recorded hand, a cleared history and an undo.
historySignature() {
const history = this.state.handHistory || [];
const newest = history[0];
return [
history.length,
this.state.handNumber || 1,
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
].join("|");
},
// 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
// is skipped unless it actually did change. Everything else on the table reflects
// the current hand and still redraws every time.
renderHandHistory(force = false) {
const signature = this.historySignature();
if (!force && signature === this.lastHistorySignature) return;
this.lastHistorySignature = signature;
renderHandHistory() {
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
list.innerHTML = "";
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = 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 => {
const row = template.content.firstElementChild.cloneNode(true);
@@ -885,10 +623,8 @@ const Render = {
const payoutsEl = row.querySelector(".handHistoryPayouts");
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
fragment.appendChild(row);
list.appendChild(row);
});
list.innerHTML = "";
list.appendChild(fragment);
},
};
@@ -32,10 +32,6 @@ const SeatMenu = {
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
@@ -45,24 +41,6 @@ const SeatMenu = {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
// Naming a blind by hand. Only offered while nothing but the blinds are in —
// once somebody has called, moving a blind would mean unpicking a bet that
// has already been answered, and that's a decision for the dealer's mouth,
// not a menu item. Which blind a seat already has is shown rather than
// offered again.
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
const blinds = HoldemState.getBlindSeats(this.state);
if (blinds.sbIndex !== index) {
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
}
if (blinds.bbIndex !== index) {
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
}
if (this.state.blindOverride) {
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
}
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
@@ -1,66 +0,0 @@
// 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;
}
@@ -1,80 +0,0 @@
// Keyboard control for a dealer running this on a laptop.
//
// The phone has gestures; the desktop had a mouse and nothing else. These are the
// same actions the bar already offers, reachable without moving a hand to the
// trackpad between every decision — which is the whole point when the bar is being
// tapped thirty times a hand.
const Shortcuts = {
// Deliberately single letters with no modifier: a dealer's spare hand rests on
// the keyboard, and a chord is slower than reaching for the button would be.
KEYS: {
f: "fold",
c: "checkOrCall",
r: "raise",
a: "allIn",
" ": "advance",
enter: "advance",
u: "undo",
escape: "cancel",
},
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
},
// Nothing fires while the dealer is typing a name, an amount, or a note — the
// "c" in "Cass" must not fold the table.
isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
},
// An overlay owns the keyboard while it's up. Asking the document which overlays
// are open, rather than listing them, means a sheet added later can't quietly
// start letting "f" fold the table behind it — the same reason Modal watches for
// them instead of being told about each one.
overlayOpen() {
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;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
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;
if (this.overlayOpen()) return;
const handled = this.handlers[action] && this.handlers[action]();
if (handled) e.preventDefault();
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Shortcuts;
}
@@ -7,7 +7,7 @@
const SplitCalculator = {
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
toCents(amount) {
return Money.toCents(amount);
return Math.round((Number(amount) || 0) * 100);
},
split(potAmount, ways, chipAmount) {
@@ -40,11 +40,11 @@ const SplitCalculator = {
// Sanity check for the UI: what the split actually hands out has to equal the pot.
accountedFor(result) {
return Money.fromCents(
Money.toCents(result.each) * result.players +
Money.toCents(result.chip) * result.oddChips +
Money.toCents(result.dust)
);
return (
Math.round(result.each * 100) * result.players +
Math.round(result.chip * 100) * result.oddChips +
Math.round(result.dust * 100)
) / 100;
},
};
+12 -264
View File
@@ -31,8 +31,6 @@ const HoldemState = {
stack: null,
committed: 0,
hasActed: false,
// Cash handed over tonight, which only moves when they buy chips.
buyIns: 0,
};
},
@@ -58,26 +56,6 @@ 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,
};
},
@@ -93,27 +71,14 @@ const HoldemState = {
// 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"]
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
// means the betting was open when they closed the tab.
if (parsed.bettingOpen === undefined) {
parsed.bettingOpen =
parsed.actingSeatIndex !== null && parsed.actingSeatIndex !== undefined ||
parsed.seats.some(p =>
(Number(p.committed) || 0) > 0 || (Number(p.amountOverride) || 0) > 0);
}
parsed.seats.forEach(player => {
if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0;
if (player.hasActed === undefined) player.hasActed = false;
if (player.buyIns === undefined) player.buyIns = 0;
});
return parsed;
@@ -156,14 +121,9 @@ const HoldemState = {
},
// Contribution the table calculator will treat this player as having put in.
//
// Chips already pushed into the middle count whether or not the player is still
// in the hand. Someone who storms off, gets taken out by the dealer, or is
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
// how those chips used to disappear out of the pot the instant a seat was
// switched off, quietly shrinking the payout by whatever they had posted.
computeContribution(player, chipLegend, basePotAmount) {
if (!player.inHand) return 0;
const override = player.amountOverride;
if (override !== null && override !== undefined && String(override).trim() !== "") {
const parsed = Number(override);
@@ -176,54 +136,10 @@ const HoldemState = {
}, 0);
if (chipTotal > 0) return chipTotal;
// The table-wide default is the one thing that stays tied to being dealt in:
// it's a stand-in for "everyone put the same amount in", so applying it to
// empty chairs would invent a contribution from every seat nobody is using.
if (!player.inHand) return 0;
const base = Number(basePotAmount);
return Number.isNaN(base) ? 0 : Math.max(0, base);
},
// Anyone whose chips are in the pot, whether or not they're still in the hand.
// A seat that's been switched off mid-hand is dead money: it counts towards the
// pot but can never be picked as a winner, exactly like a fold.
isDeadMoney(player) {
return !player.inHand;
},
// What starting the next hand would take off the table for good: exactly the
// fields startNextHand clears.
//
// Deliberately not totalPot(). The table-wide base pot is a standing ante — a
// stand-in for "everyone puts the same in every hand", used by a dealer who
// isn't tracking stacks — so it re-applies to the next hand rather than being
// lost with this one. Counting it would mean warning about money that isn't
// going anywhere, on every single hand, for exactly the dealers who chose the
// simplest way to run the table.
unresolvedContributions(state) {
const total = state.seats.reduce((sum, player) => {
const override = player.amountOverride;
let banked = 0;
if (override !== null && override !== undefined && String(override).trim() !== "") {
const parsed = Number(override);
if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
}
if (banked === 0) {
banked = (state.chipLegend || []).reduce((chips, chip) => {
const count = Number(player.chipCounts[chip.id]) || 0;
return chips + count * Number(chip.value || 0);
}, 0);
}
return sum + banked + Math.max(0, Number(player.committed) || 0);
}, 0);
return Money.round(total);
},
totalPot(state) {
return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
@@ -240,117 +156,18 @@ const HoldemState = {
return ((fromIndex + delta) % count + count) % count;
},
// The button passes to the next player, not the next chair. Moving it one seat at
// a time parks it on empty chairs — with four players spread around ten seats it
// 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.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
}
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
// Seats dealt into this hand, in blind order starting from the seat after the
// button. Blinds follow the players, not the chairs — the seats immediately left
// of the button are empty often enough that counting chairs posts them to nobody.
liveOrderFromButton(state) {
const order = [];
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) order.push(index);
}
return order;
},
// A button sitting on an empty chair is only cosmetic until you try to read
// 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.
// Where the button will be when the hand is dealt — without moving it.
//
// Kept separate from ensureButtonOnPlayer because the deal preview has to be able
// to ask the question without answering it. It couldn't, and so the preview lied:
// with the button parked on an empty chair it promised one pair of blinds and
// dealing produced another, because beginHand relocates the button first and then
// works out who posts.
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) 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
// dealer who names a blind and then deals that player out shouldn't have the
// table quietly post to nobody.
validBlindOverride(state) {
const o = state.blindOverride;
if (!o) return null;
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
return o;
},
// 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) {
const live = this.liveOrderFromButton(state);
const dealerIndex = state.dealerSeatIndex;
// A blind the dealer named by hand wins over anything positional. Heads-up is
// still reported honestly so the betting order stays right — who posts what
// is the dealer's call, but two-handed play acting in reverse is a rule.
const override = this.validBlindOverride(state);
if (override) {
return {
dealerIndex,
sbIndex: override.sbIndex,
bbIndex: override.bbIndex,
headsUp: live.length === 2,
manual: true,
};
}
if (live.length < 2) {
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
}
if (live.length === 2) {
const buttonIsPlaying = state.seats[dealerIndex].inHand;
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
const bbIndex = live.find(i => i !== sbIndex);
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
},
startNextHand(state) {
this.advanceButton(state);
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// A hand-picked blind applies to the hand it was picked for. Carrying it into
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
state.blindOverride = null;
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -359,8 +176,6 @@ const HoldemState = {
p.committed = 0;
p.hasActed = false;
});
// Anyone who joined mid-hand is a full player again from here.
this.ensureButtonOnPlayer(state);
},
// Appends a resolved-pot record to the running session log a dealer can point to
@@ -421,82 +236,15 @@ const HoldemState = {
});
});
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
// Anyone who has money in the game belongs in the settle-up, whether or not
// they've been in a logged hand yet. Building the list from the hand log alone
// left a player who bought in and then sat through a few hands folding —
// or who just sat down — missing entirely, so the banker's own record didn't
// mention the cash they'd taken. They turn up at cash-out holding chips the
// list never accounted for.
state.seats.forEach((player, index) => {
if (totals.has(player.id)) return;
const hasMoneyInPlay =
(Number(player.buyIns) || 0) > 0 ||
(Number(player.stack) || 0) > 0 ||
(Number(player.amountOverride) || 0) > 0 ||
(Number(player.committed) || 0) > 0;
if (!hasMoneyInPlay) return;
totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
});
return Array.from(totals.values())
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
// and blinds are posted the instant the previous one settles, so that
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
return {
...t,
in: Money.round(t.in),
won: Money.round(t.won),
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
};
})
.map(t => ({
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
}))
.sort((a, b) => b.net - a.net);
},
// 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
// play add up to more than the cash that came in, that promise can't be kept —
// and the way you find out is the last two players cashing out and the box being
// empty. It's always someone taking chips without paying, a rebuy that never got
// recorded, or a miscount, and it's silent until it's too late to work out which.
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
(sum, p) =>
sum +
Math.max(0, Number(p.stack) || 0) +
Math.max(0, Number(p.committed) || 0) +
Math.max(0, Number(p.amountOverride) || 0),
0
);
const difference = Money.round(chipsInPlay - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
};
},
};
if (typeof module !== "undefined" && module.exports) {
-83
View File
@@ -1,83 +0,0 @@
# Hold'em dealer tests
Unit tests for the money logic behind `/Casino/TexasHoldem` — the betting engine, the
side-pot calculator, the split calculator and the table state.
```bash
node --test tests/holdem/*.test.js
```
No install step, no `package.json`, no dependencies. The browser modules are plain
script-tag files that each end with a `module.exports` guard, so Node can require them
as-is; `load.js` puts them on `globalThis` under the same names they use to find each
other in the page, in the load order from `TexasHoldem.cshtml`.
## What's covered
| File | Covers |
| --- | --- |
| `potCalculator.test.js` | Main and side pots, dead money, uncalled bets, odd-cent distribution |
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
| `state.test.js` | Button rotation, which seats post blinds, session standings |
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
## The layout audit
The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
in a browser: whether a dealer can actually reach every control.
```bash
node tests/holdem/runUiAudit.js # needs Playwright and the site running
```
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
phones on their side.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
call `LayoutAudit.runAll()`.
The contrast check is built so it cannot report a false positive: backgrounds
composite through alpha, and where a gradient is involved every colour stop is tried
and the most flattering one used. A finding means the text fails even under the
reading most generous to it. Two earlier attempts at this got it wrong in both
directions — one read only `background-color` and scored text on a gradient against
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
against known-answer cases before it is believed.
Touch targets are measured by probing outward with `elementFromPoint` rather than by
reading the element box, because the box lies in both directions: a small control can
be padded out with a transparent `::after` and respond well beyond its painted size,
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
enforced where `hover: none` says there's no mouse.
It exists because these checks were being re-typed into a console every time
something changed, and each time they only covered whatever had just been touched.
That is how the standings table shipped with a player-name column zero pixels wide
and kept it for two rounds.
## Why the randomised suite exists
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
to empty chairs, the button skipping past players, heads-up acting out of order, an
overbet announced as a side pot. None of them show up on a full six-handed table with
equal stacks, which is the table anyone writes a fixed example for.
`invariants.test.js` deals a thousand hands across random seat counts, random empty
chairs, random stack depths and random dealer input, and checks the things a dealer
would notice being wrong:
- chips are never created or destroyed during play
- the pot breakdown plus the uncalled returns always equal the money that went in
- paying the pot out settles it to the cent
- once uncalled chips are handed back, nobody is still betting above the whole table
- the button and both blinds always land on players who are actually in the hand
The generator is seeded, so a failure reproduces from the seed printed in the message
rather than being a flake to re-run.
-98
View File
@@ -1,98 +0,0 @@
// 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);
});
-169
View File
@@ -1,169 +0,0 @@
// The banker's job, which is a different job from the dealer's: every chip on the
// table is a promise to pay cash for it later, and somebody has to be able to check
// that the promise can be kept before the last two players try to cash out.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, Money, makeTable } = require("./load");
const seat = (state, index, name, cash, chips) => {
const p = state.seats[index];
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
return p;
};
describe("everyone with money in the game is accounted for", () => {
test("a player who has bought in but played no logged hand still appears", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 200, 200); // just sat down
state.seats[0].amountOverride = 10;
state.seats[1].amountOverride = 10;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
state.seats[0].amountOverride = null;
state.seats[1].amountOverride = null;
const names = HoldemState.sessionStandings(state).map(r => r.name);
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
});
test("their row reads as no hands played, not as a loss", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 200, 200);
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
assert.equal(cal.in, 0);
assert.equal(cal.won, 0);
assert.equal(cal.net, 0);
assert.equal(cal.boughtIn, 200);
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
});
test("an empty chair is not dragged into the settle-up", () => {
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
seat(state, 0, "Ann", 100, 100);
const rows = HoldemState.sessionStandings(state);
assert.equal(rows.length, 1);
});
test("somebody who has cashed out completely still shows what they took", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 0); // handed every chip back
seat(state, 1, "Ben", 100, 200);
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
});
});
describe("does the cash box cover the chips on the table", () => {
test("a straight game balances", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 60);
seat(state, 1, "Ben", 100, 140);
seat(state, 2, "Cal", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 300);
assert.equal(bank.difference, 0);
assert.equal(bank.balanced, true);
});
test("chips outrunning the cash box is caught before cash-out", () => {
// The 2am failure: the last players out can't be paid.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 460);
assert.equal(bank.difference, 160);
assert.equal(bank.balanced, false);
});
test("cash taken for chips never handed over is caught too", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
seat(state, 1, "Ben", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.difference, -100);
assert.equal(bank.balanced, false);
});
test("chips in the middle still count as chips in play", () => {
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
// would report a shortfall every time a hand was open — which is nearly always.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
Betting.beginHand(state); // blinds go in
Betting.raiseTo(state, 0, 25); // and a raise
Betting.sweepToHandTotals(state); // banked into the pot
const during = HoldemState.bankReconciliation(state);
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
assert.equal(during.chipsInPlay, before.chipsInPlay);
});
test("a whole session of play never knocks the books out", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
for (let hand = 0; hand < 6; hand++) {
Betting.beginHand(state);
Betting.liveSeats(state).forEach(s => {
if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
});
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const winner = Betting.liveSeats(state)[0];
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
}
});
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
// A proper rebuy: cash to the banker, chips to the player.
state.seats[0].buyIns += 100;
state.seats[0].stack += 100;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
// Chips taken without paying.
state.seats[1].stack += 50;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, 50);
});
test("nothing to reconcile before anybody has bought in", () => {
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 0);
assert.equal(bank.chipsInPlay, 0);
assert.equal(bank.balanced, true);
});
test("float dust is not a discrepancy", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
seat(state, 1, "Ben", 10, 10);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
-198
View File
@@ -1,198 +0,0 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, makeTable, chipsOnTable } = require("./load");
describe("Betting.beginHand", () => {
test("blinds are posted and the seat after the big blind acts first", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
assert.equal(state.seats[1].committed, 1, "small blind");
assert.equal(state.seats[2].committed, 2, "big blind");
assert.equal(state.currentBet, 2);
assert.equal(state.actingSeatIndex, 3, "under the gun");
});
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
assert.equal(state.seats[5].committed, 2);
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
});
test("heads-up, the big blind acts first once the flop is out", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
Betting.call(state, 2);
Betting.check(state, 5);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
});
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
state.seats[0].stack = 10; // can't cover the small blind
Betting.beginHand(state);
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
assert.equal(state.seats[0].committed, 10);
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
});
test("posting blinds never invents or destroys chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
});
describe("Betting street completion", () => {
test("a street ends once everyone has matched the bet and had a turn", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
Betting.check(state, 2);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("a raise reopens the action for everyone who had already called", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(state.seats[3].hasActed, true);
Betting.raiseTo(state, 1, 10);
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
assert.equal(state.seats[0].hasActed, false);
assert.equal(state.currentBet, 10);
assert.equal(Betting.isStreetComplete(state), false);
});
test("the action skips a player who is all-in but still in the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats[3].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, 3);
assert.equal(state.seats[3].stack, 0);
assert.notEqual(state.actingSeatIndex, 3);
// They are still live and can still win the pot.
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
});
test("closing a street sweeps chips into hand totals and resets the bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
Betting.check(state, 2);
const before = chipsOnTable(state);
Betting.closeStreet(state);
assert.equal(state.currentBet, 0);
assert.equal(state.street, "flop");
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(Betting.handTotal(state.seats[2]), 2);
});
});
describe("Betting all-in run-outs", () => {
test("with everyone all-in there is nobody left to ask", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.allIn(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
Betting.closeStreet(state);
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
});
test("one player with chips against an all-in cannot bet alone", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats[1].stack = 5;
Betting.beginHand(state);
Betting.fold(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
});
test("everyone folding to one player ends the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.fold(state, 3);
Betting.fold(state, 0);
Betting.fold(state, 1);
assert.equal(Betting.isHandOver(state), true);
});
});
describe("Betting money handling", () => {
test("a player can never put in more than they have", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
Betting.beginHand(state);
const moved = Betting.putIn(state, 0, 500);
assert.equal(moved, 30);
assert.equal(state.seats[0].stack, 0);
});
test("raise-to is what a dealer says out loud, not raise-by", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
assert.equal(state.seats[3].stack, 90);
});
test("the minimum raise has to match the last one", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
Betting.raiseTo(state, 3, 10);
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
});
test("winnings land back in front of the player", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
});
test("fractional blinds don't drift into floating-point dust", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
Betting.beginHand(state);
Betting.call(state, 0); // button limps
Betting.call(state, 1); // small blind completes
Betting.check(state, 2); // big blind is happy
Betting.closeStreet(state);
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
assert.equal(Betting.potSoFar(state), 0.6);
state.seats.forEach(p => assert.equal(p.stack, 9.8));
});
});
-148
View File
@@ -1,148 +0,0 @@
// 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");
});
-76
View File
@@ -1,76 +0,0 @@
// 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);
});
-205
View File
@@ -1,205 +0,0 @@
// 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);
});
});
-118
View File
@@ -1,118 +0,0 @@
// The hand-history panel is only rebuilt when its signature changes, which takes a
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
// noticing every way the log can actually change — a stale panel would have the
// dealer reading last hand's payouts while settling this one.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Render, HoldemState, makeTable } = require("./load");
// historySignature() reads Render.state, so point it at a table for each case.
const signatureOf = state => {
Render.state = state;
return Render.historySignature();
};
const tableWithHands = (count) => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
for (let i = 0; i < count; i++) {
state.seats[0].amountOverride = 2;
state.seats[1].amountOverride = 2;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
}
return state;
};
describe("the history panel knows when to redraw", () => {
test("nothing changes while a hand is being played", () => {
const state = tableWithHands(3);
const before = signatureOf(state);
// A whole street of betting touches no part of the log.
state.seats[0].committed = 5;
state.seats[1].folded = true;
state.actingSeatIndex = 2;
state.street = "flop";
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
});
test("settling a hand changes it", () => {
const state = tableWithHands(3);
const before = signatureOf(state);
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
assert.notEqual(signatureOf(state), before);
});
test("clearing the history changes it", () => {
const state = tableWithHands(5);
const before = signatureOf(state);
HoldemState.clearHistory(state);
assert.notEqual(signatureOf(state), before);
});
test("undoing back to a shorter log changes it", () => {
const state = tableWithHands(2);
const snapshot = JSON.parse(JSON.stringify(state));
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
const afterRecord = signatureOf(state);
// The undo path swaps the state's contents in place.
Object.keys(state).forEach(k => delete state[k]);
Object.assign(state, snapshot);
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
});
test("two hands that differ only in payout are told apart", () => {
const a = tableWithHands(1);
const b = tableWithHands(1);
// Same count and hand number; different pot.
b.handHistory[0].pot = a.handHistory[0].pot + 25;
assert.notEqual(signatureOf(a), signatureOf(b));
});
test("the hand counter moving on changes it", () => {
// The counter badge is drawn by the same function, so a new hand number has
// to invalidate even when the log itself is untouched.
const state = tableWithHands(2);
const before = signatureOf(state);
state.handNumber += 1;
assert.notEqual(signatureOf(state), before);
});
test("an empty log has a stable signature of its own", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1] });
const first = signatureOf(state);
assert.equal(signatureOf(state), first);
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
});
});
describe("the hand-time cache", () => {
test("the same timestamp is only formatted once, and formats the same", () => {
Render.handTimeCache.clear();
const stamp = Date.UTC(2026, 8, 1, 15, 4);
const first = Render.formatHandTime(stamp);
assert.equal(Render.handTimeCache.size, 1);
const second = Render.formatHandTime(stamp);
assert.equal(second, first);
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
});
test("different timestamps still format differently", () => {
Render.handTimeCache.clear();
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
assert.notEqual(a, b);
assert.equal(Render.handTimeCache.size, 2);
});
test("a nonsense timestamp doesn't throw", () => {
Render.handTimeCache.clear();
assert.doesNotThrow(() => Render.formatHandTime(undefined));
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
});
});
-213
View File
@@ -1,213 +0,0 @@
// Randomised whole-hand play against the invariants a dealer would notice being broken.
//
// These ran once, by hand, in a browser console while the betting engine was being
// fixed, and then went away with the tab. The bugs they caught (blinds posted to empty
// chairs, the button skipping players, heads-up acting out of order) are all cases you
// only hit on a particular table shape, so a fixed set of examples misses them. Keeping
// the generator means the next change gets checked against the same thousand tables.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, PotCalculator, HoldemState, Money, makeTable, chipsOnTable, contributions } = require("./load");
// Deterministic PRNG: a failure has to be reproducible from the seed printed with it,
// otherwise a randomised suite is just a flaky one.
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
// Plays one hand to completion with random but legal-ish dealer input.
function playHand(random) {
const seatCount = 2 + Math.floor(random() * 9);
const seatIndexes = [...Array(seatCount).keys()];
const seated = seatIndexes.filter(() => random() < 0.7);
if (seated.length < 2) seated.push(...seatIndexes.filter(i => !seated.includes(i)).slice(0, 2 - seated.length));
const bigBlind = [2, 5, 0.5][Math.floor(random() * 3)];
const state = makeTable({
seatCount,
seated,
stack: 0,
smallBlind: bigBlind / 2,
bigBlind,
dealerSeatIndex: Math.floor(random() * seatCount),
});
// Varied stack depths, so short stacks and side pots actually happen.
seated.forEach(i => {
state.seats[i].stack = Money.round(bigBlind * (1 + Math.floor(random() * 60)));
});
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
let guard = 0;
while (!Betting.isHandOver(state) && state.street !== "done" && guard++ < 400) {
if (state.actingSeatIndex === null) {
if (state.street === "river" || !Betting.canStillBet(state)) break;
Betting.closeStreet(state);
continue;
}
const index = state.actingSeatIndex;
const player = state.seats[index];
const toCall = Betting.toCall(state, player);
const roll = random();
if (roll < 0.15) Betting.fold(state, index);
else if (roll < 0.25) Betting.allIn(state, index);
else if (toCall > 0) Betting.call(state, index);
else if (roll < 0.75) Betting.check(state, index);
else Betting.raiseTo(state, index, Betting.minRaiseTo(state));
}
Betting.sweepToHandTotals(state);
return { state, startingChips, seated };
}
describe("randomised hands hold the dealer's invariants", () => {
const HANDS = 1000;
test(`${HANDS} hands never create or destroy a chip`, () => {
const random = rng(20260901);
for (let hand = 0; hand < HANDS; hand++) {
const { state, startingChips } = playHand(random);
assert.equal(
chipsOnTable(state),
startingChips,
`hand ${hand}: chips on the table changed during play`
);
}
});
test(`${HANDS} hands: pots plus uncalled returns always equal the money in`, () => {
const random = rng(778899);
let sidePotHands = 0;
let returnHands = 0;
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
const inThePot = players.reduce((sum, p) => sum + p.contribution, 0);
if (inThePot <= 0) continue;
// Fold flags can leave nobody live, which the calculator deliberately
// refuses to guess at — that's a dealer correction, not an invariant.
if (players.every(p => p.folded)) continue;
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
const returned = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
assert.equal(
Money.toCents(layered + returned),
Money.toCents(inThePot),
`hand ${hand}: pot breakdown doesn't add up to what went in`
);
if (PotCalculator.layers(players).length > 1) sidePotHands++;
if (returned > 0) returnHands++;
}
assert.ok(sidePotHands > 20, `expected side pots to occur; saw ${sidePotHands}`);
assert.ok(returnHands > 20, `expected uncalled bets to occur; saw ${returnHands}`);
});
test(`${HANDS} hands: what's left after returns is money somebody actually matched`, () => {
// The rule the pot breakdown rests on: you are only called up to what somebody
// else was willing to put in. Once the uncalled chips are handed back, no
// player should still be sitting above every other player at the table.
//
// A single-eligible pot is *not* the thing to look for here — that's legitimate
// when a player called and then folded, leaving dead money one contender
// collects. The bug this guards against is subtler: chips nobody ever matched
// being dressed up as a pot to play for.
const random = rng(13579);
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
if (players.every(p => p.folded)) continue;
const returns = PotCalculator.uncalledReturns(players);
assert.ok(returns.length <= 1, `hand ${hand}: only the biggest bet can go uncalled`);
returns.forEach(r => {
const player = players.find(p => p.id === r.id);
assert.ok(r.amount <= player.contribution + 0.001, `hand ${hand}: returned more than was put in`);
});
const returned = new Map(returns.map(r => [r.id, r.amount]));
const capped = players.map(p => Money.toCents(p.contribution - (returned.get(p.id) || 0)));
capped.forEach((cents, index) => {
const others = capped.filter((_, i) => i !== index);
const mostAnyoneElsePaid = others.length ? Math.max(...others) : 0;
assert.ok(
cents <= mostAnyoneElsePaid,
`hand ${hand}: a player is still ${Money.fromCents(cents - mostAnyoneElsePaid)} ` +
`above everyone else after returns — that money was never matched`
);
});
}
});
test(`${HANDS} hands: paying the pot out settles it exactly`, () => {
const random = rng(24680);
for (let hand = 0; hand < HANDS; hand++) {
const { state } = playHand(random);
const players = contributions(state);
if (players.every(p => p.folded)) continue;
// Take the uncalled money off first, the way the dealer would.
const returned = new Map(PotCalculator.uncalledReturns(players).map(r => [r.id, r.amount]));
const contested = players.map(p => ({
...p,
contribution: Money.round(p.contribution - (returned.get(p.id) || 0)),
}));
let entries = PotCalculator.buildEntries(contested);
const potBefore = PotCalculator.totalPot(entries);
let paidOut = 0;
let rounds = 0;
while (PotCalculator.getContenders(entries).length > 0 && rounds++ < 30) {
const contenders = PotCalculator.getContenders(entries);
const winner = contenders[Math.floor(random() * contenders.length)];
const result = PotCalculator.resolveRound(entries, [winner.id]);
paidOut += result.perWinner.reduce((s, w) => s + w.amount, 0);
entries = result.updatedEntries;
}
assert.equal(
Money.toCents(paidOut),
Money.toCents(potBefore),
`hand ${hand}: paying out the pot left ${potBefore - paidOut} unaccounted for`
);
}
});
test(`${HANDS} hands: the button and blinds land on real players`, () => {
const random = rng(112233);
for (let hand = 0; hand < HANDS; hand++) {
const seatCount = 2 + Math.floor(random() * 9);
const seated = [...Array(seatCount).keys()].filter(() => random() < 0.6);
if (seated.length < 2) continue;
const state = makeTable({ seatCount, seated, dealerSeatIndex: Math.floor(random() * seatCount) });
HoldemState.advanceButton(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand, `hand ${hand}: button on an empty chair`);
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, `hand ${hand}: small blind on an empty chair`);
assert.ok(state.seats[bbIndex].inHand, `hand ${hand}: big blind on an empty chair`);
assert.notEqual(sbIndex, bbIndex, `hand ${hand}: both blinds on one player`);
if (seated.length === 2) {
assert.ok(headsUp, `hand ${hand}: two players should be heads-up`);
assert.equal(sbIndex, state.dealerSeatIndex, `hand ${hand}: heads-up button posts the small blind`);
}
}
});
});
-87
View File
@@ -1,87 +0,0 @@
// Loads the dealer's browser modules into Node so they can be tested directly.
//
// The modules are plain script-tag files: each one declares a `const` namespace and
// ends with a `module.exports` guard. In the browser they find each other as globals,
// so the same names have to exist on `globalThis` here or `Betting` can't reach
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
const path = require("path");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
function load(name) {
const mod = require(path.join(SRC, `${name}.js`));
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
return mod;
}
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.
const Render = require(path.join(SRC, "render.js")).Render;
globalThis.Render = Render;
// state.js exports `HoldemState`, which doesn't match its filename.
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
// in with `stack` chips each. Everything else is the app's own defaults, so a test
// that forgets to set a field gets whatever a real dealer would get.
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seatCount);
state.smallBlind = smallBlind;
state.bigBlind = bigBlind;
state.dealerSeatIndex = dealerSeatIndex;
const sitting = seated || state.seats.map((_, i) => i);
state.seats.forEach((player, index) => {
player.inHand = sitting.includes(index);
player.stack = sitting.includes(index) ? stack : null;
player.name = `P${index + 1}`;
});
return state;
}
// Total chips the table is accountable for: what's in front of everyone, plus what
// they've pushed in this street, plus what's already banked into the pot.
function chipsOnTable(state) {
return Money.round(
state.seats.reduce(
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
0
)
);
}
// Contributions in the shape PotCalculator wants them.
function contributions(state) {
return state.seats
.filter(p => p.inHand)
.map(p => ({
id: p.id,
contribution: Betting.handTotal(p),
folded: p.folded,
}));
}
module.exports = {
Money,
Render,
HandHistoryExport,
PotCalculator,
SplitCalculator,
SettleUp,
Betting,
HoldemState,
makeTable,
chipsOnTable,
contributions,
};
-260
View File
@@ -1,260 +0,0 @@
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
// whose blind it is, and pass the button round three times before anyone deals.
// These are the cases the positional rules quietly get wrong.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
const potOf = state => HoldemState.totalPot(state);
describe("a player leaves in the middle of a hand", () => {
test("their chips stay in the pot", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const before = potOf(state);
assert.equal(before, 8);
state.seats[1].inHand = false; // dealer takes them out
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
});
test("they cannot be paid out of a pot they walked away from", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
state.seats[1].inHand = false;
const players = state.seats
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded || HoldemState.isDeadMoney(p),
}));
const layers = PotCalculator.layers(players);
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
});
test("taking out the player who is to act moves the action on", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
const acting = state.actingSeatIndex;
state.seats[acting].inHand = false;
Betting.passAction(state, acting);
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
});
test("an empty chair still contributes nothing, base pot or not", () => {
// The table-wide default must not be read as an ante from seats nobody is in.
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
state.basePotAmount = 25;
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
});
});
describe("a player joins in the middle of a hand", () => {
test("they sit the hand out rather than joining a pot they never paid into", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
// Walks up and is dealt in — the app marks them folded for this hand.
state.seats[4].inHand = true;
state.seats[4].stack = 100;
state.seats[4].folded = true;
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
});
test("they are a full player again on the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.seats[4].inHand = true;
state.seats[4].stack = 100;
state.seats[4].folded = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[4].folded, false);
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
});
});
describe("passing the button around before the cards come out", () => {
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(state.seats[1].committed, 1);
assert.equal(state.seats[2].committed, 2);
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
assert.equal(state.seats[0].committed, 2);
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
});
test("passing it round many times still balances", () => {
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
[2, 5, 7, 3, 0, 5].forEach(seat => {
state.dealerSeatIndex = seat;
Betting.repostBlinds(state);
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
});
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
});
test("once somebody has acted, the blinds are settled", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
Betting.call(state, 3);
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
});
});
describe("naming the blinds by hand", () => {
test("a named blind wins over the button order", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
assert.equal(sbIndex, 3);
assert.equal(bbIndex, 0);
assert.equal(manual, true);
Betting.beginHand(state);
assert.equal(state.seats[3].committed, 1);
assert.equal(state.seats[0].committed, 2);
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
});
test("a named blind on a player who isn't dealt in is ignored", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
assert.ok(state.seats[sbIndex].inHand);
});
test("both blinds on one player is refused", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.notEqual(sbIndex, bbIndex);
});
test("naming blinds heads-up still acts in the right order", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
Betting.beginHand(state);
assert.equal(state.seats[5].committed, 1);
assert.equal(state.seats[2].committed, 2);
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
Betting.call(state, 5);
Betting.check(state, 2);
Betting.closeStreet(state);
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
});
test("the naming only lasts the hand it was made for", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.equal(state.blindOverride, null, "blinds go back to following the button");
});
});
describe("the first hand on a fresh table", () => {
test("the button never starts on an empty chair", () => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
Betting.beginHand(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand);
assert.ok(state.seats[bbIndex].inHand);
assert.notEqual(sbIndex, bbIndex);
});
test("a table that empties out around the button recovers", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
state.seats[3].inHand = false; // the player holding the button leaves
HoldemState.ensureButtonOnPlayer(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand);
});
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
[2, 3].forEach(i => { state.seats[i].inHand = false; });
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");
});
});
describe("chips survive a messy table", () => {
test("joining, leaving and passing the button never changes the chip count", () => {
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[6].stack = 100; // sitting down but not dealt in yet
const startingChips = chipsOnTable(state);
Betting.beginHand(state);
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
state.seats[6].inHand = true; // walks up mid-hand
state.seats[6].folded = true;
assert.equal(chipsOnTable(state), startingChips);
Betting.call(state, state.actingSeatIndex);
state.seats[1].inHand = false; // and someone storms off
Betting.sweepToHandTotals(state);
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
assert.equal(
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
Money.toCents(startingChips),
"and the pot plus the stacks is the whole table"
);
});
});
-126
View File
@@ -1,126 +0,0 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Money, HandHistoryExport } = require("./load");
describe("Money.format", () => {
test("whole amounts drop the pointless cents", () => {
assert.equal(Money.format(12), "$12");
assert.equal(Money.format(450), "$450");
assert.equal(Money.format(1250000), "$1,250,000");
assert.equal(Money.format(0), "$0");
});
test("amounts that carry cents still show them", () => {
assert.equal(Money.format(12.5), "$12.50");
assert.equal(Money.format(0.25), "$0.25");
assert.equal(Money.format(3.01), "$3.01");
});
test("a quarter-blind game reads exactly as it did", () => {
// The case that would be ruined by dropping cents unconditionally.
[0.25, 0.5, 1.75, 12.05].forEach(v => {
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
});
});
test("floating-point dust doesn't invent cents", () => {
assert.equal(Money.format(0.1 + 0.2), "$0.30");
assert.equal(Money.format(1.0000000001), "$1");
});
test("signed amounts put the sign out front, both scales", () => {
assert.equal(Money.formatSigned(87500), "+$87,500");
assert.equal(Money.formatSigned(-87500), "-$87,500");
assert.equal(Money.formatSigned(-12.5), "-$12.50");
assert.equal(Money.formatSigned(0), "$0");
});
test("rubbish in doesn't produce rubbish out", () => {
assert.equal(Money.format(null), "$0");
assert.equal(Money.format(undefined), "$0");
assert.equal(Money.format("nonsense"), "$0");
});
});
describe("Money.forSheet", () => {
test("a bare number a spreadsheet will add up", () => {
assert.equal(Money.forSheet(1250000), "1250000.00");
assert.equal(Money.forSheet(12.5), "12.50");
assert.equal(Money.forSheet(-87500), "-87500.00");
});
test("no currency symbol and no thousands separators", () => {
const written = Money.forSheet(1234567.89);
assert.ok(!written.includes("$"), "a $ makes the cell text");
assert.ok(!written.includes(","), "a comma makes the cell text");
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
});
});
describe("the exported CSV", () => {
const history = [
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
];
const standings = [
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
];
// Splitting on commas isn't good enough: the timestamp column legitimately holds
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
const splitRow = line => {
const fields = [];
let field = "";
let quoted = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quoted) {
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ",") { fields.push(field); field = ""; }
else field += c;
}
fields.push(field);
return fields;
};
test("every money cell parses as a number", () => {
const csv = HandHistoryExport.toCsv(history, standings);
const lines = csv.split("\r\n");
lines.slice(1, 3).forEach(line => {
const cell = splitRow(line)[2]; // Pot
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.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
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`);
});
});
});
test("a million-dollar pot doesn't need quoting any more", () => {
const csv = HandHistoryExport.toCsv(history, []);
assert.ok(csv.includes("1250000.00"), "written plainly");
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
});
test("names and payout descriptions stay human", () => {
const csv = HandHistoryExport.toCsv(history, standings);
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
});
test("a name with a comma is still quoted properly", () => {
const csv = HandHistoryExport.toCsv([], [
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
]);
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
});
});
-124
View File
@@ -1,124 +0,0 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { PotCalculator } = require("./load");
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
describe("PotCalculator.layers", () => {
test("one pot when everyone put in the same", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("c", 50)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150);
assert.equal(layers[0].label, "Main pot");
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
});
test("a short all-in opens a side pot only the deeper players can win", () => {
// a is all-in for 20, b and c keep betting to 60.
const layers = PotCalculator.layers([p("a", 20), p("b", 60), p("c", 60)]);
assert.equal(layers.length, 2);
assert.equal(layers[0].amount, 60); // 20 x 3
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c"]);
assert.equal(layers[1].amount, 80); // 40 x 2
assert.deepEqual(layers[1].eligible.sort(), ["b", "c"]);
assert.equal(layers[1].label, "Side pot 1");
});
test("folded money stays in the pot as dead money but wins nothing", () => {
const layers = PotCalculator.layers([p("a", 50), p("b", 50), p("folder", 50, true)]);
assert.equal(layers.length, 1);
assert.equal(layers[0].amount, 150, "the folder's chips are still in the middle");
assert.ok(!layers[0].eligible.includes("folder"));
});
test("layers with the same eligible players are announced as one pot", () => {
// Two folders at different heights would each open a band, but a dealer
// announces one pot between the two players who can actually win it.
const layers = PotCalculator.layers([
p("a", 100),
p("b", 100),
p("f1", 20, true),
p("f2", 40, true),
]);
const eligibleSets = layers.map(l => l.eligible.slice().sort().join(","));
assert.deepEqual(eligibleSets, ["a,b"], "one announceable pot, not three");
assert.equal(layers[0].amount, 260);
});
test("layer amounts always sum to the contested money", () => {
const players = [p("a", 12.5), p("b", 33.25), p("c", 33.25), p("d", 5, true)];
const total = players.reduce((s, x) => s + x.contribution, 0);
const uncalled = PotCalculator.uncalledReturns(players).reduce((s, r) => s + r.amount, 0);
const layered = PotCalculator.layers(players).reduce((s, l) => s + l.amount, 0);
assert.equal(Math.round((layered + uncalled) * 100), Math.round(total * 100));
});
test("nobody contributed means no pots, not an empty main pot", () => {
assert.deepEqual(PotCalculator.layers([p("a", 0), p("b", 0)]), []);
});
});
describe("PotCalculator.uncalledReturns", () => {
test("an overbet nobody called goes back to whoever bet it", () => {
// b shoves 200, a calls only 50 and everyone else folds.
const returns = PotCalculator.uncalledReturns([p("a", 50), p("b", 200)]);
assert.deepEqual(returns, [{ id: "b", amount: 150 }]);
});
test("a called bet leaves nothing to return", () => {
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 50), p("b", 50)]), []);
});
test("the cap is the biggest live contribution, so a folder's overbet comes back", () => {
const returns = PotCalculator.uncalledReturns([p("live", 30), p("bully", 90, true)]);
assert.deepEqual(returns, [{ id: "bully", amount: 60 }]);
});
test("with nobody live it stays quiet rather than refunding the table", () => {
// Every fold flag set is a dealer mistake to correct, not a signal that
// every chip on the felt was uncalled.
assert.deepEqual(PotCalculator.uncalledReturns([p("a", 10, true), p("b", 20, true)]), []);
});
});
describe("PotCalculator.resolveRound", () => {
test("a single winner takes the layer they were matched for", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
const result = PotCalculator.resolveRound(entries, ["a"]);
assert.equal(result.payoutPool, 100);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 100 }]);
assert.equal(result.remainingContenders.length, 0);
});
test("an all-in winner only takes what everyone matched, the rest rolls forward", () => {
const entries = PotCalculator.buildEntries([p("short", 20), p("b", 60), p("c", 60)]);
const result = PotCalculator.resolveRound(entries, ["short"]);
assert.equal(result.capApplied, 20);
assert.equal(result.payoutPool, 60, "20 from each of three players");
// b and c still have 40 each left to play for.
assert.deepEqual(result.remainingContenders.map(e => e.id).sort(), ["b", "c"]);
assert.equal(PotCalculator.totalPot(result.updatedEntries), 80);
});
test("a chopped pot splits evenly", () => {
const entries = PotCalculator.buildEntries([p("a", 30), p("b", 30)]);
const result = PotCalculator.resolveRound(entries, ["a", "b"]);
assert.deepEqual(result.perWinner, [{ id: "a", amount: 30 }, { id: "b", amount: 30 }]);
});
test("odd cents go to the winners in selection order and nothing is lost", () => {
// A $10.01 pot three ways doesn't divide: 3.34 / 3.34 / 3.33.
const entries = PotCalculator.buildEntries([p("a", 3.34), p("b", 3.34), p("c", 3.33)]);
const result = PotCalculator.resolveRound(entries, ["a", "b", "c"]);
const paid = result.perWinner.reduce((s, w) => s + w.amount, 0);
assert.equal(Math.round(paid * 100), Math.round(result.payoutPool * 100), "no chips lost to rounding");
assert.ok(result.perWinner[0].amount >= result.perWinner[2].amount, "odd cent goes to the first selected");
});
test("selecting nobody who can win is an error, not a silent no-op", () => {
const entries = PotCalculator.buildEntries([p("a", 50), p("b", 50)]);
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
});
});
-103
View File
@@ -1,103 +0,0 @@
// Chips only ever leave this tool because a dealer meant them to. Starting the next
// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
// the UI now asks first, and the number it quotes has to be exactly what goes.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
// The same measure the confirmation uses.
const unresolvedPot = state => HoldemState.unresolvedContributions(state);
describe("what starting the next hand destroys", () => {
test("the quoted amount is exactly what leaves the table", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const before = chipsOnTable(state);
const quoted = unresolvedPot(state);
assert.ok(quoted > 0, "there is a pot to warn about");
HoldemState.startNextHand(state);
assert.equal(
Money.toCents(chipsOnTable(state)),
Money.toCents(before - quoted),
"the dealer must be told the real number, not an approximation"
);
});
test("chips still in front of players on the current street are counted too", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state); // blinds are `committed`, not yet banked
Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
const before = chipsOnTable(state);
const quoted = unresolvedPot(state);
HoldemState.startNextHand(state);
assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
});
test("a hand that was paid out properly leaves nothing to warn about", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
});
test("settling then dealing on conserves every chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const start = chipsOnTable(state);
for (let hand = 0; hand < 5; hand++) {
Betting.beginHand(state);
const live = Betting.liveSeats(state);
live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const winner = Betting.liveSeats(state)[0];
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
}
});
test("an empty table has no phantom pot to warn about", () => {
const state = makeTable({ seatCount: 6, seated: [] });
assert.equal(unresolvedPot(state), 0);
});
test("a standing ante is never warned about, because it isn't lost", () => {
// The table default is a stand-in for "everyone puts the same in every hand",
// used by a dealer not tracking stacks at all. It re-applies to the next hand
// rather than going anywhere, so counting it would nag this dealer before
// every single hand — which is why the warning measures the fields
// startNextHand actually clears rather than the pot badge.
const state = makeTable({ seatCount: 9, seated: [0, 1] });
state.basePotAmount = 25;
state.seats.forEach(p => { p.amountOverride = null; });
// The base pot carries into the next hand, so nothing is being thrown away
// and the dealer must not be asked to confirm anything.
assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
HoldemState.startNextHand(state);
assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
});
});
-143
View File
@@ -1,143 +0,0 @@
// When does a bet reopen the action? The most misread rule in poker, and the one a
// home game argues about longest: a player shoving in less than a full raise does not
// give everybody behind them another go.
//
// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting
// to a player who has already acted and is not facing a full raise.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, makeTable, chipsOnTable } = require("./load");
const actedFlags = state => state.seats
.map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded }))
.filter(s => s.live);
describe("a short all-in does not reopen the betting", () => {
// Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls.
// Seat 2 is all-in for 13 — three more, where a full raise needs eight.
const shortAllInTable = () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 13;
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
Betting.call(state, 0);
return state;
};
test("players who already acted are not asked to act again", () => {
const state = shortAllInTable();
assert.equal(state.seats[0].hasActed, true, "seat 1 has called");
assert.equal(state.seats[3].hasActed, true, "seat 4 has raised");
Betting.allIn(state, 1);
assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened");
assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened");
});
test("but they do still owe the difference", () => {
const state = shortAllInTable();
Betting.allIn(state, 1);
assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3");
assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over");
});
test("a player who had not acted yet keeps full rights", () => {
const state = shortAllInTable();
assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted");
Betting.allIn(state, 1);
assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise");
});
test("the minimum raise is not shrunk by the short all-in", () => {
const state = shortAllInTable();
assert.equal(state.lastRaiseSize, 8);
Betting.allIn(state, 1);
assert.equal(state.lastRaiseSize, 8, "still the last *full* raise");
assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8");
});
test("calling the short all-in ends the street", () => {
const state = shortAllInTable();
Betting.allIn(state, 1);
Betting.call(state, 2); // seat 3, who had full rights, just calls
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(Betting.isStreetComplete(state), true);
});
test("no chips are created or lost by any of it", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 13;
const start = chipsOnTable(state);
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
Betting.call(state, 0);
Betting.allIn(state, 1);
Betting.call(state, 2);
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(chipsOnTable(state), start);
});
});
describe("a full raise still reopens the betting", () => {
test("an all-in that is a full raise or more reopens everyone", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 40;
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
Betting.call(state, 0);
Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed
assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go");
assert.equal(state.seats[3].hasActed, false, "and so does seat 4");
assert.equal(state.lastRaiseSize, 30);
assert.equal(Betting.minRaiseTo(state), 70);
});
test("an ordinary raise reopens everyone", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(state.seats[3].hasActed, true);
Betting.raiseTo(state, 1, 10);
assert.equal(state.seats[3].hasActed, false);
assert.equal(state.seats[0].hasActed, false);
});
test("a raise exactly the size of the last one counts as full", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10); // raise of 8
Betting.call(state, 0);
Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise
assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens");
assert.equal(state.lastRaiseSize, 8);
});
test("the first raise of a street is measured against the big blind", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
Betting.check(state, 2);
Betting.closeStreet(state); // to the flop, no bet yet
const first = Betting.nextToAct(state, state.dealerSeatIndex);
Betting.raiseTo(state, first, 2); // a bet of exactly one big blind
const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first);
assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone");
});
});
-144
View File
@@ -1,144 +0,0 @@
// Values a dealer can actually produce with a typo, and the ones a corrupted save
// can produce on its own. None of these should be able to move chips.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("a stack can never go negative", () => {
test("a mistyped negative stack reads as empty, not as owing chips", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
});
test("a negative stack can't be bet with", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
const moved = Betting.putIn(state, 0, 25);
assert.equal(moved, 0, "nothing moves out of an empty stack");
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
});
test("a negative table default doesn't hand everyone a negative stack", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats.forEach(p => { p.stack = null; });
state.defaultStack = -100;
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
});
test("a negative stack doesn't make a player look like they can still bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.seats[0].stack = -50;
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
});
});
describe("blinds that can't be posted", () => {
test("negative blinds post nothing rather than paying players to sit down", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.smallBlind = -5;
state.bigBlind = -10;
const before = chipsOnTable(state);
Betting.beginHand(state);
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(chipsOnTable(state), before);
});
test("an unreadable blind doesn't wedge the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
state.bigBlind = NaN;
state.smallBlind = 1;
Betting.beginHand(state);
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
});
test("blinds nobody can cover put those players all-in and move on", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
state.smallBlind = 500;
state.bigBlind = 1000;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
});
});
describe("a saved table that comes back damaged", () => {
test("unreadable storage falls back to a fresh table", () => {
// load() has to survive whatever is in localStorage, because the alternative
// is a dealer opening the tool mid-session to a blank screen.
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => "{not json at all",
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
assert.equal(state.handNumber, 1);
});
test("a save missing its seats is treated as no save at all", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
});
test("a save from before a field existed gets the field, not undefined", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => JSON.stringify({
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
handNumber: 4,
}),
setItem: () => {},
removeItem: () => {},
};
const state = HoldemState.load();
globalThis.localStorage = original;
assert.equal(state.blindOverride, null);
assert.equal(state.street, "preflop");
state.seats.forEach(p => {
assert.equal(p.committed, 0);
assert.equal(p.hasActed, false);
assert.equal(p.buyIns, 0);
});
// And it has to be playable, not just parseable.
assert.doesNotThrow(() => Betting.beginHand(state));
});
test("storage that refuses to be written doesn't take the table down", () => {
const original = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => null,
setItem: () => { throw new Error("QuotaExceededError"); },
removeItem: () => {},
};
const state = HoldemState.createDefault();
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
globalThis.localStorage = original;
});
});
-134
View File
@@ -1,134 +0,0 @@
// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
//
// node tests/holdem/runUiAudit.js [baseUrl]
//
// Needs Playwright available (`npx playwright install chromium` once). It is
// deliberately NOT wired into the unit-test suite or CI, because that suite is
// dependency-free and runs in a second — this one needs a browser and a running
// site. Run it after any change that adds or resizes a control.
//
// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
// and call LayoutAudit.runAll(). Same checks, one viewport.
const path = require("path");
const BASE = process.argv[2] || "http://localhost:5299";
const AUDIT = path.join(__dirname, "uiAudit.js");
// Portrait phones, tablets, desktops, then the same phones on their side — landscape
// is where this layout has broken most often, because the table is sized from width
// and the action bar is fixed to a very short viewport.
const VIEWPORTS = [
[360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
[412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
[768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
[1280, 900, "desktop"], [1600, 1000, "wide desktop"],
[640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
];
const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
];
// A table part-way through a real night: long names, uneven stacks, a hand log.
function seedTable() {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
.forEach(([name, cash], i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].buyIns = cash;
state.seats[i].stack = cash;
});
state.smallBlind = 5;
state.bigBlind = 10;
for (let hand = 1; hand <= 4; hand++) {
state.handHistory.push({
handNumber: hand, timestamp: Date.now(), pot: 120,
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
});
}
state.handNumber = 5;
HoldemState.save(state);
}
async function main() {
let chromium;
try {
({ chromium } = require("playwright"));
} catch {
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
process.exit(2);
}
const browser = await chromium.launch();
const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
}
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
return;
}
failures.forEach(f => {
console.log(`\nFAIL ${f.where}`);
f.offenders.forEach(o => console.log(` ${o}`));
});
process.exit(1);
}
main();
-124
View File
@@ -1,124 +0,0 @@
// 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);
});
});
-110
View File
@@ -1,110 +0,0 @@
// 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, []);
});
-65
View File
@@ -1,65 +0,0 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { SplitCalculator, Money } = require("./load");
describe("SplitCalculator.split", () => {
test("an even chop hands everyone the same stack", () => {
const result = SplitCalculator.split(90, 3, 1);
assert.equal(result.each, 30);
assert.equal(result.oddChips, 0);
assert.equal(result.dust, 0);
assert.equal(SplitCalculator.accountedFor(result), 90);
});
test("what won't divide is called out as odd chips, not rolled into a stack", () => {
// $100 three ways in $5 chips: 20 chips, 6 each, 2 left over.
const result = SplitCalculator.split(100, 3, 5);
assert.equal(result.each, 30);
assert.equal(result.oddChips, 2);
assert.equal(SplitCalculator.accountedFor(result), 100);
});
test("a pot that isn't a whole number of chips surfaces the remainder", () => {
// $47 in $5 chips: 9 chips ($45) and $2 that no chip in play can pay.
const result = SplitCalculator.split(47, 2, 5);
assert.equal(result.dust, 2, "the dealer picked a denomination the pot can't make");
assert.equal(SplitCalculator.accountedFor(result), 47);
});
test("every split accounts for the whole pot", () => {
const chips = [0.25, 0.5, 1, 5, 25, 100];
for (let pot = 1; pot <= 400; pot += 7) {
for (let ways = 2; ways <= 6; ways++) {
chips.forEach(chip => {
const result = SplitCalculator.split(pot, ways, chip);
assert.equal(
Money.toCents(SplitCalculator.accountedFor(result)),
Money.toCents(pot),
`${pot} split ${ways} ways in ${chip} chips lost money`
);
});
}
}
});
test("odd chips are always fewer than the number of players", () => {
for (let pot = 1; pot <= 200; pot += 3) {
for (let ways = 2; ways <= 8; ways++) {
const result = SplitCalculator.split(pot, ways, 1);
assert.ok(result.oddChips < ways, "a whole extra chip each would have been dealt out");
}
}
});
test("an empty pot isn't a valid split", () => {
assert.equal(SplitCalculator.split(0, 3, 1).valid, false);
assert.equal(SplitCalculator.split(-5, 3, 1).valid, false);
});
test("nonsense inputs fall back to something a dealer can read", () => {
const result = SplitCalculator.split(50, 0, 0);
assert.equal(result.players, 1, "a split has to be at least one way");
assert.equal(result.chip, 0.01, "and at least one cent a chip");
assert.equal(SplitCalculator.accountedFor(result), 50);
});
});
-147
View File
@@ -1,147 +0,0 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { HoldemState, makeTable } = require("./load");
describe("HoldemState.advanceButton", () => {
test("the button moves to the next player, not the next empty chair", () => {
// Four players spread around a ten-seat table: 0, 3, 4, 8.
const state = makeTable({ seatCount: 10, seated: [0, 3, 4, 8], dealerSeatIndex: 0 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3, "skips the three empty chairs between");
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 4);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 8);
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 0, "wraps back round to the first player");
});
test("every player gets the button once per orbit", () => {
const seated = [1, 2, 6, 7, 9];
const state = makeTable({ seatCount: 12, seated, dealerSeatIndex: 1 });
const visited = [];
for (let i = 0; i < seated.length; i++) {
visited.push(state.dealerSeatIndex);
HoldemState.advanceButton(state);
}
assert.deepEqual(visited.slice().sort((a, b) => a - b), seated);
});
test("it still moves when nobody is dealt in, rather than looking stuck", () => {
const state = makeTable({ seatCount: 6, seated: [], dealerSeatIndex: 2 });
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 3);
});
test("counterclockwise play runs the button the other way", () => {
const state = makeTable({ seatCount: 6, seated: [0, 2, 4], dealerSeatIndex: 4 });
state.direction = "counterclockwise";
HoldemState.advanceButton(state);
assert.equal(state.dealerSeatIndex, 2);
});
});
describe("HoldemState.getBlindSeats", () => {
test("blinds follow the players, not the chairs left of the button", () => {
// Seats 1 and 2 are empty, so the blinds belong on 3 and 7.
const state = makeTable({ seatCount: 9, seated: [0, 3, 7], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, false);
assert.equal(sbIndex, 3);
assert.equal(bbIndex, 7);
});
test("heads-up, the button posts the small blind", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 2, "the button is the small blind heads-up");
assert.equal(bbIndex, 5);
});
test("heads-up with the button on an empty chair still posts both blinds", () => {
const state = makeTable({ seatCount: 6, seated: [1, 4], dealerSeatIndex: 0 });
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, 1);
assert.equal(bbIndex, 4);
assert.notEqual(sbIndex, bbIndex, "two blinds never land on one player");
});
test("fewer than two players means no blinds to post", () => {
const state = makeTable({ seatCount: 6, seated: [3], dealerSeatIndex: 0 });
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.equal(sbIndex, null);
assert.equal(bbIndex, null);
});
});
describe("HoldemState.sessionStandings", () => {
test("a player's cash position counts chips in front plus chips in the pot", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 0 });
const [a] = state.seats;
a.buyIns = 100;
a.stack = 60;
a.amountOverride = 25; // banked into the hand in progress
a.committed = 15; // still in front of them on this street
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 0 }]);
const standings = HoldemState.sessionStandings(state);
const row = standings.find(s => s.id === a.id);
// 60 in front + 40 in the pot - 100 bought in = level.
assert.equal(row.cashPosition, 0, "money in the pot is still theirs until it's won");
assert.equal(row.boughtIn, 100);
assert.equal(row.chipsNow, 60);
});
test("hand results accumulate across the session", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
const [a, b] = state.seats;
a.amountOverride = 10;
b.amountOverride = 10;
HoldemState.recordHand(state, [{ id: a.id, label: "P1", amount: 20 }]);
a.amountOverride = 5;
b.amountOverride = 5;
HoldemState.recordHand(state, [{ id: b.id, label: "P2", amount: 10 }]);
const standings = HoldemState.sessionStandings(state);
assert.equal(standings.find(s => s.id === a.id).net, 5);
assert.equal(standings.find(s => s.id === b.id).net, -5);
assert.equal(state.handNumber, 3, "the counter moved on twice");
});
test("the history is capped so a long session can still be saved", () => {
const state = makeTable({ seatCount: 2, seated: [0, 1], stack: 0 });
for (let i = 0; i < HoldemState.MAX_HISTORY_ENTRIES + 25; i++) {
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 1 }]);
}
assert.equal(state.handHistory.length, HoldemState.MAX_HISTORY_ENTRIES);
assert.equal(state.handHistory[0].handNumber, HoldemState.MAX_HISTORY_ENTRIES + 25, "newest first");
});
});
describe("HoldemState.resizeSeats", () => {
test("the table is clamped to a size a dealer could actually run", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, 100);
assert.equal(state.seatCount, 24);
HoldemState.resizeSeats(state, 1);
assert.equal(state.seatCount, 2);
});
test("an unreadable seat count falls back to a standard table", () => {
// Empty or non-numeric input (and zero, which is equally meaningless)
// lands on the default rather than a two-seat table.
const state = makeTable({ seatCount: 6 });
HoldemState.resizeSeats(state, NaN);
assert.equal(state.seatCount, HoldemState.DEFAULT_SEAT_COUNT);
});
test("shrinking past the button brings it back onto the table", () => {
const state = makeTable({ seatCount: 9, dealerSeatIndex: 8 });
HoldemState.resizeSeats(state, 4);
assert.ok(state.dealerSeatIndex < 4);
});
});
-575
View File
@@ -1,575 +0,0 @@
// Layout and touch audits for the dealer, written once so they stop being written
// again every round.
//
// These checks have been re-typed into a browser console at least six times over
// the course of building this thing, and each time they only covered whatever had
// just been changed. That is exactly how the standings table shipped with a
// zero-pixel player-name column and survived two rounds: the audit that would have
// caught it existed only in a previous session's scrollback.
//
// This file is deliberately dependency-free and browser-side. It is injected into
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
// package.json, and no browser automation of its own — anyone can also paste it
// into devtools and call LayoutAudit.runAll().
const LayoutAudit = {
// Everything a dealer has to be able to hit.
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
describe(el) {
if (!el) return "(nothing)";
const id = el.id ? `#${el.id}` : "";
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
},
visibleControls() {
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = getComputedStyle(el);
return style.visibility !== "hidden" && style.display !== "none";
});
},
// ---- Touch (see the mobile round) ----
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
// seat as its fold shortcut, so that wait is on the critical path.
touchAction() {
const offenders = this.visibleControls()
.filter(el => getComputedStyle(el).touchAction === "auto")
.map(el => this.describe(el));
return { name: "touch-action", offenders, checked: this.visibleControls().length };
},
tapHighlight() {
const offenders = this.visibleControls()
.filter(el => {
const colour = getComputedStyle(el).webkitTapHighlightColor;
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
})
.map(el => this.describe(el));
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
},
// Text selection must stay ON for anything a dealer types into.
textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
.filter(el => el.offsetParent !== null)
.filter(el => {
const style = getComputedStyle(el);
return (style.webkitUserSelect || style.userSelect) === "none";
})
.map(el => this.describe(el));
return { name: "fields stay selectable", offenders };
},
// ---- Reachability (see the occlusion round) ----
// A control covered by something else is unreachable however good it looks.
// Sampling several points matters: a button you can only hit in one corner is
// covered for practical purposes.
// Can this element be brought into view by scrolling? Something below the fold on
// a page that scrolls is perfectly reachable; something below the fold in a panel
// that *doesn't* scroll is lost. The first version of this check didn't make the
// distinction and reported every link under the fold as a fault.
reachableByScrolling(el) {
const rect = el.getBoundingClientRect();
if (getComputedStyle(el).position === "fixed") return false;
for (let node = el.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node);
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
if (!scrolls) continue;
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
const room = node === document.body
? document.documentElement.scrollHeight - innerHeight - scrollY
: node.scrollHeight - node.clientHeight - node.scrollTop;
if (rect.top >= box.bottom && room > 0) return true;
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
}
return false;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
const offenders = [];
const warnings = [];
[...document.querySelectorAll(selector)].forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
rect.right <= 0 || rect.left >= innerWidth;
if (outsideViewport) {
if (!this.reachableByScrolling(el)) {
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
}
return;
}
const points = [
[rect.left + rect.width / 2, rect.top + rect.height / 2],
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
if (points.length === 0) return;
let blocked = 0;
let testable = 0;
let blockedBy = null;
points.forEach(([x, y]) => {
const top = document.elementFromPoint(x, y);
// A null result means the point isn't hit-testable — typically it sits
// inside the element but outside a scrollable ancestor's clip, i.e. the
// control is scrolled out of view rather than covered by anything.
// Counting that as "covered by null" was reporting a perfectly
// reachable button in a sheet that scrolls.
if (!top) return;
testable++;
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.
//
// 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);
});
if (testable === 0) {
if (!this.reachableByScrolling(el)) {
offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
}
return;
}
if (blocked === 0) return;
const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
// A toast is transient by design: it dismisses itself and carries a close
// button, and on a desktop it necessarily lands over a table that fills the
// content column. Partial overlap from one is a fact of the layout, not a
// fault. Being *fully* covered by a toast is still a failure though — that
// was a real bug once, where a seat sat exactly under the Undo button and a
// tap meant for a player took back the last action instead.
const transient = blockedBy && /toast/i.test(blockedBy);
if (transient && blocked < testable) warnings.push(note);
else offenders.push(note);
});
return { name: "nothing important is covered", offenders, warnings };
},
// ---- Layout (the check that would have caught the standings regression) ----
// A grid column squeezed to nothing doesn't truncate its text, it erases it.
// This is the one that was missing: the player-name column sat at 0px through
// two rounds because nothing ever asked whether a column had survived.
collapsedColumns() {
const offenders = [];
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
if (getComputedStyle(row).display !== "grid") return;
[...row.children].forEach(cell => {
if (getComputedStyle(cell).display === "none") return;
const hasText = (cell.textContent || "").trim().length > 0;
if (hasText && cell.getBoundingClientRect().width < 4) {
offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
}
});
});
return { name: "no column collapsed to nothing", offenders };
},
// Text actually painted outside the box meant to contain it — which on a table of
// overlapping seats means painted on top of a neighbour.
//
// The clipping check is the whole subtlety, and the first version of this got it
// wrong. A Range's bounding rect measures the *laid out* text, which happily runs
// past the element when `overflow: hidden` is quietly clipping it — so an
// ellipsised name was reported as spilling when nothing was drawn outside at all.
// Only overflow that is actually visible can paint over a neighbour.
textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
const offenders = [];
[...document.querySelectorAll(selector)].forEach(el => {
const text = (el.textContent || "").trim();
if (!text) return;
const style = getComputedStyle(el);
const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
if (clipsItself) return;
const container = el.closest(".seat") || el.parentElement;
if (!container) return;
const range = document.createRange();
range.selectNodeContents(el);
const drawn = range.getBoundingClientRect();
const bounds = container.getBoundingClientRect();
if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
}
});
return { name: "no text painted outside its box", offenders };
},
horizontalOverflow() {
const offenders = document.documentElement.scrollWidth > innerWidth + 1
? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
: [];
return { name: "page never scrolls sideways", offenders };
},
// iOS zooms the whole page when a field under 16px takes focus, and the dealer
// is then pinching back out mid-hand.
fieldFontSize() {
const offenders = [...document.querySelectorAll("input, textarea, select")]
.filter(el => el.offsetParent !== null)
.filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
.filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
.map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
return { name: "no field small enough to trigger iOS zoom", offenders };
},
// ---- Contrast ----
//
// This check has been got wrong twice before, both times by being too clever about
// backgrounds. The first version read only `background-color`, so every sheet
// painted with a gradient was measured against transparency and the dealer puck's
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
//
// So it is built to be incapable of false positives instead of trying to be exact.
// Backgrounds composite properly through alpha, and where a gradient is involved
// every one of its colour stops is tried and the *most flattering* one is used. A
// finding therefore means the text fails even under the reading most generous to
// it, which is a claim worth making. Text that can't be resolved to any opaque
// background is reported separately as unmeasured rather than guessed at.
parseColour(value) {
const match = String(value).match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
},
// Colour stops out of any gradient in a background-image.
gradientStops(backgroundImage) {
if (!backgroundImage || backgroundImage === "none") return [];
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
.map(c => this.parseColour(c))
.filter(Boolean);
},
over(top, bottom) {
const a = top.a;
return {
r: top.r * a + bottom.r * (1 - a),
g: top.g * a + bottom.g * (1 - a),
b: top.b * a + bottom.b * (1 - a),
a: 1,
};
},
relativeLuminance({ r, g, b }) {
const channel = v => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
},
contrastRatio(fg, bg) {
const a = this.relativeLuminance(fg);
const b = this.relativeLuminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
},
// Every background this text could actually be sitting on.
//
// Layers are collected from the element outwards and stop at the first fully
// opaque one, because nothing behind that is visible. Getting this wrong in the
// lenient direction is just as bad as the old false positives: an earlier version
// kept walking past an opaque gradient and offered the page background as a
// candidate, which scored white-on-a-mid-grey-panel as though it were
// white-on-near-black and would have waved real problems through.
//
// A gradient contributes one candidate per colour stop; translucent layers above
// the opaque one are composited onto each candidate in order.
backgroundCandidates(el) {
const translucent = []; // top-down, above the opaque layer
let base = null; // candidate colours for the first opaque layer
for (let node = el; node && !base; node = node.parentElement) {
const style = getComputedStyle(node);
const stops = this.gradientStops(style.backgroundImage);
const colour = this.parseColour(style.backgroundColor);
// A gradient whose stops are all opaque hides everything behind it.
if (stops.length > 0 && stops.every(s => s.a === 1)) {
base = stops;
break;
}
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
if (colour && colour.a === 1) { base = [colour]; break; }
if (colour && colour.a > 0) translucent.push(colour);
}
if (!base) {
// Nothing opaque anywhere up the tree — fall back to the page's own colour
// so a finding is still measured against something real.
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
}
// Fold the translucent layers back on, innermost last so it lands on top.
return base.map(candidate => {
let composed = candidate;
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
return composed;
});
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
const nodes = selector
? [...document.querySelectorAll(selector)]
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
if (el.children.length > 0) return; // leaf text only
const text = (el.textContent || "").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;
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
const candidates = this.backgroundCandidates(el);
if (candidates.length === 0) {
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
return;
}
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
});
const sizePx = parseFloat(style.fontSize);
const weight = parseInt(style.fontWeight, 10) || 400;
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
const required = isLarge ? largeMinimum : minimum;
if (best + 0.05 < required) {
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
}
});
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
},
// ---- Reach (touch target sizes) ----
// What a fingertip can actually hit, which is not the same as the element's box.
//
// Two things make the box misleading, and the first version of this check was
// fooled by both. A control can be padded out with a transparent ::after, so it
// responds well beyond its painted size — that's how a 22px dealer puck should
// stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
// tapping anywhere on the label, so a 24px box inside a 320x44 label is a
// perfectly good target. Measuring the box reported both as faults.
//
// So it probes outwards with elementFromPoint until the point stops landing on the
// control, which is the thing a finger actually experiences.
effectiveTarget(el, limit = 48) {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// The probe walks outward a fixed distance, so it can only ever report
// 2 x limit. Anything already drawn larger than that reads as exactly the cap
// — which had every header button reporting a suspiciously round 80px. Give
// the reach room for the element itself plus the padding being looked for.
limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
// A wrapping or associated label forwards the tap, so it counts as the target.
const label = el.closest("label") || (el.labels && el.labels[0]);
const lands = (x, y) => {
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
const hit = document.elementFromPoint(x, y);
if (!hit) return false;
if (hit === el || el.contains(hit)) return true;
return !!(label && (hit === label || label.contains(hit)));
};
if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
const reach = (dx, dy) => {
let far = 0;
for (let d = 1; d <= limit; d++) {
if (!lands(cx + dx * d, cy + dy * d)) break;
far = d;
}
return far;
};
return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
},
// The 44px guideline is about fingertips, so it only bites where there's no mouse.
// Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
// which is not a defect — the same buttons are 64px tall on a phone, where it
// matters. Same reasoning as the field sizing: `hover: none` is what actually
// distinguishes a finger from a pointer, not viewport width.
//
// `exempt` is for controls that are deliberately small *and* have a full-size path
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
// the rim of a seat, and growing it to 44px would take that area from the seat
// itself — which a dealer taps far more often. "Give the button" is in the seat
// menu at full size, so the puck is a shortcut, not the only way through.
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
const offenders = [];
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
this.visibleControls()
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
// here: they're 76x72 and overlap each other at the rim by design, so
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
const size = this.effectiveTarget(el);
if (!size) return;
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
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);
return style.display !== "none" && style.visibility !== "hidden" &&
parseFloat(style.opacity) > 0.05;
});
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 = {}) {
const checks = [
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),
this.occlusion(options.occlusionSelector),
this.collapsedColumns(),
this.textSpill(),
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
failed: checks.filter(c => c.offenders.length > 0),
// Things worth knowing that aren't faults — see occlusion() on toasts.
warnings: checks.flatMap(c => c.warnings || []),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = LayoutAudit;
}
-83
View File
@@ -1,83 +0,0 @@
// 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);
});