Compare commits

..
26 changed files with 144 additions and 2451 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
+29 -106
View File
@@ -31,13 +31,9 @@
<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,25 +81,34 @@
<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>
<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>
<details class="sidebarPanel">
<summary>Session</summary>
<summary>Chip Legend</summary>
<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 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" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
<details class="sidebarPanel">
<summary>Session Standings</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
@@ -114,33 +119,20 @@
<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>
<h3 class="panelSubhead">Hands</h3>
<details class="sidebarPanel">
<summary>Hand History</summary>
<div class="panelBody">
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<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>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<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>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
</aside>
</div>
</div>
@@ -182,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">
@@ -239,73 +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" 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" 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>
<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>
@@ -368,7 +292,6 @@
<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>
@@ -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>
}
+21 -547
View File
@@ -146,7 +146,7 @@ body {
}
.tableHint {
color: #949494;
color: #888;
font-size: 0.8rem;
text-align: center;
margin-top: 1rem;
@@ -223,68 +223,6 @@ body {
inset: 0;
}
/* ---------- Bets on the felt ---------- */
/* Shares the seat ring's box exactly, so a bet always lands on the spoke between its
player and the pot. When the seat ring gets inset on a phone, this moves with it —
measured the alternative, and a bets ring on its own geometry ends up either behind
the pucks or on top of the pot badge. */
#betsLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.betChip {
position: absolute;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 0.28rem;
padding: 0.1rem 0.42rem 0.1rem 0.2rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
/* Reads as a chip seen from above: a lighter top-left, a rim, and the notches a
casino chip has around its edge. */
.betChipDisc {
width: 13px;
height: 13px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.45),
inset 0 -1px 2px rgba(0, 0, 0, 0.45);
}
.betChipAmount {
font-size: 0.62rem;
font-weight: 700;
color: #f0f0f0;
font-variant-numeric: tabular-nums;
}
/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
.betChipAnchored {
left: 50%;
z-index: 3;
}
.betChipAbove {
top: -11px;
transform: translate(-50%, -50%);
}
.betChipBelow {
top: auto;
bottom: -11px;
transform: translate(-50%, 50%);
}
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
@@ -357,7 +295,7 @@ body {
}
.seatEmptyLabel {
color: #8a8a8a;
color: #6d6d6d;
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -373,7 +311,7 @@ body {
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #8a8a8a;
color: #777;
}
.seatPuckName {
@@ -578,267 +516,13 @@ body {
.cardSlot.cardSlotEmpty {
background: #191919;
color: #8a8a8a;
color: #5c5c5c;
border-style: dashed;
border-color: #3f3f3f;
box-shadow: none;
font-weight: 400;
}
/* ---------- Add chips ---------- */
.seatBuyIns {
margin-top: -0.3rem;
color: #9a9a9a;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
#addChipsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 98;
}
#addChipsOverlay[hidden] {
display: none;
}
#addChipsSheet {
width: 100%;
max-width: 420px;
background: linear-gradient(180deg, #242424, #1a1a1a);
border: 1px solid #3a3a3a;
border-bottom: none;
border-radius: 16px 16px 0 0;
padding: 0.35rem 1rem calc(1.2rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.addChipsHeader {
margin-bottom: 0.8rem;
}
.addChipsHeader h2 {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: #f5f5f5;
}
#addChipsSubtitle {
color: #9a9a9a;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
#addChipsQuick {
display: flex;
gap: 0.45rem;
margin-bottom: 0.7rem;
}
.addChipsQuickBtn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
min-height: 52px;
border-radius: 11px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-family: inherit;
cursor: pointer;
}
.addChipsQuickBtn.selected {
border-color: #d8b46a;
background: linear-gradient(180deg, #3a2f18, #241d0f);
color: #f0d9a8;
}
.addChipsQuickLabel {
font-size: 0.78rem;
font-weight: 600;
}
.addChipsQuickAmount {
font-size: 0.7rem;
color: #a8a8a8;
font-variant-numeric: tabular-nums;
}
.addChipsQuickBtn.selected .addChipsQuickAmount {
color: #d8b46a;
}
.addChipsEntry {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 11px;
padding: 0.2rem 0.8rem;
margin-bottom: 0.8rem;
}
.addChipsEntry:focus-within {
border-color: #d8b46a;
}
.addChipsCurrency {
color: #8a8a8a;
font-size: 1.3rem;
}
#addChipsInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 1.5rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.4rem 0;
}
.addChipsActions {
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
}
.addChipsActions .holdemBtn {
width: 100%;
justify-content: center;
min-height: 48px;
}
/* ---------- Rulings reference ---------- */
.tableHintActions {
margin: 0.35rem 0 0;
text-align: center;
}
.linkBtn {
background: none;
border: none;
padding: 0;
color: #d8b46a;
font-family: inherit;
font-size: 0.78rem;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.linkBtn:hover {
color: #d8b46a;
}
#rulingsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 97;
}
#rulingsOverlay[hidden] {
display: none;
}
#rulingsSheet {
width: 100%;
max-width: 460px;
max-height: 86vh;
overflow-y: auto;
background: linear-gradient(180deg, #242424, #1a1a1a);
border: 1px solid #3a3a3a;
border-bottom: none;
border-radius: 16px 16px 0 0;
padding: 0.35rem 1.1rem calc(1.2rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.rulingsHeader {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.rulingsHeader h2 {
flex: 1;
margin: 0;
font-size: 1.05rem;
font-weight: 500;
color: #f5f5f5;
}
.rulingsIntro {
margin: 0 0 1rem;
color: #9a9a9a;
font-size: 0.8rem;
line-height: 1.5;
}
.ruling {
margin-bottom: 1rem;
padding-left: 0.7rem;
border-left: 2px solid rgba(216, 180, 106, 0.4);
}
.ruling h3 {
margin: 0 0 0.35rem;
color: #d8b46a;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.03em;
}
.ruling p,
.ruling li {
color: #c4c4c4;
font-size: 0.82rem;
line-height: 1.5;
}
.ruling p {
margin: 0;
}
.ruling ul {
margin: 0;
padding-left: 1.1rem;
}
.ruling li + li {
margin-top: 0.25rem;
}
.rulingsSource {
margin: 0;
padding-top: 0.6rem;
border-top: 1px solid #2c2c2c;
color: #8a8a8a;
font-size: 0.72rem;
line-height: 1.45;
}
/* ---------- Seat long-press menu ---------- */
/* Held down, before the menu opens — the only feedback that a press is being
@@ -1188,46 +872,6 @@ body {
gap: 0.7rem;
}
/* A heading inside a panel, so one panel can hold two related things — the
standings and the hand log are two views of the same session — without needing
two identical-looking accordions to say so. */
.panelSubhead {
margin: 0.2rem 0 0;
color: #d8b46a;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.panelSubhead + * {
margin-top: -0.2rem;
}
/* Nested disclosure for the things a dealer sets once and then forgets, kept out
of the way of the settings they actually touch. */
.subPanel {
border-top: 1px solid #2c2c2c;
padding-top: 0.5rem;
}
.subPanel summary {
cursor: pointer;
color: #9a9a9a;
font-size: 0.78rem;
}
.subPanel summary:hover {
color: #c5c5c5;
}
.subPanelBody {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.6rem;
}
.fieldRow {
display: flex;
align-items: center;
@@ -1249,7 +893,7 @@ body {
.fieldHint {
font-size: 0.75rem;
color: #949494;
color: #888;
line-height: 1.4;
margin: 0;
}
@@ -1306,7 +950,7 @@ body {
}
.standingsHeader {
color: #8a8a8a;
color: #777;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1374,7 +1018,7 @@ body {
.handHistoryTime {
margin-left: auto;
color: #8a8a8a;
color: #777;
font-size: 0.72rem;
}
@@ -1480,12 +1124,6 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
.actionRunout {
margin-left: auto;
color: #d8b46a;
font-size: 0.74rem;
}
.actionWho {
margin-left: auto;
color: #9c9c9c;
@@ -1500,7 +1138,7 @@ body.hasActionBar #holdemSidebar {
flex: none;
min-width: 3.1rem;
text-align: right;
color: #949494;
color: #7d7d7d;
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
@@ -1738,7 +1376,7 @@ body.hasActionBar #holdemSidebar {
.seatPuckStack {
font-size: 0.58rem;
color: #9a9a9a;
color: #8f8f8f;
font-variant-numeric: tabular-nums;
margin-top: 0.1rem;
}
@@ -1752,18 +1390,11 @@ body.hasActionBar #holdemSidebar {
.blindsRow .fieldRow {
flex: 1 1 120px;
min-width: 0;
gap: 0.4rem;
}
/* "Small blind" broke onto two lines beside its input, which made the pair look
like a mistake rather than a pair. */
.blindsRow .fieldRow > span {
white-space: nowrap;
}
.blindsRow input {
min-width: 0;
width: 3.4rem;
width: 5rem;
}
/* ---------- Toasts ---------- */
@@ -1842,7 +1473,7 @@ body.hasActionBar #toastHost {
border-radius: 50%;
border: none;
background: transparent;
color: #949494;
color: #888;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
@@ -1968,75 +1599,20 @@ body.hasActionBar #toastHost {
margin-bottom: 0.6rem;
}
/* This is the screen where the money gets decided, so it holds the same 44px+
targets as the rest of the tool rather than the smallest ones in it. Picking the
wrong winner is the one mistake here that costs somebody real money. */
.potWizardChoice {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: 56px;
gap: 0.5rem;
background: #141414;
border: 1px solid #333;
border-radius: 10px;
padding: 0.6rem 0.8rem;
border-radius: 6px;
padding: 0.5rem 0.7rem;
cursor: pointer;
font-size: 0.95rem;
transition: border-color 0.12s ease, background 0.12s ease;
font-size: 0.85rem;
}
.potWizardChoice:hover {
border-color: #555;
background: #191919;
}
.potWizardChoice input {
flex: none;
width: 22px;
height: 22px;
accent-color: #1e8449;
}
/* Selected has to be obvious at a glance — a dealer confirms this with players
watching, and a checkbox alone is easy to misread across a table. */
.potWizardChoice.chosen {
border-color: #1e8449;
background: linear-gradient(180deg, #16301f, #12241a);
}
.potWizardChoice.chosen .potWizardChoiceName {
color: #d6ffe0;
}
.potWizardChoiceText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.potWizardChoiceName {
color: #f0f0f0;
font-weight: 600;
}
.potWizardChoiceUpTo {
color: #9a9a9a;
font-size: 0.76rem;
font-variant-numeric: tabular-nums;
}
.potWizardChoiceCards {
flex: none;
font-family: var(--font-mono, monospace);
font-size: 1rem;
font-weight: 700;
color: #ddd;
}
.potWizardChoiceCards .cardRed {
color: #ff6b6b;
}
.potWizardSummary ul {
@@ -2057,37 +1633,6 @@ body.hasActionBar #toastHost {
gap: 0.6rem;
}
/* Every button in this modal is one a dealer presses with players waiting. */
#potWizardBody .holdemBtn {
min-height: 48px;
justify-content: center;
}
/* The confirm is the whole point of the step, so it gets the whole width rather
than being sized to the length of its own label. */
.potWizardRound > .holdemBtn {
width: 100%;
}
.potWizardActions .holdemBtn {
flex: 1;
}
.potWizardSummary li {
font-variant-numeric: tabular-nums;
}
@media (max-width: 700px) {
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
}
.potWizardChoice {
min-height: 60px;
}
}
@media (max-width: 900px) {
#holdemLayout {
grid-template-columns: 1fr;
@@ -2142,8 +1687,7 @@ body.hasActionBar #toastHost {
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
#seatsLayer,
#betsLayer {
#seatsLayer {
inset: 26px 44px;
}
@@ -2232,10 +1776,11 @@ body.hasActionBar #toastHost {
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
/* Every text or number field in the form, rather than a list of the ones that
existed when this was written. The stack field was added later and sat at 28px
next to its 44px neighbours precisely because it wasn't on that list. */
.seatEditorForm input:not([type="checkbox"]):not([type="color"]) {
.seatEditorForm .seatNameInput,
.seatEditorForm .amountOverrideInput,
.seatEditorForm .chipCountInput,
.seatEditorForm .customFieldLabel,
.seatEditorForm .customFieldValue {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
@@ -2299,77 +1844,6 @@ body.hasActionBar #toastHost {
margin-top: 0.4rem;
}
/* The sidebar's own controls never got the touch pass the seat form did — these
are settings a dealer changes at the table, on the same phone. Written against
the panel rather than each field, so a setting added later inherits it. */
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and were 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again in a row this
narrow, which is why it stayed 32px wide with the height applied. */
.chipColorInput {
flex: none;
width: 44px;
height: 44px;
}
/* A bare checkbox can't grow into a target, but the row it sits in can — the
label is what a tap actually lands on. */
.panelBody .fieldRow {
min-height: 44px;
}
/* A bare checkbox with no label box around it to grow. */
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
/* An underlined word is a 14px-tall target; give it room without making it look
like a button. */
.linkBtn {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 0.4rem;
}
/* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck. So the disc keeps its size and
an invisible pad around it takes the tap. It reaches outward, away from the
puck's own tap target, so pressing the seat still opens the seat. */
.dealerChip::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.dealerChip {
overflow: visible;
}
.toastAction {
min-height: 44px;
}
@@ -45,171 +45,6 @@ body {
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 {
@@ -269,12 +104,6 @@ body {
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;
@@ -74,16 +74,6 @@ const ActionBar = {
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];
@@ -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;
}
@@ -18,7 +18,7 @@ const Betting = {
},
round(amount) {
return Money.round(amount);
return Math.round((Number(amount) || 0) * 100) / 100;
},
// ---- Reading the table ----
@@ -70,28 +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;
},
// 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
@@ -169,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 ----
@@ -197,52 +184,15 @@ 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) {
return this.liveSeats(state).length > 1 && !this.canStillBet(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.
beginHand(state) {
@@ -257,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.
@@ -35,19 +35,9 @@ 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.format(row.in),
Money.format(row.won),
Money.formatSigned(row.net),
Money.format(row.boughtIn || 0),
Money.format(row.chipsNow || 0),
Money.formatSigned(row.cashPosition || 0),
]);
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
});
}
+19 -80
View File
@@ -1,23 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
// 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();
@@ -25,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,
@@ -67,15 +57,6 @@ document.addEventListener("DOMContentLoaded", () => {
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
player.stack = Money.round(Betting.stackOf(state, player) + amount);
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
});
}),
};
Render.mount(state, hooks);
@@ -114,7 +95,7 @@ document.addEventListener("DOMContentLoaded", () => {
resolvePot: openPotWizard,
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
@@ -123,7 +104,10 @@ document.addEventListener("DOMContentLoaded", () => {
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
restoreState(step.snapshot);
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, step.snapshot);
hooks.save(state);
Render.renderAll();
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
},
});
@@ -170,7 +154,7 @@ 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.
@@ -181,44 +165,19 @@ document.addEventListener("DOMContentLoaded", () => {
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 => {
@@ -301,26 +260,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();
});
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.addEventListener("keydown", e => {
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
@@ -1,30 +1,7 @@
// 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;
},
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
@@ -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,15 +18,6 @@ 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),
@@ -58,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);
@@ -128,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);
@@ -183,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();
@@ -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 === "";
@@ -69,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) {
@@ -190,8 +118,6 @@ const Render = {
layer.appendChild(node);
});
this.renderBets();
},
isRedSuit(card) {
@@ -242,13 +168,11 @@ 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);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
@@ -491,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", () => {
@@ -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),
@@ -35,7 +35,7 @@ const Shortcuts = {
// An overlay owns the keyboard while it's up: the confirm dialog already binds
// Enter and Escape, and the card picker and seat menu are modal.
overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
@@ -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;
},
};
@@ -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,
};
},
@@ -81,7 +79,6 @@ const HoldemState = {
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;
@@ -159,53 +156,14 @@ 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;
},
// 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;
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) {
@@ -278,32 +236,13 @@ 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]));
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);
},
};
-43
View File
@@ -1,43 +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) |
## 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.
-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));
});
});
-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`);
}
}
});
});
-78
View File
@@ -1,78 +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");
// 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,
PotCalculator,
SplitCalculator,
Betting,
HoldemState,
makeTable,
chipsOnTable,
contributions,
};
-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/);
});
});
-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);
});
});