Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a488bbf832 | ||
|
|
9ee4816fa4 | ||
|
|
db208a1a86 | ||
|
|
3d63ba50ee | ||
|
|
073ba9a252 | ||
|
|
c1a484b97a | ||
|
|
ae8744672f | ||
|
|
328ef56af5 | ||
|
|
7714e16547 | ||
|
|
cc31cd033b | ||
|
|
f324793732 | ||
|
|
56d9d740b5 | ||
|
|
e5b02b00e4 | ||
|
|
34cd7dbc59 | ||
|
|
4f72dda6fa | ||
|
|
18bb23ad6d | ||
|
|
ae591e4212 | ||
|
|
89d79a0f7d | ||
|
|
d0f75d0c14 | ||
|
|
6b3a7dc09d | ||
|
|
38488f2978 | ||
|
|
7c64749267 |
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
@@ -138,7 +138,7 @@
|
||||
<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>
|
||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
@@ -198,8 +198,8 @@
|
||||
|
||||
<template id="chipLegendRowTemplate">
|
||||
<div class="chipLegendRow">
|
||||
<input type="color" class="chipColorInput" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
|
||||
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
|
||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -253,7 +253,7 @@
|
||||
</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>
|
||||
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@
|
||||
<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>
|
||||
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
|
||||
<p class="rulingsIntro">
|
||||
@@ -314,7 +314,7 @@
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
<div class="cardPickerHeader">
|
||||
<h2 id="cardPickerTitle"></h2>
|
||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
<div id="cardPickerSlots"></div>
|
||||
<div id="cardPickerGrid"></div>
|
||||
@@ -340,17 +340,17 @@
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal">
|
||||
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2>Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
|
||||
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
</div>
|
||||
@@ -374,5 +374,6 @@
|
||||
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<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>
|
||||
@@ -18,7 +23,18 @@
|
||||
<div id="splitDustNote" hidden></div>
|
||||
</div>
|
||||
|
||||
<section class="splitField">
|
||||
<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">
|
||||
<label for="potInput" class="splitLabel">Pot</label>
|
||||
<div class="potInputWrap">
|
||||
<span class="potCurrency">$</span>
|
||||
@@ -28,12 +44,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<section class="splitField evenOnly">
|
||||
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||
</section>
|
||||
|
||||
<section class="splitField">
|
||||
<section class="splitField evenOnly">
|
||||
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||
</section>
|
||||
@@ -43,12 +59,30 @@
|
||||
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
</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" />
|
||||
}
|
||||
@@ -56,5 +90,6 @@
|
||||
@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>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,148 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- Touch ----------
|
||||
A dealer runs this one-handed on a phone with players waiting, so a tap has to
|
||||
register the instant their thumb lands.
|
||||
|
||||
`touch-action: manipulation` is the whole game here. Without it a mobile browser
|
||||
holds every tap for ~300ms to see whether a second one is coming that would mean
|
||||
"zoom in" — on top of which double-tapping a seat, which this tool uses for fold,
|
||||
*is* the browser's zoom gesture, so the dealer's shortcut fights the page zooming
|
||||
under their thumb. `manipulation` keeps panning and pinch-zoom and drops only the
|
||||
double-tap-to-zoom, which is exactly the one we don't want.
|
||||
|
||||
The tap highlight goes with it: every control below gets a real pressed state, and
|
||||
the browser's translucent grey rectangle on top of a designed one just looks like
|
||||
a rendering artefact. Text selection goes too — a fast tap-and-hold on a seat
|
||||
should open the seat menu, not raise a text-selection magnifier over the felt. */
|
||||
.holdemBtn,
|
||||
.seat,
|
||||
.dealerChip,
|
||||
.cardSlot,
|
||||
.linkBtn,
|
||||
.seatMenuBtn,
|
||||
.seatSheetClose,
|
||||
.pickerCard,
|
||||
.actionBtn,
|
||||
.raiseQuickBtn,
|
||||
.actionUndo,
|
||||
.toastAction,
|
||||
.toastClose,
|
||||
.potWizardChoice,
|
||||
.subPanel summary,
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* The grey rectangle goes from every control, fields included — a tapped input
|
||||
already shows a caret and a focus ring, which say the same thing more quietly. */
|
||||
.holdemBtn,
|
||||
.seat,
|
||||
.dealerChip,
|
||||
.cardSlot,
|
||||
.linkBtn,
|
||||
.seatMenuBtn,
|
||||
.seatSheetClose,
|
||||
.pickerCard,
|
||||
.actionBtn,
|
||||
.raiseQuickBtn,
|
||||
.actionUndo,
|
||||
.toastAction,
|
||||
.toastClose,
|
||||
.potWizardChoice,
|
||||
.subPanel summary,
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Text selection is switched off only on the things you press, never on the fields
|
||||
you type into — a dealer editing a player's name still needs to select the text. */
|
||||
.holdemBtn,
|
||||
.seat,
|
||||
.dealerChip,
|
||||
.cardSlot,
|
||||
.linkBtn,
|
||||
.seatMenuBtn,
|
||||
.seatSheetClose,
|
||||
.pickerCard,
|
||||
.actionBtn,
|
||||
.raiseQuickBtn,
|
||||
.actionUndo,
|
||||
.toastAction,
|
||||
.toastClose,
|
||||
.potWizardChoice,
|
||||
.subPanel summary,
|
||||
button,
|
||||
[role="button"] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---------- Pressed states ----------
|
||||
Touch has no hover, so the press is the only feedback a dealer gets that the tap
|
||||
landed — and with the grey highlight suppressed above, these have to carry it.
|
||||
Everything presses the same way it would on a real table: down and slightly
|
||||
darker, no travel time. */
|
||||
.holdemBtn:active:not(:disabled),
|
||||
.linkBtn:active:not(:disabled),
|
||||
.seatMenuBtn:active:not(:disabled),
|
||||
.seatSheetClose:active:not(:disabled),
|
||||
.toastAction:active,
|
||||
.toastClose:active,
|
||||
.potWizardChoice:active,
|
||||
.raiseQuickBtn:active:not(:disabled),
|
||||
.actionUndo:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(1.25);
|
||||
transition-duration: 0.04s;
|
||||
}
|
||||
|
||||
.cardSlot:active,
|
||||
.pickerCard:active:not(:disabled),
|
||||
.dealerChip:active {
|
||||
transform: translateY(1px) scale(0.97);
|
||||
transition-duration: 0.04s;
|
||||
}
|
||||
|
||||
/* The action bar already presses down; the brightness is what it was missing, and
|
||||
it's the one place a dealer taps without looking. */
|
||||
.actionBtn:active:not(:disabled) {
|
||||
filter: brightness(1.3);
|
||||
transition-duration: 0.04s;
|
||||
}
|
||||
|
||||
/* Seats deliberately have no `:active` rule. They already dip via `.seatPressing`,
|
||||
which `pointerdown` adds in render.js — that fires earlier than `:active` resolves,
|
||||
and, more importantly, a seat's `transform` is what positions it around the felt
|
||||
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
|
||||
would drop that translate and throw every pressed seat about 38px off its spot. */
|
||||
|
||||
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.holdemBtn:active:not(:disabled),
|
||||
.linkBtn:active:not(:disabled),
|
||||
.seatMenuBtn:active:not(:disabled),
|
||||
.seatSheetClose:active:not(:disabled),
|
||||
.cardSlot:active,
|
||||
.pickerCard:active:not(:disabled),
|
||||
.dealerChip:active,
|
||||
.raiseQuickBtn:active:not(:disabled),
|
||||
.actionUndo:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#holdemApp {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -77,7 +219,11 @@ body {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
/* Named rather than `all`, so the focus ring isn't animated. With `all` the
|
||||
outline faded in over 150ms, which on keyboard navigation reads as the ring
|
||||
lagging a step behind the Tab key. Nothing else here changes. */
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
|
||||
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -85,10 +231,12 @@ body {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.holdemBtn:hover {
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
border-color: #4a4a4a;
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) {
|
||||
.holdemBtn:hover {
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
border-color: #4a4a4a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.holdemBtnAccent {
|
||||
@@ -97,8 +245,10 @@ body {
|
||||
color: #ecffef;
|
||||
}
|
||||
|
||||
.holdemBtnAccent:hover {
|
||||
background: linear-gradient(180deg, #1a8a44, #0f602c);
|
||||
@media (hover: hover) {
|
||||
.holdemBtnAccent:hover {
|
||||
background: linear-gradient(180deg, #1a8a44, #0f602c);
|
||||
}
|
||||
}
|
||||
|
||||
.holdemBtnDanger {
|
||||
@@ -107,8 +257,10 @@ body {
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
.holdemBtnDanger:hover {
|
||||
background: linear-gradient(180deg, #572525, #401818);
|
||||
@media (hover: hover) {
|
||||
.holdemBtnDanger:hover {
|
||||
background: linear-gradient(180deg, #572525, #401818);
|
||||
}
|
||||
}
|
||||
|
||||
.holdemBtnGhost {
|
||||
@@ -146,7 +298,7 @@ body {
|
||||
}
|
||||
|
||||
.tableHint {
|
||||
color: #888;
|
||||
color: #949494;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
@@ -158,8 +310,10 @@ body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 72%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
#pokerTable {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
@@ -357,7 +511,7 @@ body {
|
||||
}
|
||||
|
||||
.seatEmptyLabel {
|
||||
color: #6d6d6d;
|
||||
color: #8a8a8a;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
@@ -373,7 +527,7 @@ body {
|
||||
top: 2px;
|
||||
left: 6px;
|
||||
font-size: 0.58rem;
|
||||
color: #777;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.seatPuckName {
|
||||
@@ -430,11 +584,16 @@ body {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.seat:hover .dealerChip,
|
||||
.dealerChip:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.seat:hover .dealerChip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dealerChip.active {
|
||||
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
|
||||
border-color: #a9782f;
|
||||
@@ -474,6 +633,13 @@ body {
|
||||
color: #fff7e6;
|
||||
}
|
||||
|
||||
/* A blind the dealer named by hand rather than one the button handed out. Without
|
||||
the ring the table just looks like it's put the blinds in the wrong place. */
|
||||
.blindBadge.blindManual {
|
||||
outline: 1.5px solid #f2f2f2;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||
|
||||
.seatEditorPanel {
|
||||
@@ -567,9 +733,11 @@ body {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.cardSlot:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
@media (hover: hover) {
|
||||
.cardSlot:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.cardSlot.cardRed {
|
||||
@@ -578,7 +746,7 @@ body {
|
||||
|
||||
.cardSlot.cardSlotEmpty {
|
||||
background: #191919;
|
||||
color: #5c5c5c;
|
||||
color: #8a8a8a;
|
||||
border-style: dashed;
|
||||
border-color: #3f3f3f;
|
||||
box-shadow: none;
|
||||
@@ -694,7 +862,7 @@ body {
|
||||
}
|
||||
|
||||
.addChipsCurrency {
|
||||
color: #777;
|
||||
color: #8a8a8a;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
@@ -735,7 +903,7 @@ body {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #a9782f;
|
||||
color: #d8b46a;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: underline;
|
||||
@@ -743,8 +911,10 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkBtn:hover {
|
||||
color: #d8b46a;
|
||||
@media (hover: hover) {
|
||||
.linkBtn:hover {
|
||||
color: #d8b46a;
|
||||
}
|
||||
}
|
||||
|
||||
#rulingsOverlay {
|
||||
@@ -834,7 +1004,7 @@ body {
|
||||
margin: 0;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid #2c2c2c;
|
||||
color: #7a7a7a;
|
||||
color: #8a8a8a;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -909,9 +1079,11 @@ body {
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.seatMenuBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
@media (hover: hover) {
|
||||
.seatMenuBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
}
|
||||
|
||||
.seatMenuBtn.menuDanger {
|
||||
@@ -1037,8 +1209,10 @@ body {
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.pickerCard:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) {
|
||||
.pickerCard:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.pickerCard.cardRed {
|
||||
@@ -1213,12 +1387,14 @@ body {
|
||||
|
||||
.subPanel summary {
|
||||
cursor: pointer;
|
||||
color: #8f8f8f;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.subPanel summary:hover {
|
||||
color: #c5c5c5;
|
||||
@media (hover: hover) {
|
||||
.subPanel summary:hover {
|
||||
color: #c5c5c5;
|
||||
}
|
||||
}
|
||||
|
||||
.subPanelBody {
|
||||
@@ -1249,7 +1425,7 @@ body {
|
||||
|
||||
.fieldHint {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
color: #949494;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1306,7 +1482,7 @@ body {
|
||||
}
|
||||
|
||||
.standingsHeader {
|
||||
color: #777;
|
||||
color: #8a8a8a;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
@@ -1374,7 +1550,7 @@ body {
|
||||
|
||||
.handHistoryTime {
|
||||
margin-left: auto;
|
||||
color: #777;
|
||||
color: #8a8a8a;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@@ -1395,6 +1571,10 @@ body {
|
||||
padding: 0.5rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
/* iOS zooms the whole page in when you focus a field under 16px, and the dealer
|
||||
is then pinching back out to see the table. Every other input here is already
|
||||
at 16px; this one was the straggler. */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Pot wizard ---------- */
|
||||
@@ -1456,10 +1636,12 @@ body.hasActionBar #holdemSidebar {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionUndo:hover {
|
||||
background: #333;
|
||||
border-color: #4f4f4f;
|
||||
color: #eee;
|
||||
@media (hover: hover) {
|
||||
.actionUndo:hover {
|
||||
background: #333;
|
||||
border-color: #4f4f4f;
|
||||
color: #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.actionStreet {
|
||||
@@ -1480,6 +1662,12 @@ body.hasActionBar #holdemSidebar {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actionRunout {
|
||||
margin-left: auto;
|
||||
color: #d8b46a;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.actionWho {
|
||||
margin-left: auto;
|
||||
color: #9c9c9c;
|
||||
@@ -1494,7 +1682,7 @@ body.hasActionBar #holdemSidebar {
|
||||
flex: none;
|
||||
min-width: 3.1rem;
|
||||
text-align: right;
|
||||
color: #7d7d7d;
|
||||
color: #949494;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
@@ -1594,9 +1782,11 @@ body.hasActionBar #holdemSidebar {
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
@media (hover: hover) {
|
||||
.actionBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
}
|
||||
|
||||
.actionBtn:active {
|
||||
@@ -1732,11 +1922,29 @@ body.hasActionBar #holdemSidebar {
|
||||
|
||||
.seatPuckStack {
|
||||
font-size: 0.58rem;
|
||||
color: #8f8f8f;
|
||||
color: #9a9a9a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||
the seat next to it. These step the size down so every digit stays on its own
|
||||
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
|
||||
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
|
||||
than a small one, and the exact figure is what the tool is for. */
|
||||
.seatPuckStack.puckAmountLong,
|
||||
.seatPuckTotal.puckAmountLong {
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.seatPuckStack.puckAmountHuge,
|
||||
.seatPuckTotal.puckAmountHuge {
|
||||
font-size: 0.44rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.blindsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1778,8 +1986,13 @@ body.hasActionBar #holdemSidebar {
|
||||
|
||||
/* Sit above the action bar rather than on top of it, so an Undo never covers the
|
||||
Fold button the dealer is reaching for. */
|
||||
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
|
||||
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
|
||||
moment the raise pad opens — and on a short phone it put the toast, Undo button and
|
||||
all, straight over the bottom seats of the table, so a tap meant for a player hit
|
||||
"Undo" instead. */
|
||||
body.hasActionBar #toastHost {
|
||||
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -1825,8 +2038,10 @@ body.hasActionBar #toastHost {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toastAction:hover {
|
||||
background: rgba(216, 180, 106, 0.22);
|
||||
@media (hover: hover) {
|
||||
.toastAction:hover {
|
||||
background: rgba(216, 180, 106, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
@@ -1836,15 +2051,17 @@ body.hasActionBar #toastHost {
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
color: #949494;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toastClose:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
@media (hover: hover) {
|
||||
.toastClose:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Confirmation dialog ---------- */
|
||||
@@ -1962,20 +2179,77 @@ 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.5rem;
|
||||
gap: 0.7rem;
|
||||
min-height: 56px;
|
||||
background: #141414;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.potWizardChoice:hover {
|
||||
border-color: #555;
|
||||
@media (hover: hover) {
|
||||
.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 {
|
||||
@@ -1996,6 +2270,37 @@ 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;
|
||||
@@ -2018,8 +2323,13 @@ body.hasActionBar #toastHost {
|
||||
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
||||
editor up as a bottom sheet over the table. */
|
||||
@media (max-width: 700px) {
|
||||
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
|
||||
sit above it. Without it the table just carries on underneath the bar and the
|
||||
lower seats of the oval are behind the Fold button — measured at 360x640, where
|
||||
62px of felt was covered. `--actionBarHeight` is published by actionBar.js and
|
||||
is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */
|
||||
#holdemApp {
|
||||
padding: 1rem 0.75rem 2rem;
|
||||
padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
#holdemHeader {
|
||||
@@ -2140,11 +2450,10 @@ 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. */
|
||||
.seatEditorForm .seatNameInput,
|
||||
.seatEditorForm .amountOverrideInput,
|
||||
.seatEditorForm .chipCountInput,
|
||||
.seatEditorForm .customFieldLabel,
|
||||
.seatEditorForm .customFieldValue {
|
||||
/* 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"]) {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
@@ -2208,6 +2517,77 @@ 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;
|
||||
}
|
||||
@@ -2221,3 +2601,134 @@ body.hasActionBar #toastHost {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- A phone on its side ----------
|
||||
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
|
||||
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
|
||||
— so a landscape rule placed any earlier is overridden at equal specificity and
|
||||
does nothing, which is exactly what happened on the first attempt.
|
||||
|
||||
Landscape is where this layout falls apart worst: sized from width, the table came
|
||||
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
|
||||
and the rest behind the action bar. Sizing it from the height that's actually left
|
||||
makes every seat reachable without scrolling. The table ends up small, but a seat
|
||||
you have to go hunting for is worse than a seat that's small. */
|
||||
@media (orientation: landscape) and (max-height: 560px) {
|
||||
/* Set outright rather than via padding-bottom: a percentage padding resolves
|
||||
against the containing block's width, not the element's own, so narrowing the
|
||||
wrap on its own left it full height regardless. */
|
||||
#pokerTableWrap {
|
||||
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem);
|
||||
width: min(100%, calc(var(--feltRoom) / 0.72));
|
||||
height: var(--feltRoom);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||
table started below the fold. Here it only needs to be a title bar. */
|
||||
#holdemHeader {
|
||||
margin-bottom: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
font-size: 1.05rem;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.holdemSubtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* There's no vertical room left, but plenty either side of a table this narrow,
|
||||
so the toast moves out of the middle and sits beside the felt instead of on
|
||||
top of it. Centred, it was covering up to six seats at once — and it carries
|
||||
an Undo button, so a tap meant for a player was landing on "Undo". */
|
||||
body.hasActionBar #toastHost,
|
||||
#toastHost {
|
||||
left: auto;
|
||||
right: 0.5rem;
|
||||
max-width: 17rem;
|
||||
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
|
||||
.seat {
|
||||
width: 62px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Short phones, upright ----------
|
||||
A 640px-tall screen spends ~150px on the header before the felt even starts, which
|
||||
left the bottom of the table sitting behind the action bar until you scrolled. The
|
||||
header carries no information a dealer needs mid-hand, so it gives the space up. */
|
||||
@media (max-height: 700px) {
|
||||
/* Nowhere on a screen this short is the toast clear of both the table and the
|
||||
action bar, so it overlaps the one that costs least: it sits over the bar's
|
||||
status line, which is information a dealer can re-read a second later, rather
|
||||
than over the seats and buttons, which are things they need to hit. Overlapping
|
||||
a readout beats covering a control. */
|
||||
body.hasActionBar #toastHost {
|
||||
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 700px) {
|
||||
#holdemHeader {
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
font-size: 1.15rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.holdemSubtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Keyboard focus ----------
|
||||
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
|
||||
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
|
||||
.holdemBtn:focus-visible,
|
||||
.linkBtn:focus-visible,
|
||||
.seatMenuBtn:focus-visible,
|
||||
.seatSheetClose:focus-visible,
|
||||
.pickerCard:focus-visible,
|
||||
.actionBtn:focus-visible,
|
||||
.raiseQuickBtn:focus-visible,
|
||||
.actionUndo:focus-visible,
|
||||
.toastAction:focus-visible,
|
||||
.toastClose:focus-visible,
|
||||
.potWizardChoice:focus-visible,
|
||||
.cardSlot:focus-visible,
|
||||
.subPanel summary:focus-visible,
|
||||
.seat:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #7fc4ff;
|
||||
outline-offset: 2px;
|
||||
/* The ring has to sit above the neighbouring seat that overlaps this one. */
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
|
||||
That shouldn't paint a ring around the whole panel. */
|
||||
[role="dialog"]:focus,
|
||||
[role="alertdialog"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,58 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Same touch handling as the dealer table — see the note there. This page is the one
|
||||
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
|
||||
whether a tap was a double-tap-to-zoom is the last thing it needs. */
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
|
||||
button,
|
||||
[role="button"] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.clearPotBtn:active:not(:disabled),
|
||||
.choiceChip:active:not(:disabled),
|
||||
.stepBtn:active:not(:disabled),
|
||||
.splitBtn:active:not(:disabled),
|
||||
.splitBtnGhost:active:not(:disabled),
|
||||
.unevenFoldBtn:active:not(:disabled),
|
||||
.unevenRemoveBtn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(1.25);
|
||||
transition-duration: 0.04s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clearPotBtn:active:not(:disabled),
|
||||
.choiceChip:active:not(:disabled),
|
||||
.stepBtn:active:not(:disabled),
|
||||
.splitBtn:active:not(:disabled),
|
||||
.splitBtnGhost:active:not(:disabled),
|
||||
.unevenFoldBtn:active:not(:disabled),
|
||||
.unevenRemoveBtn:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#splitApp {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
@@ -45,6 +97,171 @@ 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 {
|
||||
@@ -104,6 +321,12 @@ 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;
|
||||
@@ -165,9 +388,11 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearPotBtn:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
@media (hover: hover) {
|
||||
.clearPotBtn:hover {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Tap targets ---------- */
|
||||
@@ -193,9 +418,11 @@ body {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.choiceChip:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
@media (hover: hover) {
|
||||
.choiceChip:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
}
|
||||
|
||||
.choiceChip.selected {
|
||||
@@ -230,8 +457,10 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepBtn:hover {
|
||||
background: #333;
|
||||
@media (hover: hover) {
|
||||
.stepBtn:hover {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.waysReadout {
|
||||
@@ -268,9 +497,11 @@ body {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.splitBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
@media (hover: hover) {
|
||||
.splitBtn:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||
}
|
||||
}
|
||||
|
||||
.splitBtnGhost {
|
||||
|
||||
@@ -11,9 +11,35 @@ const ActionBar = {
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
},
|
||||
|
||||
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
||||
// tall it is or the table simply runs underneath it — on a 640px-tall phone the
|
||||
// bottom seats of the oval were sitting behind the Fold button. Its height isn't a
|
||||
// constant either: opening the raise pad grows it. Publishing the measured height
|
||||
// as a custom property lets the layout and the toast both stay clear of it without
|
||||
// anyone guessing a number.
|
||||
watchHeight() {
|
||||
const host = document.getElementById("actionBar");
|
||||
if (!host || this.heightWatcher) return;
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.heightWatcher = new ResizeObserver(publish);
|
||||
this.heightWatcher.observe(host);
|
||||
}
|
||||
window.addEventListener("resize", publish);
|
||||
window.addEventListener("orientationchange", publish);
|
||||
publish();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar.
|
||||
isRunning() {
|
||||
return Betting.liveSeats(this.state).length > 0;
|
||||
@@ -27,6 +53,7 @@ const ActionBar = {
|
||||
if (!this.isRunning()) {
|
||||
host.hidden = true;
|
||||
document.body.classList.remove("hasActionBar");
|
||||
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
||||
this.stopTankTimer();
|
||||
this.tankSeatIndex = null;
|
||||
return;
|
||||
@@ -74,6 +101,16 @@ 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];
|
||||
|
||||
@@ -35,7 +35,7 @@ const AddChips = {
|
||||
quickAmounts() {
|
||||
const base = this.baseAmount();
|
||||
return [
|
||||
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
|
||||
{ label: "½ buy-in", value: Money.round(base / 2) },
|
||||
{ label: "Buy-in", value: base },
|
||||
{ label: "2 buy-ins", value: base * 2 },
|
||||
];
|
||||
@@ -83,7 +83,7 @@ const AddChips = {
|
||||
confirm() {
|
||||
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
|
||||
this.close();
|
||||
if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100);
|
||||
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const Betting = {
|
||||
},
|
||||
|
||||
round(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100) / 100;
|
||||
return Money.round(amount);
|
||||
},
|
||||
|
||||
// ---- Reading the table ----
|
||||
@@ -39,11 +39,16 @@ const Betting = {
|
||||
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||
},
|
||||
|
||||
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
||||
// the stack field used to come back as-is — which then flowed into putIn(), where
|
||||
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
||||
stackOf(state, player) {
|
||||
const own = player.stack;
|
||||
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
||||
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
||||
return Math.max(0, this.round(own));
|
||||
}
|
||||
const fallback = Number(state.defaultStack);
|
||||
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
||||
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
|
||||
},
|
||||
|
||||
toCall(state, player) {
|
||||
@@ -70,15 +75,28 @@ 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 && this.stackOf(state, player) >= 0) return index;
|
||||
if (player.inHand && !player.folded) return index;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
seatAt(state, fromIndex, step) {
|
||||
// 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;
|
||||
const delta = state.direction === "counterclockwise" ? -step : step;
|
||||
return ((fromIndex + delta) % count + count) % count;
|
||||
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);
|
||||
},
|
||||
|
||||
// A street is done when everyone still live has either matched the bet or has
|
||||
@@ -156,7 +174,7 @@ const Betting = {
|
||||
},
|
||||
|
||||
passAction(state, index) {
|
||||
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
|
||||
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
|
||||
},
|
||||
|
||||
// ---- Street and hand boundaries ----
|
||||
@@ -184,15 +202,74 @@ const Betting = {
|
||||
|
||||
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||
state.actingSeatIndex = this.liveSeats(state).length > 1
|
||||
? this.nextLiveIndex(state, state.dealerSeatIndex)
|
||||
: null;
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
// True while nothing has happened in this hand except the blinds going in. Up to
|
||||
// that point the dealer can still move the button or rename the blinds and have
|
||||
// it cost nothing — which is the state a home game spends a lot of time in,
|
||||
// arguing about whose turn it is before the cards come out.
|
||||
onlyBlindsIn(state) {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// Pulls the blinds back off the table and posts them again from wherever the
|
||||
// button and the blind assignment now say they belong. Refunding first is what
|
||||
// keeps the chip count straight when the button gets passed round three or four
|
||||
// times before anybody is happy with it.
|
||||
repostBlinds(state) {
|
||||
state.seats.forEach(player => {
|
||||
const back = this.committed(player);
|
||||
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
|
||||
player.committed = 0;
|
||||
player.hasActed = false;
|
||||
});
|
||||
this.beginHand(state);
|
||||
},
|
||||
|
||||
// Deals a fresh betting round: blinds posted, action to the seat after the big
|
||||
// blind, which is where a dealer expects to be looking when the cards go out.
|
||||
beginHand(state) {
|
||||
@@ -200,6 +277,11 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
|
||||
// Position is read off the button all through the hand, so it has to be on
|
||||
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||
// brand-new table starts on.
|
||||
HoldemState.ensureButtonOnPlayer(state);
|
||||
|
||||
state.seats.forEach(player => {
|
||||
player.committed = 0;
|
||||
player.hasActed = false;
|
||||
@@ -207,15 +289,26 @@ const Betting = {
|
||||
|
||||
const sb = Number(state.smallBlind) || 0;
|
||||
const bb = Number(state.bigBlind) || 0;
|
||||
const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
|
||||
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
|
||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
|
||||
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
|
||||
if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
|
||||
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);
|
||||
|
||||
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
// Winners get their share put back in front of them.
|
||||
|
||||
@@ -15,6 +15,10 @@ const HandHistoryExport = {
|
||||
}
|
||||
},
|
||||
|
||||
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
|
||||
// text, so the one thing anybody opens this file to do — total a column and see
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = []) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
@@ -25,7 +29,7 @@ const HandHistoryExport = {
|
||||
rows.push([
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.format(entry.pot),
|
||||
Money.forSheet(entry.pot),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
@@ -41,12 +45,12 @@ const HandHistoryExport = {
|
||||
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),
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
Money.forSheet(row.boughtIn || 0),
|
||||
Money.forSheet(row.chipsNow || 0),
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||
// by reference to every module at mount time, so it has to be refilled in place —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
// the old object and quietly ignoring the undo.
|
||||
const snapshotState = () => JSON.parse(JSON.stringify(state));
|
||||
|
||||
const restoreState = snapshot => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
};
|
||||
|
||||
// A gesture on a seat is a shortcut for something the dealer could do the long
|
||||
// way, so each one is undoable the same way the header buttons are.
|
||||
const seatAction = (index, message, mutate) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
const snapshot = snapshotState();
|
||||
mutate(state.seats[index], index);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
@@ -12,10 +29,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
HoldemState.save(state);
|
||||
Render.renderAll();
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
duration: 6000,
|
||||
@@ -42,6 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
@@ -49,21 +64,79 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
|
||||
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
|
||||
toggleDealtIn: index => {
|
||||
const dealtIn = state.seats[index].inHand;
|
||||
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
|
||||
const player = state.seats[index];
|
||||
const dealtIn = player.inHand;
|
||||
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
|
||||
const alreadyIn = Betting.handTotal(player);
|
||||
|
||||
// Somebody walking away mid-hand doesn't get their chips back — they're
|
||||
// dead money the winner collects. Say so, because the pot total pointedly
|
||||
// does not go down and a dealer needs to know that's deliberate.
|
||||
const message = dealtIn
|
||||
? (alreadyIn > 0
|
||||
? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.`
|
||||
: `${nameOf(index)} sits out.`)
|
||||
: (handRunning
|
||||
? `${nameOf(index)} is in from the next hand.`
|
||||
: `${nameOf(index)} is dealt in.`);
|
||||
|
||||
seatAction(index, message, p => {
|
||||
p.inHand = !dealtIn;
|
||||
if (!p.inHand) p.folded = false;
|
||||
if (!p.inHand) {
|
||||
// Their chips stay put; only the fold flag is cleared, since being
|
||||
// out of the hand already covers not winning it.
|
||||
p.folded = false;
|
||||
if (state.actingSeatIndex === index) Betting.passAction(state, index);
|
||||
} else if (handRunning) {
|
||||
// Joining a hand already in progress means sitting this one out —
|
||||
// you can't buy into a pot you haven't been paying for. `folded`
|
||||
// is exactly that state, and the next hand clears it.
|
||||
p.folded = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
|
||||
|
||||
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => {
|
||||
state.dealerSeatIndex = index;
|
||||
// Before anyone has acted the blinds are still just sitting there, so
|
||||
// moving the button moves them too — a home game passes it round several
|
||||
// times before the cards come out, and the posted chips have to follow.
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
|
||||
assignBlind: (index, which) => {
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex;
|
||||
// Naming one blind pins the other where it already is, so the dealer only
|
||||
// has to state the part they're changing. If that would land both on one
|
||||
// player, the other blind moves to the next seat along.
|
||||
let partner = other !== null && other !== undefined && other !== index
|
||||
? other
|
||||
: Betting.nextLiveIndex(state, index);
|
||||
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||
|
||||
const label = which === "sb" ? "small blind" : "big blind";
|
||||
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
|
||||
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 = Math.round((Betting.stackOf(state, player) + amount) * 100) / 100;
|
||||
player.buyIns = Math.round(((Number(player.buyIns) || 0) + amount) * 100) / 100;
|
||||
player.stack = Money.round(Betting.stackOf(state, player) + amount);
|
||||
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -104,7 +177,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
resolvePot: openPotWizard,
|
||||
|
||||
beginAction: label => {
|
||||
actionHistory.push({ label, snapshot: JSON.parse(JSON.stringify(state)) });
|
||||
actionHistory.push({ label, snapshot: snapshotState() });
|
||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||
},
|
||||
|
||||
@@ -113,10 +186,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
undoLast: () => {
|
||||
const step = actionHistory.pop();
|
||||
if (!step) return;
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, step.snapshot);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
restoreState(step.snapshot);
|
||||
Toast.show(`Took back ${step.label}.`, { duration: 2500 });
|
||||
},
|
||||
});
|
||||
@@ -163,7 +233,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 = JSON.parse(JSON.stringify(state));
|
||||
const snapshot = snapshotState();
|
||||
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.
|
||||
@@ -174,19 +244,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
onAction: () => {
|
||||
Object.keys(state).forEach(key => delete state[key]);
|
||||
Object.assign(state, snapshot);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", e => {
|
||||
HoldemState.resizeSeats(state, Number(e.target.value));
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
// 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("directionSelect").addEventListener("change", e => {
|
||||
@@ -278,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (e.key === "Enter") AddChips.confirm();
|
||||
});
|
||||
|
||||
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
||||
@@ -285,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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 => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||
//
|
||||
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||
// confirm — opened and closed by five different modules, and each one was missing
|
||||
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||
// 1 of 5 closed on Escape.
|
||||
//
|
||||
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||
// failure this is fixing in the first place.
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
open: [],
|
||||
|
||||
mount() {
|
||||
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||
// table-level shortcut sees them.
|
||||
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||
},
|
||||
|
||||
watch(overlay) {
|
||||
const sync = () => {
|
||||
const isOpen = !overlay.hidden;
|
||||
const known = this.open.some(entry => entry.overlay === overlay);
|
||||
if (isOpen && !known) this.onOpened(overlay);
|
||||
else if (!isOpen && known) this.onClosed(overlay);
|
||||
};
|
||||
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||
sync();
|
||||
},
|
||||
|
||||
sheetOf(overlay) {
|
||||
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||
},
|
||||
|
||||
focusableIn(overlay) {
|
||||
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||
if (el.closest("[hidden]")) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
},
|
||||
|
||||
onOpened(overlay) {
|
||||
const sheet = this.sheetOf(overlay);
|
||||
// aria-modal is only true while the thing is actually open, so it's set here
|
||||
// rather than baked into the markup.
|
||||
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||
|
||||
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||
|
||||
// Focus the first real control. A dialog that leaves focus on the button
|
||||
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||
// Content-only sheets get focus on the sheet so the title is announced.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||
sheet.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
onClosed(overlay) {
|
||||
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||
if (index === -1) return;
|
||||
const [entry] = this.open.splice(index, 1);
|
||||
|
||||
const sheet = this.sheetOf(overlay);
|
||||
if (sheet) sheet.removeAttribute("aria-modal");
|
||||
|
||||
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||
// drops focus to the top of the document and the next Tab starts from the
|
||||
// page header.
|
||||
const back = entry.returnFocus;
|
||||
if (back && back.isConnected && typeof back.focus === "function") {
|
||||
back.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
|
||||
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||
// overlay directly.
|
||||
dismiss(overlay) {
|
||||
const closer = overlay.querySelector("[data-modal-close]");
|
||||
if (closer) closer.click();
|
||||
else overlay.hidden = true;
|
||||
},
|
||||
|
||||
onKeyDown(event) {
|
||||
const top = this.topmost();
|
||||
if (!top) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dismiss(top.overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||
const focusable = this.focusableIn(top.overlay);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Modal;
|
||||
}
|
||||
@@ -1,10 +1,51 @@
|
||||
// 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.
|
||||
// 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.
|
||||
const Money = {
|
||||
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
|
||||
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
|
||||
// exact `> 0` would treat that player as still having chips to bet.
|
||||
EPSILON: 0.004,
|
||||
|
||||
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
|
||||
toCents(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
},
|
||||
|
||||
fromCents(cents) {
|
||||
return cents / 100;
|
||||
},
|
||||
|
||||
round(amount) {
|
||||
return this.toCents(amount) / 100;
|
||||
},
|
||||
|
||||
// Cents only when there are cents.
|
||||
//
|
||||
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||||
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||||
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||||
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||||
// 25c-blind game reads exactly as it did.
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const hasCents = this.toCents(value) % 100 !== 0;
|
||||
return `$${value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
},
|
||||
|
||||
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||||
// and no thousands separators, because those are what make a column of money
|
||||
// import as text and refuse to add up.
|
||||
forSheet(amount) {
|
||||
return (this.toCents(amount) / 100).toFixed(2);
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
|
||||
// that player put in the pot and is whittled down round by round as money gets paid out.
|
||||
const PotCalculator = {
|
||||
EPSILON: 0.004,
|
||||
EPSILON: Money.EPSILON,
|
||||
|
||||
// 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,24 +33,39 @@ 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) {
|
||||
const live = players.filter(p => p.contribution > this.EPSILON);
|
||||
// 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);
|
||||
if (live.length === 0) return [];
|
||||
|
||||
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
|
||||
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
|
||||
|
||||
const layers = [];
|
||||
let floor = 0;
|
||||
heights.forEach(height => {
|
||||
const band = height - floor;
|
||||
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
|
||||
const payers = live.filter(p => p.cents >= height);
|
||||
const amount = band * payers.length;
|
||||
const eligible = payers.filter(p => !p.folded).map(p => p.id);
|
||||
|
||||
// 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 });
|
||||
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.
|
||||
}
|
||||
floor = height;
|
||||
});
|
||||
@@ -66,7 +81,7 @@ const PotCalculator = {
|
||||
previous.eligible.every(id => layer.eligible.includes(id));
|
||||
|
||||
if (sameEligibility) {
|
||||
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
|
||||
previous.amount = Money.round(previous.amount + layer.amount);
|
||||
} else {
|
||||
merged.push({ ...layer });
|
||||
}
|
||||
@@ -79,21 +94,28 @@ const PotCalculator = {
|
||||
},
|
||||
|
||||
// Chips nobody could call, which the dealer pushes back rather than into the pot.
|
||||
// Anything above the biggest live contribution was never contested — most often an
|
||||
// overbet by someone who then folded.
|
||||
//
|
||||
// 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.
|
||||
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 (liveCents.length === 0) return [];
|
||||
if (contributing.filter(p => !p.folded).length === 0) return [];
|
||||
|
||||
const cap = Math.max(...liveCents);
|
||||
const cents = contributing.map(p => Money.toCents(p.contribution));
|
||||
|
||||
return contributing
|
||||
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
|
||||
.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) };
|
||||
})
|
||||
.filter(r => r.amount > this.EPSILON);
|
||||
},
|
||||
|
||||
@@ -113,12 +135,12 @@ const PotCalculator = {
|
||||
if (e.remaining <= this.EPSILON) return e;
|
||||
const used = Math.min(e.remaining, cap);
|
||||
payoutPool += used;
|
||||
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
|
||||
return { ...e, remaining: Money.round(e.remaining - used) };
|
||||
});
|
||||
|
||||
payoutPool = Math.round(payoutPool * 100) / 100;
|
||||
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
|
||||
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
|
||||
payoutPool = Money.round(payoutPool);
|
||||
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
|
||||
let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length;
|
||||
|
||||
const perWinner = winnerIds
|
||||
.filter(id => winners.some(w => w.id === id))
|
||||
@@ -133,7 +155,7 @@ const PotCalculator = {
|
||||
i++;
|
||||
}
|
||||
|
||||
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
|
||||
const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) }));
|
||||
const remainingContenders = this.getContenders(updatedEntries);
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,10 +19,21 @@ const PotWizard = {
|
||||
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),
|
||||
folded: p.folded,
|
||||
// A seat switched off mid-hand still has its chips in the pot, but it
|
||||
// can no more win them than a folded one can — same dead money, and the
|
||||
// wizard must not offer it as a winner.
|
||||
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||
}));
|
||||
this.entries = PotCalculator.buildEntries(contributions);
|
||||
// Kept so the wizard can show the whole breakdown up front, not just the layer
|
||||
@@ -120,13 +131,46 @@ 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;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
|
||||
// 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);
|
||||
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);
|
||||
|
||||
@@ -15,12 +15,28 @@ 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();
|
||||
@@ -127,7 +143,112 @@ 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 === "";
|
||||
|
||||
@@ -16,6 +16,10 @@ const Render = {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.selectedSeatIndex = null;
|
||||
// A reset hands over a different table entirely, so nothing cached about the
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
@@ -158,7 +162,7 @@ const Render = {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
layer.innerHTML = "";
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -172,15 +176,26 @@ const Render = {
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
dealerChip.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
this.state.dealerSeatIndex = index;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.hooks.onGiveButton(index);
|
||||
});
|
||||
|
||||
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
|
||||
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
|
||||
const sbBadge = node.querySelector(".sbBadge");
|
||||
const bbBadge = node.querySelector(".bbBadge");
|
||||
sbBadge.classList.toggle("visible", index === sbIndex);
|
||||
bbBadge.classList.toggle("visible", index === bbIndex);
|
||||
// A blind the dealer named by hand isn't where the button would have put
|
||||
// it, so it's marked — otherwise the table looks like it's just wrong.
|
||||
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
|
||||
// blinds are positional, and classList.toggle with an undefined second
|
||||
// argument *flips* the class instead of clearing it, so the marker
|
||||
// appeared and disappeared on alternate renders.
|
||||
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||||
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
@@ -250,11 +265,14 @@ const Render = {
|
||||
const total = handTotal > 0
|
||||
? handTotal
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||
const totalEl = node.querySelector(".seatPuckTotal");
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||
this.fitAmount(stackEl);
|
||||
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
@@ -263,6 +281,17 @@ const Render = {
|
||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||
},
|
||||
|
||||
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const length = (el.textContent || "").length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
// (which live in a separate panel) never lose focus.
|
||||
refreshPuck(index) {
|
||||
@@ -678,24 +707,57 @@ const Render = {
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
// toLocaleTimeString is the single most expensive call in the history panel, and a
|
||||
// logged hand's timestamp never changes, so the answer is worth keeping.
|
||||
handTimeCache: new Map(),
|
||||
|
||||
formatHandTime(timestamp) {
|
||||
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
|
||||
let formatted = "";
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
formatted = "";
|
||||
}
|
||||
this.handTimeCache.set(timestamp, formatted);
|
||||
return formatted;
|
||||
},
|
||||
|
||||
renderHandHistory() {
|
||||
// Cheap stand-in for "has the log changed". Entries are immutable once written and
|
||||
// only ever added at the front, so the length, the counter and the newest row
|
||||
// between them catch a recorded hand, a cleared history and an undo.
|
||||
historySignature() {
|
||||
const history = this.state.handHistory || [];
|
||||
const newest = history[0];
|
||||
return [
|
||||
history.length,
|
||||
this.state.handNumber || 1,
|
||||
newest ? newest.handNumber : "",
|
||||
newest ? newest.timestamp : "",
|
||||
newest ? newest.pot : "",
|
||||
].join("|");
|
||||
},
|
||||
|
||||
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||
// is skipped unless it actually did change. Everything else on the table reflects
|
||||
// the current hand and still redraws every time.
|
||||
renderHandHistory(force = false) {
|
||||
const signature = this.historySignature();
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
// Built off-document so the panel is laid out once instead of once per row.
|
||||
const fragment = document.createDocumentFragment();
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -706,8 +768,10 @@ const Render = {
|
||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
|
||||
list.appendChild(row);
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
list.innerHTML = "";
|
||||
list.appendChild(fragment);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,24 @@ const SeatMenu = {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||
// once somebody has called, moving a blind would mean unpicking a bet that
|
||||
// has already been answered, and that's a decision for the dealer's mouth,
|
||||
// not a menu item. Which blind a seat already has is shown rather than
|
||||
// offered again.
|
||||
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||
const blinds = HoldemState.getBlindSeats(this.state);
|
||||
if (blinds.sbIndex !== index) {
|
||||
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
}
|
||||
if (blinds.bbIndex !== index) {
|
||||
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
}
|
||||
if (this.state.blindOverride) {
|
||||
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
@@ -32,14 +32,12 @@ const Shortcuts = {
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
},
|
||||
|
||||
// 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.
|
||||
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||
// are open, rather than listing them, means a sheet added later can't quietly
|
||||
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||
// them instead of being told about each one.
|
||||
overlayOpen() {
|
||||
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
|
||||
.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return el && !el.hidden;
|
||||
});
|
||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const SplitCalculator = {
|
||||
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||
toCents(amount) {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
return Money.toCents(amount);
|
||||
},
|
||||
|
||||
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 (
|
||||
Math.round(result.each * 100) * result.players +
|
||||
Math.round(result.chip * 100) * result.oddChips +
|
||||
Math.round(result.dust * 100)
|
||||
) / 100;
|
||||
return Money.fromCents(
|
||||
Money.toCents(result.each) * result.players +
|
||||
Money.toCents(result.chip) * result.oddChips +
|
||||
Money.toCents(result.dust)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ const HoldemState = {
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
// Set when the dealer names the blinds by hand instead of letting them
|
||||
// follow the button — { sbIndex, bbIndex }. Home games do this constantly
|
||||
// ("you had it last time", someone arriving late owing a blind), and the
|
||||
// button order is the wrong answer often enough that it has to be
|
||||
// overridable. Cleared at the start of every hand.
|
||||
blindOverride: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -73,7 +79,8 @@ const HoldemState = {
|
||||
// Tables saved before live betting existed are missing its fields; fill
|
||||
// them in rather than making the dealer reset a table mid-session.
|
||||
const defaults = this.createDefault();
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"]
|
||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||
"actingSeatIndex", "blindOverride"]
|
||||
.forEach(key => {
|
||||
if (parsed[key] === undefined) parsed[key] = defaults[key];
|
||||
});
|
||||
@@ -124,9 +131,14 @@ const HoldemState = {
|
||||
},
|
||||
|
||||
// Contribution the table calculator will treat this player as having put in.
|
||||
//
|
||||
// Chips already pushed into the middle count whether or not the player is still
|
||||
// in the hand. Someone who storms off, gets taken out by the dealer, or is
|
||||
// dealt out mid-hand leaves their money on the felt — it's dead money owed to
|
||||
// whoever wins, and it is not theirs to take back. Reading `inHand` first is
|
||||
// how those chips used to disappear out of the pot the instant a seat was
|
||||
// switched off, quietly shrinking the payout by whatever they had posted.
|
||||
computeContribution(player, chipLegend, basePotAmount) {
|
||||
if (!player.inHand) return 0;
|
||||
|
||||
const override = player.amountOverride;
|
||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||
const parsed = Number(override);
|
||||
@@ -139,10 +151,22 @@ const HoldemState = {
|
||||
}, 0);
|
||||
if (chipTotal > 0) return chipTotal;
|
||||
|
||||
// The table-wide default is the one thing that stays tied to being dealt in:
|
||||
// it's a stand-in for "everyone put the same amount in", so applying it to
|
||||
// empty chairs would invent a contribution from every seat nobody is using.
|
||||
if (!player.inHand) return 0;
|
||||
|
||||
const base = Number(basePotAmount);
|
||||
return Number.isNaN(base) ? 0 : Math.max(0, base);
|
||||
},
|
||||
|
||||
// Anyone whose chips are in the pot, whether or not they're still in the hand.
|
||||
// A seat that's been switched off mid-hand is dead money: it counts towards the
|
||||
// pot but can never be picked as a winner, exactly like a fold.
|
||||
isDeadMoney(player) {
|
||||
return !player.inHand;
|
||||
},
|
||||
|
||||
totalPot(state) {
|
||||
return state.seats.reduce(
|
||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
@@ -159,18 +183,101 @@ const HoldemState = {
|
||||
return ((fromIndex + delta) % count + count) % count;
|
||||
},
|
||||
|
||||
// The button passes to the next player, not the next chair. Moving it one seat at
|
||||
// a time parks it on empty chairs — with four players spread around ten seats it
|
||||
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
||||
// supposed to come round to each player at the same rate, and they don't if the
|
||||
// button is rotating past chairs nobody is sitting in.
|
||||
advanceButton(state) {
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) {
|
||||
state.dealerSeatIndex = index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
|
||||
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
},
|
||||
|
||||
// Seats dealt into this hand, in blind order starting from the seat after the
|
||||
// button. Blinds follow the players, not the chairs — the seats immediately left
|
||||
// of the button are empty often enough that counting chairs posts them to nobody.
|
||||
liveOrderFromButton(state) {
|
||||
const order = [];
|
||||
for (let step = 1; step <= state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) order.push(index);
|
||||
}
|
||||
return order;
|
||||
},
|
||||
|
||||
// A button sitting on an empty chair is only cosmetic until you try to read
|
||||
// position off it. On the very first hand of a fresh table it starts on seat 1
|
||||
// whether or not anybody is sitting there, and a table that empties out around it
|
||||
// leaves it stranded. Move it to a real player before a hand is dealt.
|
||||
ensureButtonOnPlayer(state) {
|
||||
if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return;
|
||||
for (let step = 0; step < state.seatCount; step++) {
|
||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||
if (state.seats[index].inHand) {
|
||||
state.dealerSeatIndex = index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// A hand-picked blind is only usable if that seat is actually in the hand — a
|
||||
// dealer who names a blind and then deals that player out shouldn't have the
|
||||
// table quietly post to nobody.
|
||||
validBlindOverride(state) {
|
||||
const o = state.blindOverride;
|
||||
if (!o) return null;
|
||||
const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand;
|
||||
if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null;
|
||||
return o;
|
||||
},
|
||||
|
||||
// Who posts what. Heads-up is its own rule: with two players the button posts the
|
||||
// small blind, which is the opposite of the usual order.
|
||||
getBlindSeats(state) {
|
||||
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
|
||||
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
|
||||
const live = this.liveOrderFromButton(state);
|
||||
const dealerIndex = state.dealerSeatIndex;
|
||||
|
||||
// A blind the dealer named by hand wins over anything positional. Heads-up is
|
||||
// still reported honestly so the betting order stays right — who posts what
|
||||
// is the dealer's call, but two-handed play acting in reverse is a rule.
|
||||
const override = this.validBlindOverride(state);
|
||||
if (override) {
|
||||
return {
|
||||
dealerIndex,
|
||||
sbIndex: override.sbIndex,
|
||||
bbIndex: override.bbIndex,
|
||||
headsUp: live.length === 2,
|
||||
manual: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (live.length < 2) {
|
||||
return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false };
|
||||
}
|
||||
|
||||
if (live.length === 2) {
|
||||
const buttonIsPlaying = state.seats[dealerIndex].inHand;
|
||||
const sbIndex = buttonIsPlaying ? dealerIndex : live[0];
|
||||
const bbIndex = live.find(i => i !== sbIndex);
|
||||
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
||||
}
|
||||
|
||||
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
|
||||
},
|
||||
|
||||
startNextHand(state) {
|
||||
this.advanceButton(state);
|
||||
// A hand-picked blind applies to the hand it was picked for. Carrying it into
|
||||
// the next one would freeze the blinds on two players for the rest of the
|
||||
// night, which is the opposite of what naming them once is for.
|
||||
state.blindOverride = null;
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -179,6 +286,8 @@ const HoldemState = {
|
||||
p.committed = 0;
|
||||
p.hasActed = false;
|
||||
});
|
||||
// Anyone who joined mid-hand is a full player again from here.
|
||||
this.ensureButtonOnPlayer(state);
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
@@ -246,8 +355,8 @@ const HoldemState = {
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0;
|
||||
const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 0;
|
||||
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 —
|
||||
@@ -257,12 +366,12 @@ const HoldemState = {
|
||||
|
||||
return {
|
||||
...t,
|
||||
in: Math.round(t.in * 100) / 100,
|
||||
won: Math.round(t.won * 100) / 100,
|
||||
net: Math.round((t.won - t.in) * 100) / 100,
|
||||
in: Money.round(t.in),
|
||||
won: Money.round(t.won),
|
||||
net: Money.round(t.won - t.in),
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.net - a.net);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,198 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||
// noticing every way the log can actually change — a stale panel would have the
|
||||
// dealer reading last hand's payouts while settling this one.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// historySignature() reads Render.state, so point it at a table for each case.
|
||||
const signatureOf = state => {
|
||||
Render.state = state;
|
||||
return Render.historySignature();
|
||||
};
|
||||
|
||||
const tableWithHands = (count) => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
for (let i = 0; i < count; i++) {
|
||||
state.seats[0].amountOverride = 2;
|
||||
state.seats[1].amountOverride = 2;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the history panel knows when to redraw", () => {
|
||||
test("nothing changes while a hand is being played", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
|
||||
// A whole street of betting touches no part of the log.
|
||||
state.seats[0].committed = 5;
|
||||
state.seats[1].folded = true;
|
||||
state.actingSeatIndex = 2;
|
||||
state.street = "flop";
|
||||
|
||||
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||
});
|
||||
|
||||
test("settling a hand changes it", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("clearing the history changes it", () => {
|
||||
const state = tableWithHands(5);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.clearHistory(state);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("undoing back to a shorter log changes it", () => {
|
||||
const state = tableWithHands(2);
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||
const afterRecord = signatureOf(state);
|
||||
|
||||
// The undo path swaps the state's contents in place.
|
||||
Object.keys(state).forEach(k => delete state[k]);
|
||||
Object.assign(state, snapshot);
|
||||
|
||||
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||
});
|
||||
|
||||
test("two hands that differ only in payout are told apart", () => {
|
||||
const a = tableWithHands(1);
|
||||
const b = tableWithHands(1);
|
||||
// Same count and hand number; different pot.
|
||||
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
|
||||
test("the hand counter moving on changes it", () => {
|
||||
// The counter badge is drawn by the same function, so a new hand number has
|
||||
// to invalidate even when the log itself is untouched.
|
||||
const state = tableWithHands(2);
|
||||
const before = signatureOf(state);
|
||||
state.handNumber += 1;
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("an empty log has a stable signature of its own", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||
const first = signatureOf(state);
|
||||
assert.equal(signatureOf(state), first);
|
||||
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand-time cache", () => {
|
||||
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||
const first = Render.formatHandTime(stamp);
|
||||
assert.equal(Render.handTimeCache.size, 1);
|
||||
|
||||
const second = Render.formatHandTime(stamp);
|
||||
assert.equal(second, first);
|
||||
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||
});
|
||||
|
||||
test("different timestamps still format differently", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(Render.handTimeCache.size, 2);
|
||||
});
|
||||
|
||||
test("a nonsense timestamp doesn't throw", () => {
|
||||
Render.handTimeCache.clear();
|
||||
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Loads the dealer's browser modules into Node so they can be tested directly.
|
||||
//
|
||||
// The modules are plain script-tag files: each one declares a `const` namespace and
|
||||
// ends with a `module.exports` guard. In the browser they find each other as globals,
|
||||
// so the same names have to exist on `globalThis` here or `Betting` can't reach
|
||||
// `HoldemState`. Load order matches the <script> order in TexasHoldem.cshtml.
|
||||
const path = require("path");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
|
||||
function load(name) {
|
||||
const mod = require(path.join(SRC, `${name}.js`));
|
||||
globalThis[name.charAt(0).toUpperCase() + name.slice(1)] = mod;
|
||||
return mod;
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
const Render = require(path.join(SRC, "render.js")).Render;
|
||||
globalThis.Render = Render;
|
||||
|
||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
// in with `stack` chips each. Everything else is the app's own defaults, so a test
|
||||
// that forgets to set a field gets whatever a real dealer would get.
|
||||
function makeTable({ seatCount = 6, seated = null, stack = 100, smallBlind = 1, bigBlind = 2, dealerSeatIndex = 0 } = {}) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seatCount);
|
||||
state.smallBlind = smallBlind;
|
||||
state.bigBlind = bigBlind;
|
||||
state.dealerSeatIndex = dealerSeatIndex;
|
||||
|
||||
const sitting = seated || state.seats.map((_, i) => i);
|
||||
state.seats.forEach((player, index) => {
|
||||
player.inHand = sitting.includes(index);
|
||||
player.stack = sitting.includes(index) ? stack : null;
|
||||
player.name = `P${index + 1}`;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// Total chips the table is accountable for: what's in front of everyone, plus what
|
||||
// they've pushed in this street, plus what's already banked into the pot.
|
||||
function chipsOnTable(state) {
|
||||
return Money.round(
|
||||
state.seats.reduce(
|
||||
(sum, p) => sum + (Number(p.stack) || 0) + (Number(p.committed) || 0) + (Number(p.amountOverride) || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Contributions in the shape PotCalculator wants them.
|
||||
function contributions(state) {
|
||||
return state.seats
|
||||
.filter(p => p.inHand)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
contribution: Betting.handTotal(p),
|
||||
folded: p.folded,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
Betting,
|
||||
HoldemState,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about
|
||||
// whose blind it is, and pass the button round three times before anyone deals.
|
||||
// These are the cases the positional rules quietly get wrong.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load");
|
||||
|
||||
const potOf = state => HoldemState.totalPot(state);
|
||||
|
||||
describe("a player leaves in the middle of a hand", () => {
|
||||
test("their chips stay in the pot", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const before = potOf(state);
|
||||
assert.equal(before, 8);
|
||||
|
||||
state.seats[1].inHand = false; // dealer takes them out
|
||||
|
||||
assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out");
|
||||
});
|
||||
|
||||
test("they cannot be paid out of a pot they walked away from", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
state.seats[1].inHand = false;
|
||||
|
||||
const players = state.seats
|
||||
.filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
folded: p.folded || HoldemState.isDeadMoney(p),
|
||||
}));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win");
|
||||
assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for");
|
||||
});
|
||||
|
||||
test("taking out the player who is to act moves the action on", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
const acting = state.actingSeatIndex;
|
||||
|
||||
state.seats[acting].inHand = false;
|
||||
Betting.passAction(state, acting);
|
||||
|
||||
assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left");
|
||||
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("an empty chair still contributes nothing, base pot or not", () => {
|
||||
// The table-wide default must not be read as an ante from seats nobody is in.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 });
|
||||
state.basePotAmount = 25;
|
||||
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a player joins in the middle of a hand", () => {
|
||||
test("they sit the hand out rather than joining a pot they never paid into", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
// Walks up and is dealt in — the app marks them folded for this hand.
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 100;
|
||||
state.seats[4].folded = true;
|
||||
|
||||
assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand");
|
||||
assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0);
|
||||
});
|
||||
|
||||
test("they are a full player again on the next hand", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.seats[4].inHand = true;
|
||||
state.seats[4].stack = 100;
|
||||
state.seats[4].folded = true;
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[4].folded, false);
|
||||
assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else");
|
||||
});
|
||||
});
|
||||
|
||||
describe("passing the button around before the cards come out", () => {
|
||||
test("moving the button re-posts the blinds and keeps the chip count straight", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const startingChips = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[1].committed, 1);
|
||||
assert.equal(state.seats[2].committed, 2);
|
||||
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back");
|
||||
assert.equal(state.seats[3].committed, 1, "and the new one has posted");
|
||||
assert.equal(state.seats[0].committed, 2);
|
||||
assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("passing it round many times still balances", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 });
|
||||
const startingChips = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
[2, 5, 7, 3, 0, 5].forEach(seat => {
|
||||
state.dealerSeatIndex = seat;
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`);
|
||||
});
|
||||
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.committed(state.seats[sbIndex]), 1);
|
||||
assert.equal(Betting.committed(state.seats[bbIndex]), 2);
|
||||
});
|
||||
|
||||
test("once somebody has acted, the blinds are settled", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far");
|
||||
|
||||
Betting.call(state, 3);
|
||||
assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are");
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming the blinds by hand", () => {
|
||||
test("a named blind wins over the button order", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
|
||||
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(sbIndex, 3);
|
||||
assert.equal(bbIndex, 0);
|
||||
assert.equal(manual, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[3].committed, 1);
|
||||
assert.equal(state.seats[0].committed, 2);
|
||||
assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing");
|
||||
});
|
||||
|
||||
test("a named blind on a player who isn't dealt in is ignored", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair
|
||||
|
||||
const { sbIndex, manual } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(!manual, "falls back to the button order rather than posting to nobody");
|
||||
assert.ok(state.seats[sbIndex].inHand);
|
||||
});
|
||||
|
||||
test("both blinds on one player is refused", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 1 };
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.notEqual(sbIndex, bbIndex);
|
||||
});
|
||||
|
||||
test("naming blinds heads-up still acts in the right order", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
|
||||
state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(state.seats[5].committed, 1);
|
||||
assert.equal(state.seats[2].committed, 2);
|
||||
assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up");
|
||||
|
||||
Betting.call(state, 5);
|
||||
Betting.check(state, 2);
|
||||
Betting.closeStreet(state);
|
||||
assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop");
|
||||
});
|
||||
|
||||
test("the naming only lasts the hand it was made for", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
Betting.beginHand(state);
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.blindOverride, null, "blinds go back to following the button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first hand on a fresh table", () => {
|
||||
test("the button never starts on an empty chair", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; });
|
||||
assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1");
|
||||
assert.equal(state.seats[0].inHand, false, "which nobody is sitting in");
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player");
|
||||
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
|
||||
assert.ok(state.seats[sbIndex].inHand);
|
||||
assert.ok(state.seats[bbIndex].inHand);
|
||||
assert.notEqual(sbIndex, bbIndex);
|
||||
});
|
||||
|
||||
test("a table that empties out around the button recovers", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 });
|
||||
state.seats[3].inHand = false; // the player holding the button leaves
|
||||
HoldemState.ensureButtonOnPlayer(state);
|
||||
assert.ok(state.seats[state.dealerSeatIndex].inHand);
|
||||
});
|
||||
|
||||
test("two players left after everyone else walks is heads-up, blinds reversed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
[2, 3].forEach(i => { state.seats[i].inHand = false; });
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||
assert.equal(headsUp, true);
|
||||
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chips survive a messy table", () => {
|
||||
test("joining, leaving and passing the button never changes the chip count", () => {
|
||||
const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[6].stack = 100; // sitting down but not dealt in yet
|
||||
const startingChips = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
Betting.repostBlinds(state);
|
||||
|
||||
state.seats[6].inHand = true; // walks up mid-hand
|
||||
state.seats[6].folded = true;
|
||||
assert.equal(chipsOnTable(state), startingChips);
|
||||
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
state.seats[1].inHand = false; // and someone storms off
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for");
|
||||
assert.equal(
|
||||
Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)),
|
||||
Money.toCents(startingChips),
|
||||
"and the pot plus the stacks is the whole table"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Money, HandHistoryExport } = require("./load");
|
||||
|
||||
describe("Money.format", () => {
|
||||
test("whole amounts drop the pointless cents", () => {
|
||||
assert.equal(Money.format(12), "$12");
|
||||
assert.equal(Money.format(450), "$450");
|
||||
assert.equal(Money.format(1250000), "$1,250,000");
|
||||
assert.equal(Money.format(0), "$0");
|
||||
});
|
||||
|
||||
test("amounts that carry cents still show them", () => {
|
||||
assert.equal(Money.format(12.5), "$12.50");
|
||||
assert.equal(Money.format(0.25), "$0.25");
|
||||
assert.equal(Money.format(3.01), "$3.01");
|
||||
});
|
||||
|
||||
test("a quarter-blind game reads exactly as it did", () => {
|
||||
// The case that would be ruined by dropping cents unconditionally.
|
||||
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||
});
|
||||
});
|
||||
|
||||
test("floating-point dust doesn't invent cents", () => {
|
||||
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||
assert.equal(Money.format(1.0000000001), "$1");
|
||||
});
|
||||
|
||||
test("signed amounts put the sign out front, both scales", () => {
|
||||
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||
assert.equal(Money.formatSigned(0), "$0");
|
||||
});
|
||||
|
||||
test("rubbish in doesn't produce rubbish out", () => {
|
||||
assert.equal(Money.format(null), "$0");
|
||||
assert.equal(Money.format(undefined), "$0");
|
||||
assert.equal(Money.format("nonsense"), "$0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Money.forSheet", () => {
|
||||
test("a bare number a spreadsheet will add up", () => {
|
||||
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||
assert.equal(Money.forSheet(12.5), "12.50");
|
||||
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||
});
|
||||
|
||||
test("no currency symbol and no thousands separators", () => {
|
||||
const written = Money.forSheet(1234567.89);
|
||||
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the exported CSV", () => {
|
||||
const history = [
|
||||
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||
];
|
||||
const standings = [
|
||||
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||
];
|
||||
|
||||
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||
const splitRow = line => {
|
||||
const fields = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ",") { fields.push(field); field = ""; }
|
||||
else field += c;
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
};
|
||||
|
||||
test("every money cell parses as a number", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
const lines = csv.split("\r\n");
|
||||
|
||||
lines.slice(1, 3).forEach(line => {
|
||||
const cell = splitRow(line)[2]; // Pot
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||
});
|
||||
|
||||
// Standings rows: every column after the name has to be numeric.
|
||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||
splitRow(line).slice(1).forEach(cell => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, []);
|
||||
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||
});
|
||||
|
||||
test("names and payout descriptions stay human", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||
});
|
||||
|
||||
test("a name with a comma is still quoted properly", () => {
|
||||
const csv = HandHistoryExport.toCsv([], [
|
||||
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||
]);
|
||||
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Values a dealer can actually produce with a typo, and the ones a corrupted save
|
||||
// can produce on its own. None of these should be able to move chips.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("a stack can never go negative", () => {
|
||||
test("a mistyped negative stack reads as empty, not as owing chips", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
|
||||
});
|
||||
|
||||
test("a negative stack can't be bet with", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
|
||||
|
||||
const moved = Betting.putIn(state, 0, 25);
|
||||
|
||||
assert.equal(moved, 0, "nothing moves out of an empty stack");
|
||||
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
|
||||
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
|
||||
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
|
||||
});
|
||||
|
||||
test("a negative table default doesn't hand everyone a negative stack", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats.forEach(p => { p.stack = null; });
|
||||
state.defaultStack = -100;
|
||||
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
|
||||
});
|
||||
|
||||
test("a negative stack doesn't make a player look like they can still bet", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("blinds that can't be posted", () => {
|
||||
test("negative blinds post nothing rather than paying players to sit down", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.smallBlind = -5;
|
||||
state.bigBlind = -10;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("an unreadable blind doesn't wedge the hand", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.bigBlind = NaN;
|
||||
state.smallBlind = 1;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
|
||||
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
|
||||
});
|
||||
|
||||
test("blinds nobody can cover put those players all-in and move on", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
|
||||
state.smallBlind = 500;
|
||||
state.bigBlind = 1000;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
|
||||
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
|
||||
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a saved table that comes back damaged", () => {
|
||||
test("unreadable storage falls back to a fresh table", () => {
|
||||
// load() has to survive whatever is in localStorage, because the alternative
|
||||
// is a dealer opening the tool mid-session to a blank screen.
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => "{not json at all",
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
|
||||
assert.equal(state.handNumber, 1);
|
||||
});
|
||||
|
||||
test("a save missing its seats is treated as no save at all", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
|
||||
});
|
||||
|
||||
test("a save from before a field existed gets the field, not undefined", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({
|
||||
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
|
||||
handNumber: 4,
|
||||
}),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.blindOverride, null);
|
||||
assert.equal(state.street, "preflop");
|
||||
state.seats.forEach(p => {
|
||||
assert.equal(p.committed, 0);
|
||||
assert.equal(p.hasActed, false);
|
||||
assert.equal(p.buyIns, 0);
|
||||
});
|
||||
// And it has to be playable, not just parseable.
|
||||
assert.doesNotThrow(() => Betting.beginHand(state));
|
||||
});
|
||||
|
||||
test("storage that refuses to be written doesn't take the table down", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error("QuotaExceededError"); },
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.createDefault();
|
||||
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
|
||||
globalThis.localStorage = original;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user