Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde65db45a | ||
|
|
55aceaed5b | ||
|
|
4a1cb842cb | ||
|
|
e7ddff2fed | ||
|
|
f8ed4abc85 | ||
|
|
942919f3f7 | ||
|
|
02c2a034cb | ||
|
|
cd2db6346e | ||
|
|
6eb97c8a35 | ||
|
|
bd8ee11f77 | ||
|
|
13d704301d | ||
|
|
7bd205a91a | ||
|
|
f07144acd8 | ||
|
|
fbc4bd9a3e | ||
|
|
cd57c16050 | ||
|
|
2a080eab6a | ||
|
|
f93e0935ef | ||
|
|
190ccebf6c | ||
|
|
c052ee32c8 | ||
|
|
1f3dda180f | ||
|
|
3ffce9d7c1 | ||
|
|
5d5093f3cc | ||
|
|
aaf96543a8 | ||
|
|
2e9c55af21 | ||
|
|
8b43614b1c | ||
|
|
42b9e83e8f | ||
|
|
419c1de2a0 | ||
|
|
470fbc0d26 | ||
|
|
c4e842f3e3 | ||
|
|
1d9353c94e | ||
|
|
6174981cdc | ||
|
|
0ad5592687 | ||
|
|
236c52567e | ||
|
|
b8655be258 | ||
|
|
7e420ce747 | ||
|
|
c36d7f60bd | ||
|
|
15cec18256 | ||
|
|
9c8d981e2d | ||
|
|
907e0165db | ||
|
|
86e1570618 | ||
|
|
6df400322e | ||
|
|
ca9ac7b6af | ||
|
|
80dae56f0d | ||
|
|
6ca99fde6b | ||
|
|
b2262f8604 | ||
|
|
6ad9923aae | ||
|
|
168e8a380c | ||
|
|
edc08d7213 |
+3
-1
@@ -366,4 +366,6 @@ MigrationBackup/
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
native/**/build/
|
||||
# Playwright MCP scratch output
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -7,28 +7,41 @@
|
||||
|
||||
<div id="holdemApp">
|
||||
<header id="holdemHeader">
|
||||
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
|
||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
||||
before the dealer saw the table. Only two of the six are per-hand controls.
|
||||
The rest have gone where they belong rather than into a "more" menu, which
|
||||
would have relocated the crowding rather than removed it: Reset Table down to
|
||||
the sidebar with the save/load pair — in a panel called Save & reset, so the
|
||||
danger is on the label rather than at the end of a scroll — Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
|
||||
@* Not the accent any more. It is a shortcut to a sheet, available all night,
|
||||
and it is never the next step — the bar always has that, and at showdown
|
||||
the bar says so in as many words. See .actionAdvance in holdem.css. *@
|
||||
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
<div id="tableCentre">
|
||||
<div id="boardRow" hidden aria-label="Community cards"></div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="betsLayer" aria-hidden="true"></div>
|
||||
@@ -37,6 +50,7 @@
|
||||
<p id="tableHint" class="tableHint"></p>
|
||||
<p class="tableHintActions">
|
||||
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
|
||||
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -52,8 +66,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="sidebarPanel" open>
|
||||
<summary>Table Setup</summary>
|
||||
@* Not `open` in the markup any more. Whether this panel starts open is a
|
||||
question about the table, so main.js answers it after the state has
|
||||
loaded: open on a table nobody has set up yet, shut on one already
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
@@ -71,6 +89,14 @@
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Ask me for the cards as they come out</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
@* The label used to lead with "Track the board", which is the feature's name
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
@@ -126,11 +152,23 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* These four were one panel called "Session", which is a container word rather
|
||||
than a name: it held the standings, a waiting list, the hand log and the
|
||||
save/load/reset buttons — a topic, a task, a log and three utilities, which
|
||||
is four kinds of thing under one label. It measured at 1206px and 103
|
||||
elements, and "The table itself" was 1052px down inside it, so reaching
|
||||
Reset table meant opening a panel called Session and scrolling past the
|
||||
money and the log to get there.
|
||||
|
||||
Each panel is named for the thing a dealer came looking for, and the
|
||||
subheads that used to do that job are gone, because the summary says it
|
||||
now. They are in the order of a night: set the table up, watch the money,
|
||||
fill the seats, look back at a hand — and, last and apart from the rest,
|
||||
the two buttons that end it. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session</summary>
|
||||
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div class="standingsTableWrap">
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
@@ -146,14 +184,6 @@
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<h3 class="panelSubhead">Waiting list</h3>
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
@@ -184,8 +214,24 @@
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div class="handHistoryFilterRow">
|
||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||
@@ -199,22 +245,11 @@
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">The table itself</h3>
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
@@ -229,7 +264,7 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel" id="keyboardPanel">
|
||||
<summary>Keyboard</summary>
|
||||
<summary><h2>Keyboard</h2></summary>
|
||||
<div class="panelBody">
|
||||
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||
@@ -237,11 +272,37 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Last, and on its own, because everything in it is about the night as a whole
|
||||
rather than the hand in front of you — and because two of the three buttons
|
||||
are the ones you cannot take back.
|
||||
|
||||
Reset table came down from the header a while ago, where it sat in the same
|
||||
weight as Resolve Pot: the most-pressed control in a session beside the one
|
||||
that throws the night away. It used to be buried at the bottom of Session,
|
||||
1052px down, which hid it but also meant nobody could find it on purpose.
|
||||
Naming the panel for both jobs puts the danger on the label, where it is
|
||||
read before the panel is opened rather than met at the end of a scroll. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Save & reset</h2></summary>
|
||||
<div class="panelBody">
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,7 +626,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="actionBar" aria-live="polite" hidden></div>
|
||||
@* No aria-live here any more. This element's entire contents are rebuilt on every
|
||||
action, so a live region on it announced the whole bar every time — measured, after
|
||||
one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
||||
Fold Call $10 Raise". That is what a screen reader read out after every single fold.
|
||||
A live region has to be a stable element whose *text* changes; the one below is. *@
|
||||
<div id="actionBar" hidden></div>
|
||||
|
||||
@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so
|
||||
assistive tech is already watching it when the first update lands — a live region
|
||||
created at the moment it has something to say is one the screen reader misses. *@
|
||||
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></div>
|
||||
|
||||
<div id="toastHost" aria-live="polite"></div>
|
||||
|
||||
@@ -587,13 +658,48 @@
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
@* The buttons live outside the scrolling body on purpose. This is the one
|
||||
dialog that divides money, and its Confirm sat below a scroll at every screen
|
||||
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
|
||||
sticky footer cannot fix that from inside: the actions were the last child of
|
||||
the scrolling content, so their containing block ended exactly where they did
|
||||
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
|
||||
<div id="potWizardFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
|
||||
@* Installable, and works with no signal — see the service worker registration at the
|
||||
foot of main.js. A dealer running a night in somebody's back room is on the worst
|
||||
network there is, and the tool being a *website* was the last thing that could
|
||||
take it away from them mid-game. *@
|
||||
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
||||
@* iOS reads none of the manifest's icons and only this. *@
|
||||
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
|
||||
@* The standard name first, then Apple's older one. Chrome has been printing
|
||||
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
|
||||
console on every load; the replacement is the unprefixed name, and Safari still
|
||||
wants the prefixed one, so both are here. *@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
||||
@* Paints the phone's chrome the same near-black as the felt's ground, so an
|
||||
installed copy doesn't sit under a white status bar. *@
|
||||
<meta name="theme-color" content="#141414" />
|
||||
}
|
||||
|
||||
@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData
|
||||
["cssVersion"] is assigned in _Layout's own code block, which runs after these
|
||||
sections are handed their values, so every one of these renders as a bare "?v=" —
|
||||
on this page and on Chess, Particles and Memory Lane too. It is inert rather than
|
||||
broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing
|
||||
is being browser-cached for a version string to bust in the first place.
|
||||
Left alone deliberately. Fixing it is a one-line change to a file every page on the
|
||||
site shares, and the service worker was the reason to care — that now uses stale-
|
||||
while-revalidate, which needs no version to stay current. *@
|
||||
@section Scripts {
|
||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
@@ -601,6 +707,7 @@
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -52,8 +53,16 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
|
||||
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
|
||||
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
|
||||
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
|
||||
var contentTypes = new FileExtensionContentTypeProvider();
|
||||
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
ContentTypeProvider = contentTypes,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Texas Hold'em Dealer",
|
||||
"short_name": "Hold'em",
|
||||
"description": "Seat your players, track the action, split the pot. Works with no signal.",
|
||||
"start_url": "/casino/texasholdem",
|
||||
"scope": "/casino/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#141414",
|
||||
"theme_color": "#141414",
|
||||
"categories": ["utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/casino/holdem-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/casino/holdem-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Keeps the dealer tool working when the network doesn't.
|
||||
//
|
||||
// A home game is the worst network in the world: somebody's back room, one bar of
|
||||
// signal, a router two floors away. The tool already keeps the whole night in
|
||||
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
|
||||
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
|
||||
// one hand and a deck in the other got a browser error page with nine players waiting.
|
||||
//
|
||||
// This is the one thing standing between "a website about poker" and "a tool you can
|
||||
// rely on at a table", so it is worth the moving part.
|
||||
//
|
||||
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
|
||||
// prefix of the page it controls and "/casino/texasholdem" does not start with
|
||||
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
|
||||
// goes straight to the network, so the other casino pages behave exactly as they did.
|
||||
|
||||
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
|
||||
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
|
||||
// without touching this.
|
||||
const CACHE = "holdem-shell-v1";
|
||||
|
||||
// What this worker will keep. Anything else in scope — a form post, an API call, a
|
||||
// page it was never told about — goes straight to the network and behaves exactly as
|
||||
// it did before the worker existed.
|
||||
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
|
||||
|
||||
// The page hands over the exact list of URLs it was built from — see registerWorker()
|
||||
// in main.js. Deriving it from the document instead of keeping a list here is the
|
||||
// whole trick: a hand-written manifest of files drifts from the markup the first time
|
||||
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
|
||||
// right up until the release that quietly broke it.
|
||||
async function precache(urls) {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Individually rather than cache.addAll, which rejects the whole batch if any one
|
||||
// URL 404s — one stale entry in the list would leave the dealer with nothing.
|
||||
await Promise.all(urls.map(async url => {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "reload" });
|
||||
if (res.ok) await cache.put(url, res);
|
||||
} catch {
|
||||
// Offline while installing. The runtime handler below picks these up on
|
||||
// the next online visit.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
|
||||
// live page can hand it assets from a different release than the ones it started with,
|
||||
// and this page loads seventeen separate scripts that expect to agree with each other.
|
||||
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
|
||||
// and it cannot leave them with a half-swapped app in the middle of a hand.
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys
|
||||
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
|
||||
.map(key => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
|
||||
event.waitUntil(precache(event.data.urls));
|
||||
}
|
||||
});
|
||||
|
||||
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
|
||||
// can't be inspected, and caching one would mean serving a failure forever.
|
||||
function handles(request) {
|
||||
if (request.method !== "GET") return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
const { request } = event;
|
||||
if (!handles(request)) return;
|
||||
|
||||
// The page itself: network first, so an online dealer always gets the current
|
||||
// build, and the cached copy is there the moment they aren't.
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch {
|
||||
const cached = await caches.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
throw new Error("offline and this page was never cached");
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets: stale while revalidate. The cached copy answers immediately — which is
|
||||
// the whole point, a dealer opening this between hands should not wait for a
|
||||
// network — and a fresh copy is fetched in the background for next time.
|
||||
//
|
||||
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
|
||||
// ?v= from the layout and so a hit is always the right file. They don't: the
|
||||
// version renders empty (see the note in TexasHoldem.cshtml), so every release
|
||||
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
|
||||
// frozen on a build from three months ago with no way for anyone to tell. This
|
||||
// strategy needs no version to be correct: worst case a dealer is one launch
|
||||
// behind, which for a tool that runs entirely in the browser is nothing.
|
||||
if (!CACHEABLE.has(request.destination)) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const fresh = fetch(request).then(res => {
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
}).catch(() => null);
|
||||
|
||||
if (cached) return cached;
|
||||
const res = await fresh;
|
||||
if (res) return res;
|
||||
throw new Error("offline and not cached: " + request.url);
|
||||
})());
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -32,11 +32,23 @@ const ActionBar = {
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
const rail = this.isRail(host);
|
||||
// On a phone held sideways the bar is a rail down the right-hand side, so it
|
||||
// takes no height at all — and `--actionBarHeight` is what four other rules
|
||||
// and the felt measurement all mean by "how much of the bottom is spoken
|
||||
// for". Publishing the rail's height there would reserve the whole window.
|
||||
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
// What is under the felt is the bar in one layout and the two links in the
|
||||
// other. Either way it is the same question — how much of the bottom is
|
||||
// already spoken for — so it is the same argument.
|
||||
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
|
||||
};
|
||||
|
||||
// Kept so anything that moves the table without touching the bar can ask for
|
||||
// the felt to be measured again — the board strip appearing above it does
|
||||
// exactly that, and the felt's height is worked out from where the table starts.
|
||||
this.publishHeight = publish;
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.heightWatcher = new ResizeObserver(publish);
|
||||
this.heightWatcher.observe(host);
|
||||
@@ -46,6 +58,33 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// Is the bar a rail down the side rather than a bar across the bottom?
|
||||
//
|
||||
// Asked of the stylesheet rather than answered again here. The layout is chosen by
|
||||
// a media query — orientation, height and width together — and a second copy of
|
||||
// that query written in JavaScript is a copy that drifts: the first thing anyone
|
||||
// changes is the breakpoint, in one of the two places. So the CSS declares which
|
||||
// layout it picked in a custom property and this reads the answer back.
|
||||
isRail(host) {
|
||||
if (!host || typeof getComputedStyle !== "function") return false;
|
||||
return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
|
||||
},
|
||||
|
||||
// What sits between the bottom of the felt and the bottom of the window when the
|
||||
// bar is off to one side: the table hint and the row of links under it. Measured,
|
||||
// because both come and go — the hint is empty mid-hand and the links wrap on the
|
||||
// narrowest column.
|
||||
// Taken as the distance from the bottom of the felt to the bottom of the section it
|
||||
// sits in, which is a difference rather than a height — so it doesn't move when the
|
||||
// felt's own height changes, and this can't chase itself around the screen.
|
||||
roomBelowFelt() {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
const section = wrap && wrap.parentElement;
|
||||
if (!section) return 0;
|
||||
const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
|
||||
return Math.max(0, Math.round(gap));
|
||||
},
|
||||
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
@@ -86,13 +125,27 @@ const ActionBar = {
|
||||
// header in normal flow, so its own height never moves its top and this can't
|
||||
// chase itself around the screen.
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx",
|
||||
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
||||
const root = document.documentElement;
|
||||
const was = root.style.getPropertyValue("--feltRoomPx");
|
||||
const now = `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`;
|
||||
root.style.setProperty("--feltRoomPx", now);
|
||||
// The felt has just changed size, so how much rim each seat gets has changed
|
||||
// with it. Told rather than left to be noticed on the next render, which on a
|
||||
// bar that grew mid-hand could be several actions away.
|
||||
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
|
||||
if (typeof Render === "undefined" || !Render.state) return;
|
||||
if (Render.updateSeatDensity) Render.updateSeatDensity();
|
||||
|
||||
// And when it really did change size, the seats are laid out again rather than
|
||||
// only re-measured.
|
||||
//
|
||||
// The seats follow a felt on their own — they are placed in percentages of it —
|
||||
// but a bet chip anchored inside a seat is placed in pixels off that seat's
|
||||
// centre, worked out against the felt as it was. Nothing re-ran that. Measured
|
||||
// at 640x360 with five players: the chips were positioned for a felt 83px tall,
|
||||
// painted on one of 60, and seat 3's $5 came to rest on seat 4's puck. The bar
|
||||
// growing mid-hand does this too — a side pot appearing is 55px off the felt —
|
||||
// so it is not only the board strip that could reach it.
|
||||
if (now !== was && Render.renderSeats) Render.renderSeats();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
@@ -113,12 +166,19 @@ const ActionBar = {
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
// A table with nobody at it used to hide this bar altogether, which meant the
|
||||
// one moment a dealer has never used the tool before is the one moment it says
|
||||
// nothing at all. What they got instead was a felt printing EMPTY ten times and
|
||||
// a green Resolve Pot button that would resolve nothing.
|
||||
//
|
||||
// An empty table is a state with an obvious next step, so it says what it is.
|
||||
if (!this.isRunning()) {
|
||||
host.hidden = true;
|
||||
document.body.classList.remove("hasActionBar");
|
||||
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
||||
host.hidden = false;
|
||||
document.body.classList.add("hasActionBar");
|
||||
host.appendChild(this.renderEmptyTable());
|
||||
this.stopTankTimer();
|
||||
this.tankSeatIndex = null;
|
||||
this.announce();
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
@@ -142,6 +202,79 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
this.syncTankTimer();
|
||||
this.announce();
|
||||
},
|
||||
|
||||
// The empty table. One sentence and one button, in the place every other prompt in
|
||||
// this tool appears, so a dealer who has never opened it before is looking at the
|
||||
// next step rather than at ten chairs labelled EMPTY.
|
||||
renderEmptyTable() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dealPrompt";
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "dealPromptLine";
|
||||
line.textContent = "Nobody's at the table yet.";
|
||||
wrap.appendChild(line);
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "dealPromptButtons";
|
||||
|
||||
const seat = document.createElement("button");
|
||||
seat.type = "button";
|
||||
seat.className = "actionBtn actionDeal";
|
||||
seat.textContent = "Seat a player";
|
||||
seat.addEventListener("click", () => this.hooks.seatFirstPlayer());
|
||||
buttons.appendChild(seat);
|
||||
|
||||
wrap.appendChild(buttons);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// One sentence, for anyone running this by ear.
|
||||
//
|
||||
// The bar used to carry aria-live itself, and because render() rebuilds the whole
|
||||
// thing every action, a screen reader read the lot out after every tap — street,
|
||||
// pot, player count, who's short, the clock, the undo label and all three buttons.
|
||||
// Nothing in that is wrong; it is just unusable at the speed a hand is dealt.
|
||||
//
|
||||
// What a dealer actually needs from the tool between taps is who is on the clock
|
||||
// and what it costs them. That is what this says, into a small element that exists
|
||||
// from page load and only ever has its text replaced.
|
||||
//
|
||||
// Guarded on the sentence itself rather than on the state, because render() is
|
||||
// called for a great many reasons that don't change whose turn it is — resizing the
|
||||
// window, opening a panel, editing a note — and a live region that repeats itself
|
||||
// is read out again every time.
|
||||
announce() {
|
||||
const target = document.getElementById("actionAnnouncer");
|
||||
if (!target) return;
|
||||
|
||||
const said = this.announcement();
|
||||
if (said === this.lastAnnouncement) return;
|
||||
this.lastAnnouncement = said;
|
||||
target.textContent = said;
|
||||
},
|
||||
|
||||
announcement() {
|
||||
const state = this.state;
|
||||
if (!this.isRunning()) return "Nobody's at the table yet.";
|
||||
if (!state.bettingOpen) {
|
||||
const preview = Betting.blindPreview(state);
|
||||
return preview ? "Ready to deal." : "";
|
||||
}
|
||||
if (Betting.isHandOver(state)) return "Hand over. Resolve the pot.";
|
||||
|
||||
const index = state.actingSeatIndex;
|
||||
if (index === null || index === undefined) {
|
||||
return `${Betting.streetLabel(state.street)} betting is done.`;
|
||||
}
|
||||
|
||||
const player = state.seats[index];
|
||||
const name = HoldemState.displayName(player, index);
|
||||
const owed = Betting.toCall(state, player);
|
||||
const cost = owed > 0 ? `${Money.format(owed)} to call` : "checked to them";
|
||||
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
@@ -231,7 +364,17 @@ const ActionBar = {
|
||||
undo.type = "button";
|
||||
undo.className = "actionUndo";
|
||||
undo.title = `Undo ${undoLabel}`;
|
||||
undo.textContent = `↶ ${undoLabel}`;
|
||||
// The label goes in its own element rather than straight onto the button.
|
||||
// The pill is a fixed width — it names the last action, so its text changes
|
||||
// on every tap, and a control that resizes under the dealer's thumb reads
|
||||
// as the interface twitching. Fixed width means long labels have to
|
||||
// ellipsize, and only a real element can: as a bare text node the label
|
||||
// truncated *through* the keycap below it, which then sat squashed against
|
||||
// the cut-off text looking like a rendering fault.
|
||||
const label = document.createElement("span");
|
||||
label.className = "actionUndoLabel";
|
||||
label.textContent = `↶ ${undoLabel}`;
|
||||
undo.appendChild(label);
|
||||
// U undoes, and nothing said so — this control was the only place a dealer
|
||||
// could have learned it, and it was the one action bar control without its
|
||||
// key printed on it.
|
||||
@@ -463,10 +606,15 @@ const ActionBar = {
|
||||
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
||||
);
|
||||
|
||||
// Four is what the bar can hold without taking the felt with it — see
|
||||
// buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check
|
||||
// for the ceiling this is keeping the bar under.
|
||||
return Render.buildPotBreakdown(
|
||||
PotCalculator.layers(contributions),
|
||||
PotCalculator.uncalledReturns(contributions),
|
||||
id => nameById.get(id)
|
||||
id => nameById.get(id),
|
||||
null,
|
||||
{ maxLayers: 4 }
|
||||
);
|
||||
},
|
||||
|
||||
@@ -521,6 +669,19 @@ const ActionBar = {
|
||||
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
||||
// tool a dealer can't talk their way out of.
|
||||
commit(label, mutate) {
|
||||
// A toast is news about the last thing that happened, and this is the next
|
||||
// thing: whatever it says is now out of date. Two reasons it goes rather than
|
||||
// waiting out its eight seconds. It lies — "Next hand — button on seat 2.
|
||||
// [Undo]" reads as an offer to move the button back, but the snapshot behind
|
||||
// that Undo predates the hand you have since dealt, so taking it would throw
|
||||
// the hand away without saying so. And it is in the way: measured across the
|
||||
// sweep, a toast left up over a dealt table covered seats, the clock button
|
||||
// or the bar's own Undo on thirty of the small screens it was checked at,
|
||||
// because there is nowhere on a 360x640 phone for a 560px strip to go. The
|
||||
// bar's own Undo control takes over from here, and it names the step it
|
||||
// would actually take back.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
this.hooks.beginAction(label);
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
@@ -1008,6 +1169,20 @@ const ActionBar = {
|
||||
return line;
|
||||
},
|
||||
|
||||
// The cards come over before anybody is paid.
|
||||
//
|
||||
// Same shape as the street button above: when the dealer has asked to be asked, the
|
||||
// moment they press this is the moment they are holding the hands, so the sheet
|
||||
// opens here rather than leaving them to find the seat editor for each player with
|
||||
// the pot already on the table. Off by default, and unchanged when it's off.
|
||||
toShowdown() {
|
||||
if (this.state.trackBoard && typeof Render !== "undefined") {
|
||||
Render.openShowdownPicker(() => this.hooks.resolvePot());
|
||||
return;
|
||||
}
|
||||
this.hooks.resolvePot();
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
@@ -1023,10 +1198,14 @@ const ActionBar = {
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
this.toShowdown();
|
||||
return;
|
||||
}
|
||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||
// The cards that just came off the deck. Asked for here rather than left
|
||||
// for the dealer to remember, because the moment they are holding them is
|
||||
// the only moment they know what they are without turning the board round.
|
||||
if (this.state.trackBoard && typeof Render !== "undefined") Render.openBoardPicker();
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
@@ -1051,7 +1230,7 @@ const ActionBar = {
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
() => this.toShowdown()
|
||||
));
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
const Betting = {
|
||||
STREETS: ["preflop", "flop", "turn", "river"],
|
||||
|
||||
// How many community cards are face up once a street has been dealt. The flop is
|
||||
// three at once and everything after it is one at a time, which is why this is a
|
||||
// table rather than an index.
|
||||
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
|
||||
|
||||
boardCardsFor(street) {
|
||||
return this.BOARD_CARDS[street] ?? 0;
|
||||
},
|
||||
|
||||
streetLabel(street) {
|
||||
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||
},
|
||||
@@ -459,6 +468,25 @@ const Betting = {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// Are the blinds actually down? Moving the button or naming a blind means two
|
||||
// completely different things depending on the answer, and `onlyBlindsIn` alone
|
||||
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
|
||||
// committed, satisfies it just as well as a hand that has only just been dealt.
|
||||
//
|
||||
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
|
||||
// those chips back and post them again from the new positions, or the badges and
|
||||
// the money disagree.
|
||||
//
|
||||
// Before the deal it is a marker being slid round a table with nothing in the
|
||||
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
|
||||
// two stacks, action on the seat after the big blind — which is not what a dealer
|
||||
// means by "she's the big blind this one". Setting the positions is how a dealer
|
||||
// gets ready to deal; the cards come out when they press the button, and not a
|
||||
// moment before.
|
||||
blindsArePosted(state) {
|
||||
return !!state.bettingOpen && this.onlyBlindsIn(state);
|
||||
},
|
||||
|
||||
// Chips the pot has been priced with but nobody has actually paid.
|
||||
//
|
||||
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
|
||||
@@ -538,6 +566,7 @@ const Betting = {
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
HoldemState.clearBoard(state);
|
||||
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||
// would have counted as is being dealt again from the same button.
|
||||
state.postedBlinds = null;
|
||||
@@ -574,6 +603,10 @@ const Betting = {
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// A fresh deal starts on an empty felt. Dealing without moving the button —
|
||||
// after a misdeal, or a table set up and dealt twice — would otherwise leave
|
||||
// the last hand's board sitting there being ranked against the new cards.
|
||||
HoldemState.clearBoard(state);
|
||||
// Posting a blind isn't aggression — a hand limped and checked all the way
|
||||
// down has no aggressor at all, which is exactly the case the showdown rule
|
||||
// sends to the seat left of the button.
|
||||
|
||||
@@ -16,75 +16,147 @@ const CardPicker = {
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
|
||||
// the same job either way — what changes is where the card is written and how many
|
||||
// slots there are to walk along.
|
||||
target: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
// The board. `limit` is how many of the five are actually out — three on the flop,
|
||||
// four on the turn — so the sheet never offers a slot for a card the dealer has not
|
||||
// turned over yet.
|
||||
openBoard(state, hooks, cardIndex, limit = 5) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "board";
|
||||
this.seatIndex = null;
|
||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||
onBoard() {
|
||||
return this.target === "board";
|
||||
},
|
||||
|
||||
// The slots the sheet is filling, whichever set that is.
|
||||
slotValues() {
|
||||
return this.onBoard()
|
||||
? (this.state.board || []).slice(0, this.boardLimit)
|
||||
: this.state.seats[this.seatIndex].cards;
|
||||
},
|
||||
|
||||
write(index, card) {
|
||||
if (this.onBoard()) {
|
||||
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
|
||||
this.state.board[index] = card;
|
||||
return;
|
||||
}
|
||||
this.state.seats[this.seatIndex].cards[index] = card;
|
||||
},
|
||||
|
||||
// Why the sheet is closing, because one caller needs to tell the difference.
|
||||
//
|
||||
// "filled" is the sheet finishing its own job — the last empty slot it was offering
|
||||
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
|
||||
// A single seat's cards don't care which. A showdown does: it walks the players one
|
||||
// at a time, and the two answers are "next player" and "stop asking".
|
||||
//
|
||||
// The hooks are dropped before the callback runs, so a caller that opens the sheet
|
||||
// again from inside `onDone` installs its own and this close can't fire twice.
|
||||
close(reason = "dismissed") {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
const done = this.hooks && this.hooks.onDone;
|
||||
this.hooks = null;
|
||||
if (done) done(reason);
|
||||
},
|
||||
|
||||
// Every card already dealt somewhere — in front of a player or face up in the
|
||||
// middle. The slot being edited is excluded, so re-picking the card that's already
|
||||
// there isn't treated as a duplicate.
|
||||
//
|
||||
// The board belongs in here as much as the hands do, and in both directions: the
|
||||
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
|
||||
// the dealer is filling in at the time.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
(this.state.board || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (this.onBoard() && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), "the board");
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = card;
|
||||
this.write(this.cardIndex, card);
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the second card — a dealer picking hole cards is always
|
||||
// picking two, and making them re-open the picker doubles the taps.
|
||||
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||
this.cardIndex = 1;
|
||||
// Straight on to the next empty slot — a dealer picking hole cards is always
|
||||
// picking two and a flop is always three, and making them re-open the picker
|
||||
// between each one multiplies the taps by the only thing the sheet is for.
|
||||
const values = this.slotValues();
|
||||
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
|
||||
if (next !== -1) {
|
||||
this.cardIndex = next;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.close("filled");
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = "";
|
||||
this.write(this.cardIndex, "");
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const used = this.cardsInUse();
|
||||
// What this slot is called out loud: the flop's three cards, then the turn, then
|
||||
// the river — which is what a dealer is holding when they open this, rather than
|
||||
// "card 4".
|
||||
boardSlotLabel(slot) {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent =
|
||||
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
render() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
||||
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
||||
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
[0, 1].forEach(slot => {
|
||||
values.forEach((_, slot) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = player.cards[slot] || "";
|
||||
const value = values[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
@@ -105,8 +177,12 @@ const CardPicker = {
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||
if (takenBy) {
|
||||
btn.title = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ const Toast = {
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add("toastVisible");
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
@@ -61,6 +64,24 @@ const Toast = {
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// How much of the bottom of the window this is standing on.
|
||||
//
|
||||
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||
// this to the bar's own height to work out where a focused control has to stop, so
|
||||
// it is published rather than guessed: the toast is one line or two depending on the
|
||||
// message, and on a narrow screen the same message is taller again.
|
||||
publishHeight() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
const host = document.getElementById("toastHost");
|
||||
const height = host && host.firstElementChild
|
||||
? Math.round(host.getBoundingClientRect().height)
|
||||
: 0;
|
||||
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// What beats what.
|
||||
//
|
||||
// The dealer has always been the one who decides who won: the pot wizard asks "who won
|
||||
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
|
||||
// won without a showdown, and at a home game the table reads the cards out loud between
|
||||
// them. But when the board is on the felt and the hands are on their backs, the tool
|
||||
// knows the answer, and reading it off a screen beats three people counting kickers.
|
||||
//
|
||||
// Cards are the same two-character strings the rest of the tool has always stored:
|
||||
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
|
||||
//
|
||||
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
|
||||
// wizard decides what to do about it.
|
||||
const HandRank = {
|
||||
RANKS: "23456789TJQKA",
|
||||
|
||||
// High to low. The number is the hand's category and nothing else — kickers and
|
||||
// the rest of the tie-breaking live in the `tiebreak` array beside it.
|
||||
STRAIGHT_FLUSH: 8,
|
||||
FOUR_OF_A_KIND: 7,
|
||||
FULL_HOUSE: 6,
|
||||
FLUSH: 5,
|
||||
STRAIGHT: 4,
|
||||
THREE_OF_A_KIND: 3,
|
||||
TWO_PAIR: 2,
|
||||
ONE_PAIR: 1,
|
||||
HIGH_CARD: 0,
|
||||
|
||||
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
|
||||
// slot gets told apart from a bad one.
|
||||
rankValue(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const index = this.RANKS.indexOf(card[0].toUpperCase());
|
||||
return index < 0 ? null : index + 2;
|
||||
},
|
||||
|
||||
suitOf(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const suit = card[card.length - 1].toUpperCase();
|
||||
return "SHDC".includes(suit) ? suit : null;
|
||||
},
|
||||
|
||||
isCard(card) {
|
||||
return this.rankValue(card) !== null && this.suitOf(card) !== null;
|
||||
},
|
||||
|
||||
// Singular and plural, for reading a hand out loud.
|
||||
NAMES: {
|
||||
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
|
||||
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
|
||||
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
|
||||
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
|
||||
2: ["two", "twos"],
|
||||
},
|
||||
|
||||
name(rank, plural = false) {
|
||||
const pair = this.NAMES[rank];
|
||||
return pair ? pair[plural ? 1 : 0] : "?";
|
||||
},
|
||||
|
||||
// ---- The five-card hand -------------------------------------------------------
|
||||
|
||||
// Exactly five cards, ranked. Everything else is built on this.
|
||||
//
|
||||
// Five rather than seven because five is the hand a player actually holds, and it
|
||||
// is small enough to check by hand — which is the whole argument for doing it this
|
||||
// way. The seven-card version below tries all twenty-one five-card hands and keeps
|
||||
// the best, which is a hand of poker's own definition of "your best five" written
|
||||
// out literally. A dealer resolves one pot a hand with at most a dozen players in
|
||||
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
|
||||
// a subtler algorithm's correctness for.
|
||||
evaluate5(cards) {
|
||||
if (!Array.isArray(cards) || cards.length !== 5) return null;
|
||||
if (!cards.every(card => this.isCard(card))) return null;
|
||||
|
||||
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
|
||||
const suits = cards.map(card => this.suitOf(card));
|
||||
const flush = suits.every(suit => suit === suits[0]);
|
||||
|
||||
// How many of each rank, ordered by how many first and by rank second — so
|
||||
// the front of this list is always the rank that decides the hand.
|
||||
const counts = new Map();
|
||||
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
|
||||
const groups = [...counts.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
|
||||
const shape = groups.map(([, count]) => count);
|
||||
const byGroup = groups.map(([rank]) => rank);
|
||||
|
||||
const straightHigh = this.straightHigh(ranks);
|
||||
|
||||
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
|
||||
|
||||
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
|
||||
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
|
||||
if (flush) return made(this.FLUSH, ranks);
|
||||
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
|
||||
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
|
||||
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
|
||||
return made(this.HIGH_CARD, ranks);
|
||||
},
|
||||
|
||||
// The top card of the straight these five ranks make, or null.
|
||||
//
|
||||
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
|
||||
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
|
||||
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
|
||||
straightHigh(sortedDesc) {
|
||||
const distinct = [...new Set(sortedDesc)];
|
||||
if (distinct.length !== 5) return null;
|
||||
if (distinct[0] - distinct[4] === 4) return distinct[0];
|
||||
// Ace, five, four, three, two.
|
||||
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
|
||||
return null;
|
||||
},
|
||||
|
||||
// ---- The best five out of however many ----------------------------------------
|
||||
|
||||
// The best five-card hand available from five, six or seven cards — hole cards and
|
||||
// board together, which is what Hold'em asks for. Returns null if there aren't five
|
||||
// real cards, which is the ordinary state of a hand still being dealt.
|
||||
best(cards) {
|
||||
const real = (cards || []).filter(card => this.isCard(card));
|
||||
if (real.length < 5) return null;
|
||||
// A card in two places at once is a mis-entry, not a hand — say so rather than
|
||||
// ranking a board that has the same king on it twice.
|
||||
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
|
||||
|
||||
let best = null;
|
||||
this.eachFive(real, five => {
|
||||
const hand = this.evaluate5(five);
|
||||
if (!best || this.compare(hand, best) > 0) best = hand;
|
||||
});
|
||||
return best;
|
||||
},
|
||||
|
||||
// Every five-card subset, without building an array of them.
|
||||
eachFive(cards, visit) {
|
||||
const n = cards.length;
|
||||
for (let a = 0; a < n - 4; a++) {
|
||||
for (let b = a + 1; b < n - 3; b++) {
|
||||
for (let c = b + 1; c < n - 2; c++) {
|
||||
for (let d = c + 1; d < n - 1; d++) {
|
||||
for (let e = d + 1; e < n; e++) {
|
||||
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
|
||||
// answer here rather than a rounding of one: two players playing the same board
|
||||
// split the pot, and the fifth card decides it far more often than a table expects.
|
||||
compare(a, b) {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
if (a.category !== b.category) return a.category - b.category;
|
||||
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// ---- Saying it out loud -------------------------------------------------------
|
||||
|
||||
// What a dealer says when they turn the hand over. Named to the level that settles
|
||||
// it and no further: "two pair, aces and eights" is the call, and the kicker only
|
||||
// gets a mention where the tool has had to use one to break a tie.
|
||||
describe(hand) {
|
||||
if (!hand) return "";
|
||||
const [first, second] = hand.tiebreak;
|
||||
switch (hand.category) {
|
||||
case this.STRAIGHT_FLUSH:
|
||||
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
|
||||
case this.FOUR_OF_A_KIND:
|
||||
return `Four of a kind, ${this.name(first, true)}`;
|
||||
case this.FULL_HOUSE:
|
||||
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
|
||||
case this.FLUSH:
|
||||
return `Flush, ${this.name(first)} high`;
|
||||
case this.STRAIGHT:
|
||||
return `Straight, ${this.name(first)} high`;
|
||||
case this.THREE_OF_A_KIND:
|
||||
return `Three of a kind, ${this.name(first, true)}`;
|
||||
case this.TWO_PAIR:
|
||||
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
|
||||
case this.ONE_PAIR:
|
||||
return `Pair of ${this.name(first, true)}`;
|
||||
default:
|
||||
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
|
||||
}
|
||||
},
|
||||
|
||||
// The same call with the article a table would put in front of it. Not derivable
|
||||
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
|
||||
// and "two pair" take none at all, and "an ace high" is not something anybody says.
|
||||
// So it belongs to the category, like the name does.
|
||||
ARTICLED: {
|
||||
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
|
||||
7: () => null,
|
||||
6: () => "a",
|
||||
5: () => "a",
|
||||
4: () => "a",
|
||||
3: () => null,
|
||||
2: () => null,
|
||||
1: () => "a",
|
||||
0: () => null,
|
||||
},
|
||||
|
||||
// "wins with X" — the phrase, ready to drop into a sentence.
|
||||
phrase(hand) {
|
||||
if (!hand) return "the best hand";
|
||||
const described = this.describe(hand);
|
||||
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
|
||||
if (article === null) return described.toLowerCase();
|
||||
if (article !== "a") return article;
|
||||
return `a ${described.toLowerCase()}`;
|
||||
},
|
||||
|
||||
// ---- The showdown -------------------------------------------------------------
|
||||
|
||||
// Rank a set of players against a board.
|
||||
//
|
||||
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
|
||||
// is returned whether or not they could be ranked, in the order they were given, so
|
||||
// the caller can show a row for each and say which ones it could not read. `place`
|
||||
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
|
||||
// null hand and no place.
|
||||
//
|
||||
// `complete` is the question the pot wizard actually asks: can this be trusted to
|
||||
// pay the table. It is only true when the board is all five cards and every single
|
||||
// player handed in has two of their own — one unknown hand and the tool cannot say
|
||||
// who won, only who is winning so far, which is not the same thing and must not be
|
||||
// dressed up as it.
|
||||
showdown(board, players) {
|
||||
const boardCards = (board || []).filter(card => this.isCard(card));
|
||||
const boardComplete = boardCards.length === 5;
|
||||
|
||||
const ranked = (players || []).map(player => {
|
||||
const hole = (player.cards || []).filter(card => this.isCard(card));
|
||||
const hand = hole.length === 2 && boardComplete
|
||||
? this.best([...hole, ...boardCards])
|
||||
: null;
|
||||
return { id: player.id, cards: hole, hand, place: null };
|
||||
});
|
||||
|
||||
const readable = ranked.filter(row => row.hand);
|
||||
// A duplicated card anywhere means somebody has been mis-entered, and the
|
||||
// ranking would be of a deck that doesn't exist.
|
||||
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
|
||||
const duplicated = new Set(all).size !== all.length;
|
||||
|
||||
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
|
||||
let place = 0;
|
||||
sorted.forEach((row, i) => {
|
||||
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
|
||||
row.place = place;
|
||||
});
|
||||
|
||||
const winners = sorted.filter(row => row.place === 1);
|
||||
const complete = boardComplete && !duplicated &&
|
||||
ranked.length > 0 && readable.length === ranked.length;
|
||||
|
||||
return {
|
||||
players: ranked,
|
||||
winners: complete ? winners.map(row => row.id) : [],
|
||||
boardComplete,
|
||||
duplicated,
|
||||
complete,
|
||||
// Who the tool could not read, so the dialog can name them rather than
|
||||
// just declining to help.
|
||||
unknown: ranked.filter(row => !row.hand).map(row => row.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandRank;
|
||||
}
|
||||
@@ -86,6 +86,54 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
|
||||
// Removing a chip colour, which is only sometimes a small thing.
|
||||
//
|
||||
// A colour nobody has counted is a row in a legend, and taking it out should
|
||||
// cost a click and nothing else. A colour seats are holding is money: the
|
||||
// legend is what prices their chips, so the row leaving takes their chips off
|
||||
// the table with it — and it cannot be put back, because a colour added again
|
||||
// is a new one and the counts belong to the old.
|
||||
//
|
||||
// So the friction is on the second case only. A confirmation that fires every
|
||||
// time is one nobody reads by the third night, and the one that matters here
|
||||
// would go the same way as the four before it that didn't.
|
||||
removeChipColor: async id => {
|
||||
const usage = HoldemState.chipColorUsage(state, id);
|
||||
if (!usage.chip) return;
|
||||
const name = Number(usage.chip.value) > 0
|
||||
? `${Money.format(usage.chip.value)} chip`
|
||||
: "chip colour";
|
||||
|
||||
if (usage.total !== 0) {
|
||||
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
|
||||
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
// Both directions are named rather than only the loss. Going up is
|
||||
// the odd one — seats falling back to the table's standing ante once
|
||||
// their chips are gone — and a dealer who is told the total will move
|
||||
// and then finds it moved the other way has been told something false.
|
||||
title: usage.total > 0
|
||||
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
|
||||
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
|
||||
message:
|
||||
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
|
||||
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
|
||||
`when the colour goes. Undo will put it back; adding the colour again will not.`,
|
||||
confirmLabel: "Remove it",
|
||||
danger: usage.total > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
runUndoable(
|
||||
usage.total > 0
|
||||
? `${name} removed — ${Money.format(usage.total)} off the table.`
|
||||
: usage.total < 0
|
||||
? `${name} removed — table up ${Money.format(-usage.total)}.`
|
||||
: `${name} removed.`,
|
||||
() => HoldemState.removeChipColor(state, id));
|
||||
},
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
@@ -176,8 +224,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
// the dealer gets told rather than finding out later.
|
||||
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
|
||||
seatAction(index, willRepost
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
@@ -204,18 +251,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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}.`, () => {
|
||||
// Only a hand that is already open has blinds to move. Before the deal this
|
||||
// is aiming the next hand, and re-posting would have dealt it — the dealer
|
||||
// says who the blind is well before they say "cards".
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(index, posted
|
||||
? `${nameOf(index)} posts the ${label}.`
|
||||
: `${nameOf(index)} has the ${label} next hand.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
clearBlindOverride: () => {
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(state.dealerSeatIndex, posted
|
||||
? "Blinds follow the button again — taken back and re-posted."
|
||||
: "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
@@ -247,6 +305,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
// Table Setup used to ship open, so the tool's first screen was its most crowded
|
||||
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
|
||||
// and two sub-panels, all under a felt the dealer had come here to look at. It is
|
||||
// the right panel to have open on a table nobody has touched, and the wrong one to
|
||||
// have open on the fourth hand of a night.
|
||||
//
|
||||
// Decided once, here, rather than on every render — a panel that reopened itself
|
||||
// whenever the table emptied would fight the dealer who had just shut it.
|
||||
(() => {
|
||||
const panel = document.getElementById("tableSetupPanel");
|
||||
if (!panel) return;
|
||||
const fresh = (Number(state.handNumber) || 1) === 1 &&
|
||||
!state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
panel.open = fresh;
|
||||
})();
|
||||
|
||||
const openPotWizard = () => {
|
||||
// Chips still in front of players belong in the pot being priced, so bank them
|
||||
// before the wizard reads anyone's contribution.
|
||||
@@ -291,6 +365,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
|
||||
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||
// them, so the button teaches the gesture rather than replacing it.
|
||||
seatFirstPlayer: () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
},
|
||||
|
||||
beginAction: label => {
|
||||
actionHistory.push({ label, snapshot: snapshotState() });
|
||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||
@@ -419,9 +503,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderTableHint();
|
||||
});
|
||||
|
||||
document.getElementById("trackBoardToggle").addEventListener("change", e => {
|
||||
state.trackBoard = e.target.checked;
|
||||
// Turning it off leaves whatever was entered in place rather than wiping it —
|
||||
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
|
||||
// still there. The next hand clears it the way it clears everything else.
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
// The one field on the page that must not go through renderAll: it would write
|
||||
// the textarea's own value back into it on every keystroke and put the caret at
|
||||
// the end. The panel's shut-state summary still has to keep up, though, and it
|
||||
// is the only thing on the page that reads the notes — so it gets called on its
|
||||
// own. It touches the six <span>s in the sidebar headers and nothing else.
|
||||
Render.renderPanelGlances();
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
@@ -571,6 +670,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Focus that lands underneath something fixed is moved out from under it.
|
||||
//
|
||||
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||
// works when the browser has to scroll to reach the control at all — then the
|
||||
// padding tells it where to stop. A control the browser already considers visible is
|
||||
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||
// nothing, because "nearest" was already satisfied.
|
||||
//
|
||||
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||
// overlays rather than every fixed element on the page, because this runs on every
|
||||
// focus change and walking the whole DOM to find three known things would be work
|
||||
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||
document.addEventListener("focusin", event => {
|
||||
const el = event.target;
|
||||
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) return;
|
||||
|
||||
let lift = 0;
|
||||
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||
const style = getComputedStyle(node);
|
||||
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||
if (style.position !== "fixed") return;
|
||||
const over = node.getBoundingClientRect();
|
||||
if (over.width === 0 || over.height === 0) return;
|
||||
// Only what is actually in the way: an overlay off to one side, like the
|
||||
// rail on a phone held sideways, is not covering a control in the middle.
|
||||
if (box.left >= over.right || box.right <= over.left) return;
|
||||
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||
});
|
||||
|
||||
// Instant rather than smooth. This is the page getting out of the way of
|
||||
// something the dealer has already arrived at, and animating it would put the
|
||||
// control they are typing into in motion under them.
|
||||
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||
|
||||
// Scrolling is not always enough. The page only has as much room below its
|
||||
// content as the padding under the app gives it, and that is sized for the bar
|
||||
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||
// lift wanted, 26px of page to do it in.
|
||||
//
|
||||
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||
// eight-second message about something that has already happened, and it is
|
||||
// standing on a control somebody has just navigated to. Only after the scroll
|
||||
// has been tried, so a toast that is not in the way is left alone.
|
||||
const toast = document.querySelector("#toastHost .toast");
|
||||
if (!toast) return;
|
||||
const after = el.getBoundingClientRect();
|
||||
const over = toast.getBoundingClientRect();
|
||||
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||
after.bottom > over.top && after.top < over.bottom;
|
||||
if (stillCovered) Toast.dismiss();
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
@@ -837,4 +1000,51 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
"The saved table couldn't be read, so this one started empty.",
|
||||
{ duration: 12000 });
|
||||
}
|
||||
|
||||
// ---- Working with no signal ----
|
||||
//
|
||||
// The night already survives anything — it lives in localStorage — but the page
|
||||
// did not. Reload in a back room with one bar of signal and a dealer with nine
|
||||
// players waiting got a browser error page. The worker below keeps the tool itself
|
||||
// on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache.
|
||||
//
|
||||
// The list of files to keep is read off this document rather than written down in
|
||||
// the worker. Every stylesheet and script the page actually loaded is right here in
|
||||
// the DOM, already carrying the ?v= release string from _Layout — so the list
|
||||
// cannot drift from the markup, which is the way a hand-kept one always fails: it
|
||||
// stays right until somebody adds a script tag, and then the tool is quietly
|
||||
// offline-broken until a dealer finds out the hard way.
|
||||
const registerWorker = () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
"/casino/holdem.webmanifest",
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" })
|
||||
.then(reg => {
|
||||
// The worker that will answer for this page — `controller` is still null
|
||||
// on the very first visit, before activate has claimed it.
|
||||
const worker = reg.active || navigator.serviceWorker.controller;
|
||||
if (worker) {
|
||||
worker.postMessage({ type: "precache", urls: shell });
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.ready.then(ready => {
|
||||
if (ready.active) ready.active.postMessage({ type: "precache", urls: shell });
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
});
|
||||
};
|
||||
|
||||
// After load, not during it: registering competes with the page's own scripts for
|
||||
// the network, and the first paint matters more than the second visit does.
|
||||
if (document.readyState === "complete") registerWorker();
|
||||
else window.addEventListener("load", registerWorker, { once: true });
|
||||
});
|
||||
|
||||
@@ -76,6 +76,39 @@ const PotWizard = {
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
this.renderStepInto();
|
||||
this.markOverflow();
|
||||
},
|
||||
|
||||
// Say when the list runs on past the bottom of the dialog.
|
||||
//
|
||||
// The body scrolls and the footer stays put, which is the right shape and was as far
|
||||
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
|
||||
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
|
||||
// under a hairline that reads as the end of the list. A dealer would have ticked the
|
||||
// two they could see. On the dialog that pays the table, a list that silently
|
||||
// continues is not a small thing.
|
||||
markOverflow() {
|
||||
const body = document.getElementById("potWizardBody");
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
if (!body || !modal) return;
|
||||
|
||||
const update = () => {
|
||||
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
|
||||
modal.classList.toggle("potWizardMoreBelow", below > 1);
|
||||
};
|
||||
if (!this.watchingOverflow) {
|
||||
body.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
this.watchingOverflow = true;
|
||||
}
|
||||
update();
|
||||
// Again once the browser has laid the step out — on the first open the body has
|
||||
// no height yet, and an answer taken then is always "nothing below".
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
renderStepInto() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
@@ -87,6 +120,11 @@ const PotWizard = {
|
||||
|
||||
const body = document.getElementById("potWizardBody");
|
||||
body.innerHTML = "";
|
||||
// The footer is a sibling of the body, outside the scroll — see the note in
|
||||
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
|
||||
// previous step's buttons.
|
||||
const footer = this.footer();
|
||||
footer.innerHTML = "";
|
||||
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||
body.appendChild(this.renderLog());
|
||||
|
||||
@@ -119,13 +157,25 @@ const PotWizard = {
|
||||
const round = document.createElement("div");
|
||||
round.className = "potWizardRound";
|
||||
|
||||
const showdown = this.readShowdown(contenders);
|
||||
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
|
||||
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
prompt.innerHTML = decided
|
||||
// It has read the hands, so it stops asking a question it knows the answer
|
||||
// to. The list stays — the dealer confirms, and a hand entered wrong is
|
||||
// corrected by unticking rather than by starting over.
|
||||
? `<strong>${Money.format(remaining)}</strong> in play.`
|
||||
: this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
round.appendChild(prompt);
|
||||
|
||||
const verdict = this.renderVerdict(showdown, contenders);
|
||||
if (verdict) round.appendChild(verdict);
|
||||
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "potWizardChoices";
|
||||
contenders.forEach(c => {
|
||||
@@ -135,6 +185,10 @@ const PotWizard = {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = c.id;
|
||||
if (decided && decided.has(c.id)) {
|
||||
checkbox.checked = true;
|
||||
label.classList.add("chosen");
|
||||
}
|
||||
// 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);
|
||||
@@ -149,11 +203,25 @@ const PotWizard = {
|
||||
name.textContent = this.nameById.get(c.id);
|
||||
text.appendChild(name);
|
||||
|
||||
// How this hand was read, under the name — on every row rather than only
|
||||
// the winning one, because a dealer checking the tool's answer is reading
|
||||
// the losers' hands, and a row that says nothing is a row they have to work
|
||||
// out for themselves. It takes the line the ceiling used to have; the
|
||||
// ceiling moves out beside the name, which is where the phone layout had
|
||||
// already put it to save exactly this line.
|
||||
const read = handById.get(c.id);
|
||||
if (read) {
|
||||
const ranked = document.createElement("span");
|
||||
ranked.className = "potWizardChoiceHand";
|
||||
ranked.textContent = HandRank.describe(read);
|
||||
text.appendChild(ranked);
|
||||
}
|
||||
label.appendChild(text);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
text.appendChild(upTo);
|
||||
label.appendChild(text);
|
||||
label.appendChild(upTo);
|
||||
|
||||
// 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.
|
||||
@@ -182,7 +250,11 @@ const PotWizard = {
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
||||
confirmBtn.textContent = "Confirm Winner(s)";
|
||||
confirmBtn.textContent = decided
|
||||
? (showdown.winners.length === 1
|
||||
? `Pay ${this.nameById.get(showdown.winners[0])}`
|
||||
: "Split the pot")
|
||||
: "Confirm Winner(s)";
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
||||
if (selected.length === 0) return;
|
||||
@@ -191,9 +263,91 @@ const PotWizard = {
|
||||
this.entries = result.updatedEntries;
|
||||
this.renderStep();
|
||||
});
|
||||
round.appendChild(confirmBtn);
|
||||
|
||||
body.appendChild(round);
|
||||
footer.appendChild(confirmBtn);
|
||||
},
|
||||
|
||||
// How the tool reads the hands still in for this round of the pot.
|
||||
//
|
||||
// Asked per round rather than once for the whole hand, because a side pot has its
|
||||
// own contenders: the player who won the main pot is out of the question for the
|
||||
// next one, and the best hand among who is left is a different answer.
|
||||
//
|
||||
// Only when the dealer asked to track the board — everything here is off by default
|
||||
// and the wizard behaves exactly as it always has.
|
||||
readShowdown(contenders) {
|
||||
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
|
||||
return HandRank.showdown(this.state.board, contenders.map(c => ({
|
||||
id: c.id,
|
||||
cards: this.cardsById.get(c.id) || [],
|
||||
})));
|
||||
},
|
||||
|
||||
// What the winners would actually be paid if this round were confirmed now.
|
||||
//
|
||||
// Worked out from the same function that does the paying rather than from a second
|
||||
// one that agrees with it today — a preview that can drift from the payout is worse
|
||||
// than no preview, because it is the number the dealer says out loud.
|
||||
previewPayout(winnerIds) {
|
||||
try {
|
||||
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
|
||||
return result.perWinner || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// "Alexandra wins $25 with a full house, kings full of threes."
|
||||
//
|
||||
// The whole point of the feature, in one line: who won and how much. It goes above
|
||||
// the list rather than replacing it, because the dealer still confirms — the tool
|
||||
// reads the cards, a person still says the pot is good.
|
||||
renderVerdict(showdown, contenders) {
|
||||
if (!showdown) return null;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardVerdict";
|
||||
|
||||
if (!showdown.boardComplete) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
|
||||
return line;
|
||||
}
|
||||
if (showdown.duplicated) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
|
||||
return line;
|
||||
}
|
||||
if (!showdown.complete) {
|
||||
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = missing.length
|
||||
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
|
||||
: "Not enough cards to read this one.";
|
||||
return line;
|
||||
}
|
||||
|
||||
const paid = this.previewPayout(showdown.winners);
|
||||
const amount = paid.length ? Money.format(paid[0].amount) : "";
|
||||
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
|
||||
const described = HandRank.phrase(hand && hand.hand);
|
||||
|
||||
line.classList.add("potWizardVerdictSure");
|
||||
line.textContent = names.length === 1
|
||||
? `${names[0]} wins ${amount} with ${described}.`
|
||||
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
|
||||
return line;
|
||||
},
|
||||
|
||||
listOf(names) {
|
||||
if (names.length <= 1) return names[0] || "";
|
||||
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
},
|
||||
|
||||
// The row of buttons that never scrolls away.
|
||||
footer() {
|
||||
return document.getElementById("potWizardFooter");
|
||||
},
|
||||
|
||||
recordRound(result) {
|
||||
@@ -331,12 +485,10 @@ const PotWizard = {
|
||||
const note = document.createElement("p");
|
||||
note.className = "fieldHint potWizardPendingNote";
|
||||
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(note);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(actions);
|
||||
this.footer().appendChild(actions);
|
||||
return wrap;
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,14 @@ const HoldemState = {
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
street: "preflop",
|
||||
// The community cards, in the order they hit the felt: three, then one,
|
||||
// then one. Empty strings for the ones that haven't been turned yet, the
|
||||
// same way a seat's two hole cards are stored.
|
||||
board: ["", "", "", "", ""],
|
||||
// Whether the dealer wants to be asked for them at all. Off is the tool as
|
||||
// it has always been: no board on the felt, and the pot wizard asks who
|
||||
// won. On, it reads the showdown and answers that question itself.
|
||||
trackBoard: false,
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
@@ -216,6 +224,11 @@ const HoldemState = {
|
||||
// A table saved before the dead button existed has neither flag. False is
|
||||
// the right guess for both: it means the rotation carries on the ordinary
|
||||
// way, which is what that table was already doing.
|
||||
// A table saved before the board existed has no board and was not tracking
|
||||
// one. Off is also the right guess for a dealer who never asked for it.
|
||||
parsed.trackBoard = parsed.trackBoard === true;
|
||||
parsed.board = this.normaliseBoard(parsed.board);
|
||||
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
@@ -705,6 +718,7 @@ const HoldemState = {
|
||||
// that stayed on would take chips off somebody who never said the word.
|
||||
state.straddleCount = 0;
|
||||
state.postedStraddles = [];
|
||||
this.clearBoard(state);
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -717,6 +731,21 @@ const HoldemState = {
|
||||
this.ensureButtonOnPlayer(state);
|
||||
},
|
||||
|
||||
// Exactly five slots of either a two-character card or an empty string, whatever
|
||||
// arrived. A board read off a file is the one that has been somewhere this tool
|
||||
// cannot see.
|
||||
normaliseBoard(board) {
|
||||
const source = Array.isArray(board) ? board : [];
|
||||
return Array.from({ length: 5 }, (_, i) => {
|
||||
const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
|
||||
return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(state) {
|
||||
state.board = ["", "", "", "", ""];
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
// for chip-count disputes, then advances the hand counter. `payouts` is
|
||||
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
||||
@@ -1110,6 +1139,84 @@ const HoldemState = {
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// What a chip colour is holding up.
|
||||
//
|
||||
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
||||
// running the legend over that seat's chip counts, so a colour that leaves the
|
||||
// legend takes every chip counted in it off the table. Measured on a three-handed
|
||||
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
||||
// on one click of a 24px button, with nothing said and nothing shown.
|
||||
//
|
||||
// It does not come back, either. A colour added again is a new colour with a new
|
||||
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
||||
// the row and none of the money.
|
||||
//
|
||||
// Hence this: the cost is answerable before the click rather than discovered at the
|
||||
// end of the night, when the box is short and nobody knows why.
|
||||
// The number here is the *difference*, asked by pricing every seat twice — once with
|
||||
// the colour and once without — rather than by multiplying the chips by their value.
|
||||
//
|
||||
// Those two are not the same, and the multiplication is the wrong one, because
|
||||
// computeContribution() does not always reach the chips. A seat with a typed amount
|
||||
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
||||
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
||||
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
||||
// were worth.
|
||||
// Both would put a number in the confirmation that the table then failed to match,
|
||||
// which is worse than no number at all — this is a warning whose whole job is to be
|
||||
// the truth.
|
||||
chipColorUsage(state, chipId) {
|
||||
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
||||
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
||||
|
||||
const legend = state.chipLegend || [];
|
||||
const without = legend.filter(entry => entry.id !== chipId);
|
||||
|
||||
const seats = state.seats
|
||||
.map((player, index) => {
|
||||
const before = this.computeContribution(player, legend, state.basePotAmount);
|
||||
const after = this.computeContribution(player, without, state.basePotAmount);
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
count: Number((player.chipCounts || {})[chipId]) || 0,
|
||||
amount: Money.round(before - after),
|
||||
};
|
||||
})
|
||||
// Signed, and not filtered to losses, because removing a colour can put the
|
||||
// table *up*. A seat whose chips are worth less than the table's standing
|
||||
// ante falls through to the ante once they are gone: found by the randomised
|
||||
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
||||
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
||||
// there would be as wrong as one that named the wrong number.
|
||||
.filter(row => row.amount !== 0);
|
||||
|
||||
return {
|
||||
chip,
|
||||
seats,
|
||||
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
||||
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Take a colour out, and take its counts with it.
|
||||
//
|
||||
// Leaving them behind is what the old code did — the seats kept counts against an
|
||||
// id no legend entry answers to any more, so the file carried money that nothing
|
||||
// could price and nothing could show. Clearing them makes the saved table say what
|
||||
// the screen says. The usage is measured before anything moves, so a caller can
|
||||
// report what it cost.
|
||||
removeChipColor(state, chipId) {
|
||||
const usage = this.chipColorUsage(state, chipId);
|
||||
if (!usage.chip) return usage;
|
||||
|
||||
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
||||
state.seats.forEach(player => {
|
||||
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
||||
});
|
||||
return usage;
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
|
||||
+567
-6
@@ -20,6 +20,13 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `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 |
|
||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||||
| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
|
||||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -33,10 +40,85 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
|
||||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||
phones on their side — each viewport twice, once with a mouse and once with a thumb.
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, that no
|
||||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||||
no bet chip is hidden behind a seat or another chip. It
|
||||
runs over the table, the raise pad, all five overlays and Quick Split, at thirteen
|
||||
viewports from a
|
||||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||||
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
|
||||
The raise pad is newer than the rest and was worth every second it costs. It is part
|
||||
of the bar rather than an overlay, so nothing here had a step that opened it, and it is
|
||||
both the tallest thing the bar ever holds and the only place in the tool a dealer types
|
||||
an amount. The first sweep that opened it found a 36px-tall field on every phone in the
|
||||
list: the `hover: none` rule that sizes fields for a thumb names the settings panels and
|
||||
the seat form, and the one field on the one surface built for thumbs was not in the
|
||||
list. It also found the rail squeezing its own status line under the pad, which is the
|
||||
kind of thing that only happens in the state nobody was looking at.
|
||||
|
||||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Text is measured where it was painted, not where its box is. The two are different
|
||||
things and the difference is not academic: a name is centred, so its box is wider than
|
||||
its ink, and a name kept clear of the dealer's chip by a float is out of the way on the
|
||||
line beside the chip and full width on the line below. The check used to model that as
|
||||
"box minus padding", which is one way of keeping text out of somewhere and not the only
|
||||
one — it called every seat in the sweep an offender while the text sat exactly where it
|
||||
was meant to. Range rects ask the question directly, so padding, floats, centring,
|
||||
wrapping and a short label in a wide box all come out right without any of them being
|
||||
modelled.
|
||||
|
||||
Offenders say how much is covered rather than that something is. "Is under a badge"
|
||||
reads the same for a stack a dealer cannot read and for a badge's rounded corner
|
||||
grazing the last digit, and the difference decides whether it is worth fixing. Probing
|
||||
the whole line and counting turned the residual this sweep has carried for three rounds
|
||||
into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context
|
||||
only.
|
||||
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
report. A full table is where the seats crowd and it had never been looked at: the
|
||||
first sweep that did found one player's name or stack painted under another on eighteen
|
||||
of the configurations, on every phone in the list.
|
||||
|
||||
The third shape is the same five players with the board turned on and five cards on it.
|
||||
It is deliberately the same roster in the same chairs as the second, so the only thing
|
||||
that differs between the two rows is the board — a seventh player would have told us
|
||||
that a seven-handed table crowds, which is true, already known, and not what that row
|
||||
is there to find out. It earned its place immediately: the board takes the middle of
|
||||
the felt, or a strip above it where the middle is too small, and the strip costs the
|
||||
felt height. The seats follow a felt that changes size on their own, being placed in
|
||||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||||
were laid out for a felt 23px taller than the one they were painted on.
|
||||
|
||||
## What the sweep taught the layout
|
||||
|
||||
For a long run of rounds the seat ring was *predicted* onto the table: work out the
|
||||
widest ring whose outermost puck still finishes inside the rail, place the seats, and
|
||||
trust it. The prediction is correct arithmetic and it was wrong on the page anyway,
|
||||
because every input to it moves after it is taken — the width search settles on a
|
||||
narrower puck, a name wraps to a second line or gets shortened out of one, the board
|
||||
appears in the middle. Each of those is up to 15px of puck height arriving after the
|
||||
answer. Ninety-six of the ninety-eight offenders this sweep could find were that.
|
||||
|
||||
So render.js now finishes the way the audit judges it. After the seats are placed it
|
||||
measures where they actually landed, and if the worst of them hangs 6px past the rail
|
||||
the ring comes in by 6px and they are placed again — the same question this file asks,
|
||||
asked by the code that has to get it right. Two passes, bounded, because each one is a
|
||||
forced layout of the whole felt and it runs after every tap a dealer makes.
|
||||
|
||||
That is the general shape of the lesson here: a layout that predicts itself has to be
|
||||
right about the future, and a layout that measures itself only has to be right about
|
||||
the present.
|
||||
|
||||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||
@@ -53,8 +135,8 @@ offenders, which reads identically to a pass. The same goes for the two exemptio
|
||||
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||
sentence, whose size is set by the line it sits in.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in about five
|
||||
seconds, while this one needs a browser and a running site. Run it after any change that adds or
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
@@ -77,6 +159,485 @@ something changed, and each time they only covered whatever had just been touche
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Which rule wins
|
||||
|
||||
`feltShape.test.js` is here because of a change that measured as correct and was a
|
||||
no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside
|
||||
`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set
|
||||
the same property back to 1. Both queries match a phone, the declarations have the same
|
||||
specificity, and the later one wins — so the property was 1 on every phone on the site
|
||||
while the rule that was supposed to change it sat there reading perfectly.
|
||||
|
||||
Nothing in the suite could have caught it and nothing in review did. A stylesheet is not
|
||||
a list of rules for viewports; it is a list of rules in an order, and the question "what
|
||||
does a 390px phone get?" has an answer that no single rule knows. So the test walks the
|
||||
media queries the way the cascade does — every declaration of a property on a selector,
|
||||
in source order, keeping the last one whose query matches — and asks what each of five
|
||||
real phone sizes actually ends up with. It carries just enough of a media-query engine
|
||||
for the forms this stylesheet uses, and refuses loudly rather than guessing when it
|
||||
meets one it doesn't know.
|
||||
|
||||
The same file records a second thing, which is a floor that was added and removed. The
|
||||
`max-height` that keeps the felt above the action bar has no lower bound, so a tall
|
||||
enough bar presses the table flat; flooring it at the desktop oval looked obviously
|
||||
right. Measured, it was worse: twelve players all in with nine side pots takes the bar
|
||||
to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its
|
||||
shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all
|
||||
twelve are visible. The slit the floor defended against turned out to be unreachable —
|
||||
it came from forcing the custom property by hand. A test now asserts the floor is
|
||||
*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way.
|
||||
|
||||
## A property the wording depends on
|
||||
|
||||
`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it
|
||||
leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The
|
||||
main pot is everyone still live, and each side pot above it drops whoever was all-in
|
||||
below, so every side pot's players are a strict subset of the pot beneath it.
|
||||
|
||||
That makes "who is in" and "who is out" complements, which is what lets the line name
|
||||
whichever of the two is shorter and never spell out more than half the table. Listing
|
||||
every eligible player instead meant the main pot's line was every name at the table and
|
||||
each side pot repeated it minus one — and the lines too long to fit were exactly the ones
|
||||
nobody needed, while the short ones at the bottom were the side pots a dealer actually
|
||||
announces by name.
|
||||
|
||||
The nesting check runs 20,000 random tables, and it asserts on its own generator as well
|
||||
as on the code: if a change to the shapes stopped producing side pots, every assertion in
|
||||
the loop would be vacuous and the test would still pass, so it also requires that more
|
||||
than ten thousand of those tables had one.
|
||||
|
||||
Writing that check is where a comparison against `Set.length` went in — a `Set` has
|
||||
`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an
|
||||
assertion it failed everything; as a filter in a throwaway script it had passed
|
||||
everything, which is how it survived long enough to be written down. Both readings are
|
||||
silent. Prefer asserting the count you expect over comparing two things whose shapes you
|
||||
have not checked.
|
||||
|
||||
## Testing a fault that broke nothing
|
||||
|
||||
`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because
|
||||
the fault it covers was not a bug. Every control worked and every id resolved. What was
|
||||
wrong was which panel a thing was in and what that panel was called.
|
||||
|
||||
The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and
|
||||
Session held the standings, a waiting list, the hand log and the save/load/reset buttons
|
||||
— a topic, a task, a log and three utilities under one word. It measured 1206px and 103
|
||||
visible elements against 555 and 29 for the next biggest, and "The table itself" sat
|
||||
1052px down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
scrolling past the money and the log to find it.
|
||||
|
||||
Three things are asserted, and each of them is a claim a future edit could quietly undo:
|
||||
|
||||
- **The names say what is inside them.** The list is checked literally, because the
|
||||
failure mode is a word like "Session" that could hold anything.
|
||||
- **Each name is a heading.** A `<summary>` is announced as a button, so the sidebar's
|
||||
names — its whole navigation — were not reachable by heading. Splitting Session also
|
||||
removed the four subheads that had been the only headings down there, so without a
|
||||
heading in each summary the page would have come out of this with a worse outline than
|
||||
it went in with.
|
||||
- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is
|
||||
legitimately long. It counts *subjects*: a panel that needs its own headings to list
|
||||
its parts is holding more than one thing. Two subheads survive, and the test names
|
||||
them, because Settle up is the standings at the end of the night and Stacks in chips
|
||||
is the chip legend counted out — the same subject at a different moment, which is what
|
||||
a subhead is for.
|
||||
|
||||
The panels are split on the opening tag rather than parsed, which is sound only because a
|
||||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||||
class. That assumption is worth knowing about if one ever does.
|
||||
|
||||
## A warning is only worth having if its number is right
|
||||
|
||||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||||
24px button and was never a change to a legend. `computeContribution()` prices a seat by
|
||||
running the legend over that seat's chip counts, so a colour that leaves takes every chip
|
||||
counted in it off the table. Measured in a browser on a three-handed table: deleting the
|
||||
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
|
||||
afterwards, and no way back — a colour added again is a new colour with a new id, while
|
||||
the counts stay keyed to the one that left.
|
||||
|
||||
Most of the file is about the number in the warning rather than the removal, because a
|
||||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||||
value — is wrong in **both** directions:
|
||||
|
||||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||||
colour it holds is worth nothing to it;
|
||||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||||
zero, so it loses less than its chips were worth.
|
||||
|
||||
So the figure is the difference between pricing every seat with the colour and without
|
||||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||||
exactly what the table then does. That check found a third case nobody would have written
|
||||
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
|
||||
**up** $186. The wording says so in that direction too.
|
||||
|
||||
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||||
reads by the third night, and this is a tool where four other confirmations already
|
||||
compete for that attention.
|
||||
|
||||
## Every control says what it is
|
||||
|
||||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||||
in a column, identical, each deleting a different colour.
|
||||
|
||||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||||
skipped rather than judged, since it is not in the tree to begin with.
|
||||
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Where the keyboard lands
|
||||
|
||||
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||||
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||||
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||||
under the action bar; on the desktop, four more under a toast.
|
||||
|
||||
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||||
keyboard and this file has no automation of its own:
|
||||
|
||||
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||||
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||||
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||||
down the right-hand side would demand scroll padding the height of the whole window;
|
||||
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||||
scroll-padding on the document would do nothing about them.
|
||||
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||||
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||||
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||||
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||||
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||||
|
||||
### Half a fix looks exactly like a whole one
|
||||
|
||||
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||||
applies when the browser has to scroll to reach the control at all; a control the browser
|
||||
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||||
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||||
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||||
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||||
outlast an eight-second toast.
|
||||
|
||||
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||||
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||||
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||||
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||||
was announced, which is worse than the fault.
|
||||
|
||||
## A sweep only measures the states it reaches
|
||||
|
||||
The action bar is fixed over the felt, so its height is the felt's budget — and the bar
|
||||
carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured
|
||||
on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**,
|
||||
and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw
|
||||
twelve seats in.
|
||||
|
||||
Nothing here could see it, and the reason is worth writing down. Every check in
|
||||
`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked
|
||||
how much room the covering element had taken. But the deeper miss was the sweep itself:
|
||||
all 294 audits drove their table with `Call` and `Check`, so **no table in the history of
|
||||
this file ever had a player all-in**, and the one thing on the bar whose height depends
|
||||
on the table was empty in every single measurement. `runUiAudit.js` now has a fourth
|
||||
shape, twelve players shoved in for twelve different amounts — there is no one-tap shove,
|
||||
so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who
|
||||
cannot cover.
|
||||
|
||||
- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing
|
||||
above **50%**, which is the line that needs no taste to defend: furniture larger than
|
||||
the content it serves is a shape fault whatever it is showing. Warning above **30%**,
|
||||
the published guidance for persistent chrome, which the shortest phone does not meet at
|
||||
rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is
|
||||
always red stops being read.
|
||||
|
||||
### Cutting the middle out of a list, and which end to keep
|
||||
|
||||
The cap is four layers, and past it the bar draws the main pot, one line for everything
|
||||
between, and the newest side pot. The two ends are not an arbitrary choice: side pots are
|
||||
paid **from the newest one back down to the main pot**, so the top layer is the next thing
|
||||
that happens and the main pot is the last, and those are exactly the two a live dealer
|
||||
says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The
|
||||
layers between are the middle of that count-down, wanted at showdown, which is the pot
|
||||
wizard — the same builder, passing no cap, in a dialog that scrolls.
|
||||
|
||||
`potBreakdownRoom.test.js` checks the parts that could go wrong quietly:
|
||||
|
||||
- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and
|
||||
twelve all-in all draw three lines
|
||||
- the drawn amounts still add up to every pot, so a dealer counting the announcement
|
||||
against the chips in the middle finds no shortfall the tool caused
|
||||
- the elision sits **where the gap is**, not appended after the list, so the amounts still
|
||||
read in the order the pots were made
|
||||
- it never fires when it would not buy anything: at exactly the cap the middle is one row,
|
||||
and swapping one row for one row loses an amount and gains a sentence
|
||||
|
||||
Three injected faults — the cap ignored, the summary carrying only the first elided pot's
|
||||
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
|
||||
respectively, and the audit check reports the original 64% when the cap is taken out.
|
||||
|
||||
## Two empty rows were setting how far apart twelve seats had to sit
|
||||
|
||||
Every seat puck carries four rows: the name, the hole cards, the amount that player has
|
||||
put in, and their stack. Two of those are empty most of the time, and both held their
|
||||
space anyway.
|
||||
|
||||
The amount pill was worse than empty space. It has a background, a pill radius and side
|
||||
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
|
||||
nine seats** at an ordinary table. One of those is a component holding its place; nine is
|
||||
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
|
||||
"this player has money out".
|
||||
|
||||
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
|
||||
it, so two rows nobody had filled were setting the spacing for the whole table:
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
|
||||
| iPhone 14, 12 seats | 44x65 | **66x55** |
|
||||
| Pro Max, 9 seats | 84x88 | **96x63** |
|
||||
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
|
||||
|
||||
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
|
||||
border with no content in it. The rule is about paint, not space: an element may hold
|
||||
its place invisibly, and several do. With the old reservation restored it reports all
|
||||
seven pills; live it reports none.
|
||||
|
||||
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
|
||||
because it was predicted — it is a 13x13px circle filled with the denomination colour of
|
||||
the chip beside it, so the colour *is* the content. Three other exemptions were written
|
||||
from memory first and matched no element on the page at all, which is the argument for
|
||||
building that list from a run.
|
||||
|
||||
### The collapse is only safe because the cache key moved
|
||||
|
||||
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
|
||||
the result against `seatShapeSignature()`. That signature listed the names and the dealer
|
||||
seat — everything that could wrap a name onto a second line. It did not mention the two
|
||||
rows, because they always held their space and so could never change a puck's height.
|
||||
|
||||
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
|
||||
chips go in and when a hole card is entered, and — just as important — that it does *not*
|
||||
move for the second player to bet, or when an amount merely gets wider. It is a boolean
|
||||
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
|
||||
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
|
||||
be correct and would throw away the cache on every single tap.
|
||||
|
||||
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
|
||||
and it settled by **23-25px there before this round too**, because the action bar changes
|
||||
height at that moment and the felt is measured against it. The one place the ring moves
|
||||
is a place it already moved.
|
||||
|
||||
### A test that passed for the wrong reason
|
||||
|
||||
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
|
||||
stay distinguishable. The first version of that test used `Ann`/`Abel` against
|
||||
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
|
||||
in different places. It passed with the separator deleted and proved nothing. Lower case
|
||||
is what makes it a test. Four faults were injected in total; the other three (dropping the
|
||||
two flags, letting the count into the key instead of a boolean, and counting only hand
|
||||
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
|
||||
|
||||
## Room the felt had and the type never got
|
||||
|
||||
The felt grows with the window and the pucks did not — 96px was the only ceiling there
|
||||
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
|
||||
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
|
||||
2560px monitor**, the same 10px as on a 360px phone.
|
||||
|
||||
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
|
||||
both:
|
||||
|
||||
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
|
||||
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
|
||||
the wider widths must not measure them, so a phone is checked to measure *exactly* the
|
||||
ten widths it measured before — same list, same order — and the measured cost on a
|
||||
phone is unchanged (10.7ms against 10.4ms, which is noise).
|
||||
- **A wide puck on a narrow felt is worse than a small one.** The first version let
|
||||
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
|
||||
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
|
||||
read as a stack of cards rather than a table with people around it.
|
||||
|
||||
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
|
||||
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
|
||||
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
|
||||
The test pins both ends, so if the felt ever changes shape enough to bring those together
|
||||
it says so rather than letting the phones quietly start paying.
|
||||
|
||||
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
|
||||
up the scale for the two numbers read across the table and one for the chair number.
|
||||
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
|
||||
continuum and the type is a set of deliberate sizes.
|
||||
|
||||
| nine-handed | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPad puck / name | 96px / 12px | **134px / 15px** |
|
||||
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
|
||||
| seats as a share of the felt, 1440 | 8% | **12%** |
|
||||
| every phone, every seat count | unchanged | unchanged |
|
||||
|
||||
### What the rail check is actually measuring, which is not the rail
|
||||
|
||||
Found while checking the above and **not fixed** — written down here because the number is
|
||||
worth having and the fix is not a small one.
|
||||
|
||||
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
|
||||
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
|
||||
**bounding box**, which is a different and much slacker question than the one the message
|
||||
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
|
||||
curves away from its own box, so a puck can be comfortably inside the box and visibly
|
||||
outside the felt.
|
||||
|
||||
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
|
||||
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
|
||||
configurations the check is seeing **303px of overhang as zero**, including **46px on a
|
||||
phone held sideways** that has been there for as long as the check has.
|
||||
|
||||
Settling against the ellipse was tried and reverted, because it is not the local fix it
|
||||
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
|
||||
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
|
||||
trade the width search explicitly refuses to make, since a dealer can see a puck over the
|
||||
rail but cannot see a tap answering for the wrong player. Guarding against that instead
|
||||
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
|
||||
to 77, with 355 rail reports.
|
||||
|
||||
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
|
||||
off each other are in genuine conflict**, and resolving it needs the two settled together
|
||||
rather than one bolted onto the other.
|
||||
|
||||
### And then measured again, and dropped
|
||||
|
||||
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
|
||||
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
|
||||
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
|
||||
extreme rather than the case.
|
||||
|
||||
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
|
||||
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
|
||||
The measurement gap is worth writing down and the appearance is not worth a round, so the
|
||||
lead is closed rather than carried forward. Worth the re-measure to find that out: the
|
||||
number that made it look urgent was the worst cell in the table, not a typical one.
|
||||
|
||||
## Eight rows that said only their own names
|
||||
|
||||
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
|
||||
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
|
||||
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
|
||||
waiting for a seat, how many hands have been played: the page had **all three worked out
|
||||
already**, and charged a tap for each of them, on rows two thirds of which were empty.
|
||||
|
||||
That is the disclosure pattern's named failure — a header you have to open to find out
|
||||
whether it was worth opening — and the answer the design systems give is the same one:
|
||||
put the status on the header. So each shut panel now carries the one fact you would have
|
||||
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
|
||||
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
|
||||
behind the tap.
|
||||
|
||||
Two rules hold it together, and `panelGlance.test.js` checks both.
|
||||
|
||||
**A glance appears only when it has something a dealer would act on.** Eight rows that
|
||||
always say something are eight rows that say nothing again, and a row reading "Nobody
|
||||
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
|
||||
no hands yet are all silence, and the bank check stays quiet over a table nobody has
|
||||
bought into — which is what the panel does with its own check, for the same reason.
|
||||
|
||||
**A glance never says something the panel under it would contradict.** It is read off the
|
||||
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
|
||||
and one test walks three buy-in figures asserting the glance's colour agrees with
|
||||
`bankReconciliation`'s verdict and its number carries the same difference.
|
||||
|
||||
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
|
||||
|
||||
The first version asked only whether a row had grown taller than a row with nothing on it,
|
||||
which sounds like the whole question and is not: a glance floated back into flow and
|
||||
allowed to wrap **sailed straight through it**, because a float does not expand the box it
|
||||
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
|
||||
where the glance actually ended up, in both axes, is the question that has an answer.
|
||||
|
||||
The second version was wrong in the other direction, and **the sweep caught it on its
|
||||
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
|
||||
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
|
||||
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
|
||||
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
|
||||
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
|
||||
Rows that aren't rendered aren't rows.
|
||||
|
||||
Which is the point of running the sweep against a check as well as against the page. A
|
||||
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
|
||||
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
|
||||
breaking it — and each is still caught, while the unbroken page stays clean.
|
||||
|
||||
## Half a feature, and the half that was missing
|
||||
|
||||
The card feature has always had two halves. Turn on the setting and every street asks
|
||||
which cards came off the deck, and puts them on the felt. That half worked, on the
|
||||
phone and on the desk, in a normal hand and in an all-in run-out.
|
||||
|
||||
The other half never got asked for at all. A dealer who entered all five community
|
||||
cards, pressed **Showdown — resolve pot**, and got a wizard that said
|
||||
|
||||
> Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is
|
||||
> still yours to say.
|
||||
|
||||
with nothing anywhere in that sheet to turn anybody over with. The only route in was to
|
||||
close the wizard, tap a seat puck, find the two card slots in the seat editor, do it
|
||||
four times, and re-open the wizard — with the pot on the table and four people waiting.
|
||||
The one moment the tool is certain to know the hands is the moment it stopped asking.
|
||||
|
||||
So the showdown button now asks, exactly the way the street button does: the sheet opens
|
||||
on the first player, takes two cards, and moves to the next. `showdownCards.test.js` is
|
||||
the list it asks from — who is on it and in what order — and three rules hold it
|
||||
together, one test each way:
|
||||
|
||||
**Only when the dealer asked.** The setting is off by default, and with it off the list
|
||||
is empty. A table that never wanted this never sees a sheet.
|
||||
|
||||
**Only what isn't known.** A hand already entered is an answer already given. Asking
|
||||
again offers to overwrite it, so an entered hand drops out of the list and the rest keep
|
||||
their order.
|
||||
|
||||
**In the order the cards come over.** The bar already announces "Alexandra shows first —
|
||||
last to bet or raise" a line above the button. Asking about somebody else first would be
|
||||
the tool contradicting itself in the same breath, so the list starts where the bar says
|
||||
and goes round the table from there.
|
||||
|
||||
### The sheet has to be leavable, and leaving it has to mean something
|
||||
|
||||
Escape, Done and a tap on the scrim all closed the picker already. What they didn't have
|
||||
was a way to say *why*, and a chain of four sheets needs to know the difference: a sheet
|
||||
that filled its last slot means "next player", and a dealer closing one means "stop".
|
||||
|
||||
So `close()` takes a reason. Two callers pass nothing and don't care. The showdown chain
|
||||
does, and it stops rather than skipping — a dealer who closes the sheet is saying the
|
||||
tool has the wrong end of the stick, and the answer to that is to get out of the way, not
|
||||
to put the same sheet in front of them three more times.
|
||||
|
||||
Stopping early is not a dead end, which is the part worth checking by hand. The wizard
|
||||
still names whoever didn't turn over; closing it leaves **Showdown — resolve pot** on the
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking
|
||||
the box; one that answers the question itself is wrong on its own account, silently,
|
||||
and a table that has come to trust it will not check.
|
||||
|
||||
So besides the hand-written cases — every category against every other, the wheel, three
|
||||
kickers deep, the counterfeited two pair, the board that plays — it is checked against a
|
||||
second evaluator written a different way, on twenty thousand random seven-card hands and
|
||||
four thousand random pairs of them. The second one is in the test file and shares no
|
||||
helper with the module: it scores a hand by counting ranks into a sortable list rather
|
||||
than by asking about shapes. That pass is most of the suite's running time and it is
|
||||
worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
|
||||
a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
|
||||
well as by the cases written for them.
|
||||
|
||||
## Why the randomised suite exists
|
||||
|
||||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Saying who the blind is, before saying "cards".
|
||||
//
|
||||
// A dealer sets the table up out loud — button here, she's the small blind, he's the
|
||||
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
|
||||
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
|
||||
// the action bar up and waiting on the seat after the big blind. The dealer had said
|
||||
// where the blinds go, and the tool heard "deal".
|
||||
//
|
||||
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
|
||||
// this hand except the blinds" — true of a hand one second old, and equally true of a
|
||||
// table in setup with no cards out and nothing committed. Those two need telling apart,
|
||||
// which is what `blindsArePosted` is for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("before the deal", () => {
|
||||
test("the blinds are not down, however much the table looks ready", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(state.bettingOpen, false);
|
||||
// The old condition, on its own, says yes here — this is the whole bug.
|
||||
assert.equal(Betting.onlyBlindsIn(state), true);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("a table straight out of setup, with nobody seated, is the same answer", () => {
|
||||
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
|
||||
});
|
||||
|
||||
test("re-posting would have dealt the hand, which is why it is guarded", () => {
|
||||
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
|
||||
// setup is the deal, and nothing about naming a blind should reach it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
|
||||
assert.notEqual(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("naming a blind moves a badge and no chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
// What the seat menu does: pin the override, repost only if the blinds are down.
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
|
||||
// And the badges say what the dealer just said.
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
assert.equal(blinds.sbIndex, 3);
|
||||
assert.equal(blinds.bbIndex, 0);
|
||||
});
|
||||
|
||||
test("and the hand that follows posts from where they were aimed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
});
|
||||
|
||||
test("giving the button away is a marker moving too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(willRepost, false);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("once the hand is dealt", () => {
|
||||
test("the blinds are down, and re-aiming them moves real chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const before = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), true);
|
||||
assert.equal(Betting.committed(state.seats[1]), 1);
|
||||
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("but not once somebody has answered them", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
|
||||
});
|
||||
|
||||
test("nor on a later street", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "flop";
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("and a misdealt hand goes back to being a table in setup", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
|
||||
});
|
||||
|
||||
test("as does a hand that has been paid out", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// What the tool says out loud between taps.
|
||||
//
|
||||
// The action bar carried `aria-live="polite"` on the element whose entire contents
|
||||
// render() rebuilds every action, so a screen reader read the whole bar after every
|
||||
// single tap. Measured, after one:
|
||||
//
|
||||
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
||||
// Fold Call $10 Raise"
|
||||
//
|
||||
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
|
||||
// is the documented anti-pattern: a live region has to be a stable element whose text
|
||||
// changes, not a container that gets replaced.
|
||||
//
|
||||
// What a dealer running this by ear needs is who is on the clock and what it costs
|
||||
// them. These are the sentences.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const barOn = (state) => {
|
||||
ActionBar.state = state;
|
||||
ActionBar.lastAnnouncement = undefined;
|
||||
return ActionBar;
|
||||
};
|
||||
|
||||
describe("before the cards are out", () => {
|
||||
test("a table that could be dealt says so", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
||||
});
|
||||
|
||||
test("and an empty table says what it is, rather than nothing", () => {
|
||||
// This used to announce "" — silence being the honest answer when the bar was
|
||||
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
|
||||
// empty table is the one state a dealer meets before they know how any of this
|
||||
// works, so it says so and offers the next step.
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
|
||||
test("and a table with one named player has moved on from that", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
state.seats[0].name = "Ann";
|
||||
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("while a hand is running", () => {
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("names who is on the clock, what it costs, and the pot", () => {
|
||||
const state = dealt();
|
||||
const said = barOn(state).announcement();
|
||||
assert.match(said, /to act/);
|
||||
assert.match(said, /\$2 to call/);
|
||||
assert.match(said, /Pot \$3\./);
|
||||
});
|
||||
|
||||
test("it is the seat that is actually acting, not the one after it", () => {
|
||||
const state = dealt();
|
||||
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
|
||||
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
|
||||
});
|
||||
|
||||
test("says checked to them rather than $0 to call", () => {
|
||||
// A number nobody owes is a number a dealer has to stop and parse.
|
||||
const state = dealt();
|
||||
state.currentBet = 0;
|
||||
assert.match(barOn(state).announcement(), /checked to them/);
|
||||
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
|
||||
});
|
||||
|
||||
test("a street with nobody left to act says the street is done", () => {
|
||||
const state = dealt();
|
||||
state.actingSeatIndex = null;
|
||||
state.street = "flop";
|
||||
assert.equal(barOn(state).announcement(), "Flop betting is done.");
|
||||
});
|
||||
|
||||
test("and a finished hand points at the pot", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
|
||||
assert.equal(Betting.isHandOver(state), true);
|
||||
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how often it speaks", () => {
|
||||
// A live region that is handed the same sentence twice reads it out twice, and
|
||||
// render() runs for a great many reasons that have nothing to do with whose turn
|
||||
// it is — a resize, a panel opening, a note being typed.
|
||||
const stubTarget = () => {
|
||||
const el = { textContent: "" };
|
||||
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
|
||||
return el;
|
||||
};
|
||||
|
||||
test("says nothing new when nothing changed", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
assert.ok(first.length > 0);
|
||||
|
||||
el.textContent = "CLEARED"; // if announce() writes again, this comes back
|
||||
bar.announce();
|
||||
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
test("but does when the action moves", () => {
|
||||
const el = stubTarget();
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
Betting.beginHand(state);
|
||||
const bar = barOn(state);
|
||||
|
||||
bar.announce();
|
||||
const first = el.textContent;
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
bar.announce();
|
||||
|
||||
assert.notEqual(el.textContent, first);
|
||||
assert.match(el.textContent, /to act/);
|
||||
delete global.document;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
// Where a player's bet sits on the felt.
|
||||
//
|
||||
// The chips were the last thing on the table nobody had measured. They were laid out on
|
||||
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
|
||||
// seat ring was itself fixed — and the choice between that ring and clinging to the
|
||||
// puck was made by asking how wide the *window* was. Neither of those is a question
|
||||
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
|
||||
// height: it passed the width test, took the ring, and every chip on it was painted
|
||||
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
|
||||
// number saying what that player has in.
|
||||
//
|
||||
// Everything below is geometry, so it runs without a browser. The felts are the ones
|
||||
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const RADII = { rx: 44, ry: 42 };
|
||||
const PUCK = { width: 96, height: 64 };
|
||||
const CHIP = { width: 44, height: 22 };
|
||||
|
||||
const felt = (width, height) => ({ width, height });
|
||||
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
|
||||
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
|
||||
|
||||
// A chip's centre in pixels on a given felt.
|
||||
const chipPixels = (index, count, box, spacing, radii = RADII) => {
|
||||
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
const seatPixels = (index, count, box, radii = RADII) => {
|
||||
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
// Two axis-aligned boxes, by their centres. The same test the seats settle among
|
||||
// themselves — see seatFootprint.test.js — because it is the same question.
|
||||
const boxesOverlap = (a, b, sizeA, sizeB) =>
|
||||
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
|
||||
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
|
||||
|
||||
describe("reaching the edge of a box", () => {
|
||||
test("straight out the side is half the width, straight up half the height", () => {
|
||||
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
|
||||
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
|
||||
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
|
||||
});
|
||||
|
||||
test("a corner is whichever edge the ray meets first", () => {
|
||||
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
|
||||
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
|
||||
// would put a chip 25px further out than it needs to be, which on a 117px felt
|
||||
// is the difference between clear of the puck and off the table.
|
||||
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
|
||||
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
|
||||
assert.ok(diagonal < 48, "and never further than the wider half-edge");
|
||||
});
|
||||
|
||||
test("a square is the same in every direction it is asked about", () => {
|
||||
const straight = Render.edgeReach(50, 50, 0, 1);
|
||||
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getting one box clear of another", () => {
|
||||
const PUCK96 = { width: 96, height: 88 };
|
||||
const CHIP50 = { width: 50, height: 21 };
|
||||
|
||||
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
|
||||
// apart. This is the question clearanceReach is supposed to answer, asked the long
|
||||
// way round so the answer can be checked rather than restated.
|
||||
const apart = (t, ux, uy) =>
|
||||
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
|
||||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
|
||||
|
||||
test("straight up and down it is what adding the two half-heights gives", () => {
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
|
||||
});
|
||||
|
||||
test("on a diagonal it is more than the two reaches added together", () => {
|
||||
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
|
||||
// one axis, and the travel that achieves that is the reach of the box you get by
|
||||
// adding their widths and their heights — which is never less, and on a diagonal
|
||||
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
|
||||
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
|
||||
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
|
||||
let shortfalls = 0;
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
|
||||
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
|
||||
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
|
||||
if (proper <= summed + 1e-9) return;
|
||||
// Where they differ, the old answer was not merely smaller — it was wrong:
|
||||
// travelling that far leaves the two boxes still on top of each other.
|
||||
shortfalls++;
|
||||
assert.ok(!apart(summed, ux, uy),
|
||||
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
|
||||
});
|
||||
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
|
||||
});
|
||||
|
||||
test("and it is exactly enough — a hair less is not", () => {
|
||||
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
|
||||
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip on the ring", () => {
|
||||
test("with nothing measured it keeps the proportions the ring always used", () => {
|
||||
// The unit suite and the very first render both call it this way. 27/44 and
|
||||
// 25/42 of the seat's own reach, so the chips move with the ring rather than
|
||||
// with the felt.
|
||||
const seat = Render.seatPosition(3, 9, 1.4, RADII);
|
||||
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
|
||||
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
|
||||
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
|
||||
});
|
||||
|
||||
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
|
||||
const box = felt(560, 400);
|
||||
const spacing = spacingOn(box);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const seat = seatPixels(i, 9, box);
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
|
||||
`seat ${i + 1}'s own chip is under its own puck`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and never wanders into the pot badge in the middle", () => {
|
||||
// The floor: 38% of the way out. Without it a chip walked in past a puck that
|
||||
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
|
||||
// is the same fault at the other end of the spoke.
|
||||
const box = felt(300, 216);
|
||||
const spacing = spacingOn(box, { width: 200, height: 150 });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const seat = seatPixels(i, 6, box);
|
||||
const chip = chipPixels(i, 6, box, spacing);
|
||||
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
|
||||
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
|
||||
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
|
||||
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
|
||||
const box = felt(400, 300);
|
||||
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deciding whether a ring of chips fits at all", () => {
|
||||
// The shape the layout works in: one entry per chip on the felt, carrying its own
|
||||
// measured size, because a chip is as wide as the number printed on it.
|
||||
const tableOf = (count, chip = CHIP) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
({ index: i, width: chip.width, height: chip.height }));
|
||||
};
|
||||
|
||||
test("a desktop felt has room for one", () => {
|
||||
const box = felt(700, 500);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
|
||||
});
|
||||
|
||||
test("a phone on its side has not, whatever the window's width says about it", () => {
|
||||
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
|
||||
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
|
||||
// on the seat across the table from it.
|
||||
const box = felt(692, 117);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
|
||||
"this is the felt where every chip was painted behind a puck");
|
||||
});
|
||||
|
||||
test("its answer is the one the geometry gives", () => {
|
||||
// Not a restatement of the two cases above: whatever it returns, it has to agree
|
||||
// with laying the chips out and measuring them against every puck on the felt.
|
||||
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
|
||||
shapes.forEach(box => {
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const aspect = box.width / box.height;
|
||||
const clear = all.every(({ index: i }) => {
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
|
||||
});
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
|
||||
`${box.width}x${box.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("each chip is asked about at its own size", () => {
|
||||
// A chip is as wide as the number on it: at 768x1024, four players' chips were
|
||||
// 40px and one holding a four-figure bet was 47. Answering the question once, at
|
||||
// the widest, told the whole felt it had no room for a ring — and dropped all
|
||||
// five chips onto their own players' stacks to solve a problem four of them
|
||||
// did not have.
|
||||
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
|
||||
const box = felt(672, 672);
|
||||
const narrow = { width: 40, height: 20 };
|
||||
const huge = { width: 420, height: 380 };
|
||||
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
|
||||
"a felt this size has room for a ring of ordinary chips");
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
|
||||
"and none at all for a ring of chips that size");
|
||||
|
||||
// Now one of each. The answer has to come from the sizes in the list, not from
|
||||
// one size standing in for all of them: with one huge chip the ring is off, and
|
||||
// taking that chip out of the question puts it back.
|
||||
const mixed = tableOf(9, narrow);
|
||||
mixed[0] = { index: 0, ...huge };
|
||||
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
|
||||
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
|
||||
"the eight ordinary ones were never the problem");
|
||||
});
|
||||
|
||||
test("and at the chips against each other", () => {
|
||||
// The ring is a smaller circle than the seats' one and holds the same number of
|
||||
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
|
||||
// players, two $10s on the ring sat on top of each other while every puck on the
|
||||
// felt was clear — so a check that only looks at the pucks says yes to a layout
|
||||
// that hides one player's bet behind another's.
|
||||
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
|
||||
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
|
||||
const box = felt(300, 336);
|
||||
const radii = { rx: 44, ry: 40.84 };
|
||||
const puck = { width: 44, height: 55 };
|
||||
const pot = { x: 150, y: 168, width: 64, height: 53 };
|
||||
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const bettors = [2, 3, 4, 5, 6, 7, 8];
|
||||
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
|
||||
|
||||
const chips = all.map(entry => ({
|
||||
...entry,
|
||||
...chipPixels(entry.index, 12, box,
|
||||
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
|
||||
}));
|
||||
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
|
||||
|
||||
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
|
||||
"this table is chosen so the pucks are not the problem");
|
||||
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
|
||||
"nor the pot");
|
||||
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
|
||||
boxesOverlap(chip, other, chip, other))), "and so the chips are");
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
|
||||
});
|
||||
|
||||
test("it looks at every puck, not just the chip's own", () => {
|
||||
// The rule this replaced walked each chip in until it was clear of its *own*
|
||||
// seat and stopped there, which says nothing about the eight other pucks on the
|
||||
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
|
||||
// good: every chip clears the puck it belongs to, and one of them is sitting on
|
||||
// a stranger's.
|
||||
const box = felt(560, 190);
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
|
||||
|
||||
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
|
||||
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
|
||||
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
|
||||
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
|
||||
"expected a chip on somebody else's puck — what the old rule could not see");
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip clinging to its puck", () => {
|
||||
// The fallback when there is no room for a ring: the chip hangs off the puck on the
|
||||
// side facing the empty middle of the felt.
|
||||
//
|
||||
// The felts the app actually draws, with the puck size each one settles on — read
|
||||
// off the running site at each viewport rather than guessed here, because the tight
|
||||
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
|
||||
// a 528x83 felt is describing a table the app never draws.
|
||||
const REAL = [
|
||||
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
|
||||
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
|
||||
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
|
||||
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
|
||||
];
|
||||
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
|
||||
// one at that size.
|
||||
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
|
||||
|
||||
// What each chip is told to do, worked out for the whole table at once.
|
||||
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
const indices = [...Array(count).keys()];
|
||||
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
|
||||
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
|
||||
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
|
||||
return indices.map(i => {
|
||||
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
|
||||
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
|
||||
const off = offs[i];
|
||||
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
|
||||
});
|
||||
};
|
||||
|
||||
test("on its own, a chip asks to sit just clear of its puck", () => {
|
||||
const box = felt(692, 113);
|
||||
const puck = { width: 72, height: 55 };
|
||||
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
|
||||
const travel = Math.hypot(off.x, off.y);
|
||||
const ux = off.x / travel, uy = off.y / travel;
|
||||
assert.ok(Math.abs(travel -
|
||||
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
|
||||
});
|
||||
|
||||
test("and gets it, where nothing else is in the way", () => {
|
||||
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
|
||||
// puck and none of them has to give ground.
|
||||
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
|
||||
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
|
||||
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it goes off the rim, not down the spoke", () => {
|
||||
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
|
||||
// top rail has a spoke pointing diagonally inward — which along a slot this flat
|
||||
// runs sideways into the next seat — while off the rim is very nearly straight
|
||||
// down, into the empty middle. This is the seam that put two chips on top of
|
||||
// each other on a Galaxy S8.
|
||||
const box = felt(692, 113);
|
||||
const rows = placeAll(9, box, { width: 72, height: 55 });
|
||||
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
|
||||
assert.ok(top, "expected a seat up on the right of the top rail");
|
||||
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
|
||||
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
|
||||
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
|
||||
});
|
||||
|
||||
test("no two chips on a full table land on each other", () => {
|
||||
// Four sides gave three seams. There are no sides now, and this is what that is
|
||||
// worth — on every felt the app actually draws, at every table size.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck);
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
|
||||
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a chip only gives ground when it is standing on something", () => {
|
||||
// The point of pulling chips back one at a time rather than by a rule that
|
||||
// applies to all of them. Stopping every chip at the felt's midline, or scaling
|
||||
// them all by one factor, takes travel from the seats at the ends of a long
|
||||
// rail — measured at 692x113, that parked two chips on their own player's stack
|
||||
// to solve a collision that did not exist.
|
||||
//
|
||||
// So: every chip either got everything it asked for, or putting the last step
|
||||
// back reintroduces a collision it is part of. The step goes back for all of
|
||||
// them at once, because a facing pair gives ground together and the pass that
|
||||
// finally separates such a pair moves both ends of it.
|
||||
const step = Render.CHIP_PULLBACK_STEP_PX;
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck).map(row => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
|
||||
const asked = travel < 1e-6 ? 0 :
|
||||
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
const back = Math.min(asked, travel + step);
|
||||
return { ...row, travel, asked,
|
||||
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
|
||||
});
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
|
||||
const onSomething = rows.some((other, j) => j !== i && (
|
||||
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
|
||||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
|
||||
assert.ok(onSomething,
|
||||
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("nobody is pushed further out than they asked to go", () => {
|
||||
// The pull-back only ever takes travel away. A chip pushed past its clearance
|
||||
// would be floating out on the felt with nothing to say whose it is.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
placeAll(12, box, puck).forEach((row, i) => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
if (travel < 1e-6) return;
|
||||
const ux = row.off.x / travel, uy = row.off.y / travel;
|
||||
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
assert.ok(travel <= asked + 0.5,
|
||||
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the direction turns smoothly all the way round the ring", () => {
|
||||
// The property a set of discrete sides cannot have, and the reason there are
|
||||
// none: neighbouring seats get neighbouring directions, with no step anywhere.
|
||||
const box = felt(560, 400);
|
||||
const angles = Array.from({ length: 24 }, (_, i) => {
|
||||
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
|
||||
return Math.atan2(off.y, off.x);
|
||||
});
|
||||
angles.forEach((angle, i) => {
|
||||
const next = angles[(i + 1) % angles.length];
|
||||
let turn = Math.abs(next - angle);
|
||||
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
||||
assert.ok(turn < Math.PI / 4,
|
||||
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it stops short of the pot total as well", () => {
|
||||
// The other thing in the middle of the felt, and the one number on the table
|
||||
// that belongs to everybody. A seat on the middle of a rail sends its chip
|
||||
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
|
||||
// "Pot $65". The badge is measured rather than assumed to be at the centre —
|
||||
// it lives on the table, not on either overlay, and the two are not concentric.
|
||||
const box = felt(528, 83);
|
||||
const pot = { x: 264, y: 40, width: 96, height: 34 };
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const indices = [...Array(12).keys()];
|
||||
const entries = indices.map(i =>
|
||||
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
|
||||
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
|
||||
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
|
||||
|
||||
indices.forEach(i => {
|
||||
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
|
||||
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
|
||||
y: (seat.top / 100) * box.height + offs[i].y };
|
||||
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
|
||||
`chip ${i + 1} is on the pot total`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a seat with no rim to be off still gets an answer", () => {
|
||||
const box = felt(400, 300);
|
||||
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
|
||||
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
|
||||
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The community cards, as a thing the table remembers.
|
||||
//
|
||||
// The board is the one part of a hand this tool never had a place for: hole cards go on
|
||||
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
|
||||
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
|
||||
// cards at all — and everything that reads it has to behave exactly as it always did
|
||||
// when it is off.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const dealt = (over = {}) => {
|
||||
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a new table", () => {
|
||||
test("has five empty slots and is not tracking them", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
assert.equal(state.trackBoard, false,
|
||||
"off by default: a dealer who never asked for this should not be asked for cards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many cards are out", () => {
|
||||
test("none, three, four, five", () => {
|
||||
assert.equal(Betting.boardCardsFor("preflop"), 0);
|
||||
assert.equal(Betting.boardCardsFor("flop"), 3);
|
||||
assert.equal(Betting.boardCardsFor("turn"), 4);
|
||||
assert.equal(Betting.boardCardsFor("river"), 5);
|
||||
});
|
||||
|
||||
test("and nothing at all for a street that isn't one", () => {
|
||||
assert.equal(Betting.boardCardsFor("showdown"), 0);
|
||||
assert.equal(Betting.boardCardsFor(undefined), 0);
|
||||
});
|
||||
|
||||
test("it matches the streets the betting engine actually deals", () => {
|
||||
// If a street is ever added, this is the table that has to hear about it —
|
||||
// a street with no entry silently draws no cards.
|
||||
Betting.STREETS.forEach(street => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
|
||||
`${street} has no card count`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing it", () => {
|
||||
test("the next hand takes the board with it", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("so does a misdeal", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and so does dealing a hand, however the table got here", () => {
|
||||
// Dealing twice off the same button — after a misdeal, or a table set up and
|
||||
// then dealt again — would otherwise rank the last hand's board against this
|
||||
// hand's cards, and it would look exactly like a real answer.
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("but turning the tracking off does not", () => {
|
||||
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
|
||||
// the way past would cost them cards they had already entered.
|
||||
const state = dealt({ trackBoard: true });
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
state.trackBoard = false;
|
||||
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a board off a file", () => {
|
||||
const migrated = board => HoldemState.migrate({
|
||||
seats: [HoldemState.makePlayer()], board,
|
||||
}).board;
|
||||
|
||||
test("a table saved before boards existed gets an empty one, not tracked", () => {
|
||||
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
|
||||
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
|
||||
assert.equal(parsed.trackBoard, false);
|
||||
});
|
||||
|
||||
test("it is always five slots, however many arrived", () => {
|
||||
assert.equal(migrated(["AH"]).length, 5);
|
||||
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
|
||||
assert.equal(migrated("not an array").length, 5);
|
||||
assert.equal(migrated(null).length, 5);
|
||||
});
|
||||
|
||||
test("anything that isn't a card becomes an empty slot", () => {
|
||||
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and a real one keeps its place, upper-cased", () => {
|
||||
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
|
||||
test("tracking is only on if the file said so, in so many words", () => {
|
||||
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
|
||||
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
|
||||
assert.equal(on.trackBoard, true);
|
||||
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Taking a chip colour out of the legend.
|
||||
//
|
||||
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
|
||||
// computeContribution() prices a seat by running the legend over that seat's chip
|
||||
// counts, so a colour that leaves takes every chip counted in it off the table.
|
||||
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
|
||||
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
|
||||
// a colour added again is a new colour with a new id, while the counts stay keyed to
|
||||
// the one that left. The counts were left behind too, so the saved file carried money
|
||||
// that nothing could price and nothing could show.
|
||||
//
|
||||
// What is tested here is mostly the number in the warning, because a warning that
|
||||
// overstates is worse than none: it is the one thing a dealer would have to trust at
|
||||
// the exact moment they are about to lose track of the box.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money } = require("./load");
|
||||
|
||||
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
state.basePotAmount = basePot;
|
||||
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = 500;
|
||||
state.seats[i].chipCounts = {};
|
||||
});
|
||||
counts.forEach(([seat, chipIndex, n]) => {
|
||||
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
|
||||
});
|
||||
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const onTable = state => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a chip colour is holding up", () => {
|
||||
test("is the money, named seat by seat", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
|
||||
|
||||
assert.equal(usage.total, 350);
|
||||
assert.equal(usage.chips, 14);
|
||||
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
|
||||
["Christopher", 4, 100],
|
||||
["Mike from work", 10, 250],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is nothing at all for a colour nobody counted", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
|
||||
assert.equal(usage.total, 0);
|
||||
assert.deepEqual(usage.seats, []);
|
||||
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
|
||||
});
|
||||
|
||||
test("and nothing for a colour that isn't there", () => {
|
||||
const state = tableWith();
|
||||
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
|
||||
{ chip: null, total: 0, chips: 0, seats: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number in the warning is the number the table loses", () => {
|
||||
// The whole point. Multiplying the chips by their value would have been the obvious
|
||||
// way to work this out and it is wrong in both directions — the two tests below are
|
||||
// the two ways it is wrong.
|
||||
const dropFromRemoving = (state, chipId) => {
|
||||
const before = onTable(state);
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
return Money.round(before - onTable(state));
|
||||
};
|
||||
|
||||
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
|
||||
// computeContribution() reads amountOverride first and never reaches the counts.
|
||||
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
|
||||
assert.equal(dropFromRemoving(state, chipId), 0);
|
||||
});
|
||||
|
||||
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
|
||||
// The $25s are everything Christopher has counted, so taking them away drops him
|
||||
// to the table's standing ante — a loss of 50, not of 250.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
|
||||
assert.equal(dropFromRemoving(state, chipId), 50);
|
||||
});
|
||||
|
||||
test("and across a spread of tables the two always agree", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
const random = rng(20260906);
|
||||
let withMoney = 0;
|
||||
|
||||
for (let table = 0; table < 2000; table++) {
|
||||
const counts = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
for (let chip = 0; chip < 5; chip++) {
|
||||
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
|
||||
}
|
||||
}
|
||||
const overrides = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
|
||||
}
|
||||
const basePot = random() < 0.5 ? 200 : null;
|
||||
|
||||
const chipIndex = Math.floor(random() * 5);
|
||||
const stated = tableWith({ counts, overrides, basePot });
|
||||
const chipId = stated.chipLegend[chipIndex].id;
|
||||
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
|
||||
|
||||
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
|
||||
if (claimed !== 0) withMoney++;
|
||||
assert.equal(claimed, actual,
|
||||
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
|
||||
JSON.stringify({ counts, overrides, basePot, chipIndex }));
|
||||
}
|
||||
|
||||
// Guards the generator: shapes that never put money on a colour would make every
|
||||
// assertion above compare zero with zero.
|
||||
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
|
||||
});
|
||||
|
||||
test("and it can be a gain, which is said as one", () => {
|
||||
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
|
||||
// stops being priced from its chips and falls through to the ante, so the table
|
||||
// goes *up* by 186. Found by the randomised check above, not by hand.
|
||||
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
|
||||
assert.equal(usage.total, -186);
|
||||
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing it", () => {
|
||||
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
|
||||
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
|
||||
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
|
||||
"a count against a colour no legend entry answers to is money nothing can show"));
|
||||
// The other colour on that seat is untouched.
|
||||
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
|
||||
});
|
||||
|
||||
test("reports what it cost, measured before anything moved", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
|
||||
assert.equal(usage.total, 100);
|
||||
assert.equal(usage.seats.length, 1);
|
||||
});
|
||||
|
||||
test("and a colour that isn't there changes nothing", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const before = onTable(state);
|
||||
const usage = HoldemState.removeChipColor(state, "no-such-chip");
|
||||
assert.equal(usage.chip, null);
|
||||
assert.equal(state.chipLegend.length, 5);
|
||||
assert.equal(onTable(state), before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// The stylesheet has to keep picking from the scales.
|
||||
//
|
||||
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
|
||||
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
|
||||
// that was anybody's decision — each number was chosen at the moment it was needed, and
|
||||
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
|
||||
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
|
||||
//
|
||||
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
|
||||
// checked here instead: a new value has to be a step on one of them, or an exception
|
||||
// with a reason written next to it.
|
||||
//
|
||||
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
|
||||
// rest of this suite has no dependencies and runs in a second, and a regex over
|
||||
// declarations is enough to catch the thing that actually happens, which is somebody
|
||||
// (me) typing a number instead of reaching for a token.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
|
||||
const css = fs.readFileSync(CSS_PATH, "utf8");
|
||||
|
||||
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
|
||||
// and none of that is a declaration. Strip them before looking for values.
|
||||
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Everything after the token block. The :root block is where the raw values are
|
||||
// supposed to be.
|
||||
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
|
||||
|
||||
const valuesIn = (properties, source = body) => {
|
||||
const found = [];
|
||||
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
|
||||
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
|
||||
// that went in — correctly by its own rules, wrongly in substance: those two values
|
||||
// are the measured heights of the dialog's sticky header and footer, a clearance
|
||||
// like the action bar's, not a step in the spacing rhythm.
|
||||
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
const [, prop, value] = m;
|
||||
// calc() and env() do their own arithmetic on tokens; the tokens inside them
|
||||
// are still checked, because the regex below looks at bare numbers only.
|
||||
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the spacing scale", () => {
|
||||
// Hairlines and structural clearances, each one deliberate:
|
||||
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
|
||||
// puck's name gives the dealer button beside it.
|
||||
// 5rem/9rem — how far the page has to scroll past the fixed action
|
||||
// bar. Not rhythm; clearance for a thing of a known size.
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
|
||||
|
||||
test("every gap, margin and padding comes off the scale", () => {
|
||||
const offenders = valuesIn(
|
||||
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"gap", "row-gap", "column-gap"])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the type scale", () => {
|
||||
// 16px — the size below which iOS zooms the page when a field is focused, so
|
||||
// every text input states it. Not a typographic choice.
|
||||
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
|
||||
// a six-figure stack on a 58px seat puck.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
|
||||
|
||||
test("every font-size comes off the scale", () => {
|
||||
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner scale", () => {
|
||||
test("every border-radius comes off it", () => {
|
||||
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
|
||||
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test("and the dark surfaces stay off pure black", () => {
|
||||
// Shadows are invisible on #000, so depth has to come from a lighter surface —
|
||||
// and white text on true black is the pairing that smears on an OLED phone,
|
||||
// which is what this tool is used on.
|
||||
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
|
||||
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
|
||||
surfaces.forEach(hex => {
|
||||
assert.notEqual(hex, "#000");
|
||||
assert.notEqual(hex, "#000000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers that change while the dealer watches", () => {
|
||||
test("are set in tabular figures", () => {
|
||||
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
|
||||
// $18 to $21 shuffles its digits sideways and the eye reads movement where
|
||||
// there is only arithmetic.
|
||||
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
|
||||
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
|
||||
.forEach(sel => assert.ok(css.includes(sel),
|
||||
`${sel} should be in the tabular-figures list`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("controls that must not resize under a thumb", () => {
|
||||
test("the undo pill has a fixed width, not a content width", () => {
|
||||
// It names the last action, so its label changes on every tap. Measured across
|
||||
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
||||
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndo rule not found");
|
||||
assert.match(rule[0], /width:\s*[0-9.]+rem/);
|
||||
});
|
||||
|
||||
test("and its label is the part that ellipsizes", () => {
|
||||
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndoLabel rule not found");
|
||||
assert.match(rule[0], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,74 @@ describe("a window with room to spare", () => {
|
||||
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the felt changes size, so does everything on it", () => {
|
||||
// The felt is published as a custom property and the seats follow it on their own,
|
||||
// because they are placed in percentages of it. A bet chip anchored inside a seat
|
||||
// is not: it is placed in pixels off that seat's centre, worked out against the felt
|
||||
// as it was, and nothing re-ran that when the felt moved.
|
||||
//
|
||||
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
|
||||
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
|
||||
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
|
||||
// for a width the felt no longer has — and two pairs of them were overlapping.
|
||||
//
|
||||
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
|
||||
const fakeDom = (windowHeight = 900) => {
|
||||
const root = { style: { values: {},
|
||||
setProperty(k, v) { this.values[k] = v; },
|
||||
getPropertyValue(k) { return this.values[k] || ""; } } };
|
||||
global.document = {
|
||||
documentElement: root,
|
||||
getElementById: id => (id === "pokerTableWrap"
|
||||
? { getBoundingClientRect: () => ({ top: 146 }) }
|
||||
: null),
|
||||
};
|
||||
global.window = { innerHeight: windowHeight, scrollY: 0 };
|
||||
return root;
|
||||
};
|
||||
|
||||
const spyRender = () => {
|
||||
const calls = { density: 0, seats: 0 };
|
||||
global.Render = {
|
||||
state: {},
|
||||
updateSeatDensity() { calls.density++; },
|
||||
renderSeats() { calls.seats++; },
|
||||
};
|
||||
return calls;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
delete global.Render;
|
||||
});
|
||||
|
||||
test("a felt that changed height lays the seats out again", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
|
||||
|
||||
ActionBar.publishFeltRoom(172);
|
||||
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
|
||||
});
|
||||
|
||||
test("a felt that did not change is left alone", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1,
|
||||
"re-laying the table on every resize tick would be work for nothing");
|
||||
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
|
||||
});
|
||||
|
||||
test("it publishes the height whether or not anything is listening", () => {
|
||||
const root = fakeDom();
|
||||
delete global.Render;
|
||||
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
|
||||
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// What shape the felt ends up, which is a question about which rule wins.
|
||||
//
|
||||
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
|
||||
// with it, and it exists because of a bug that no amount of reading caught: the upright
|
||||
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
|
||||
// 900px` block below it set the same custom property back to 1. Both queries match a
|
||||
// phone, the two declarations have identical specificity, and the later one wins. The
|
||||
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
|
||||
// on the site. It measured as a no-op and would have shipped as one.
|
||||
//
|
||||
// So this walks the media queries the way the cascade does — in source order, keeping
|
||||
// the last one that matches — instead of trusting that a rule written for a viewport is
|
||||
// the rule that viewport gets.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
|
||||
// they come out before anything here counts declarations.
|
||||
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// --- just enough of a media query engine to answer "does this viewport match?" -------
|
||||
//
|
||||
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
|
||||
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
|
||||
// new kind of query can't quietly start matching everything.
|
||||
const matches = (condition, view) => {
|
||||
if (!condition) return true;
|
||||
return condition.split(/\s+and\s+/).every(part => {
|
||||
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
|
||||
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
|
||||
const [, feature, raw] = m;
|
||||
const px = parseFloat(raw);
|
||||
switch (feature) {
|
||||
case "min-width": return view.width >= px;
|
||||
case "max-width": return view.width <= px;
|
||||
case "min-height": return view.height >= px;
|
||||
case "max-height": return view.height <= px;
|
||||
case "orientation":
|
||||
return raw === (view.width > view.height ? "landscape" : "portrait");
|
||||
default:
|
||||
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Every style rule in the file, in source order, with the media condition it sits
|
||||
// under. Written as a brace walk rather than a regex because a regex for `max-height`
|
||||
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
|
||||
// media feature is not a declaration.
|
||||
const rules = () => {
|
||||
const out = [];
|
||||
const open = [];
|
||||
let prelude = "", i = 0;
|
||||
while (i < SOURCE.length) {
|
||||
const c = SOURCE[i];
|
||||
if (c === "{") {
|
||||
const head = prelude.trim();
|
||||
prelude = "";
|
||||
if (head.startsWith("@")) { open.push(head); i++; continue; }
|
||||
let depth = 1, j = i + 1;
|
||||
while (j < SOURCE.length && depth > 0) {
|
||||
if (SOURCE[j] === "{") depth++;
|
||||
else if (SOURCE[j] === "}") depth--;
|
||||
j++;
|
||||
}
|
||||
const media = open.filter(h => h.startsWith("@media"))
|
||||
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
|
||||
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
|
||||
prelude += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const ALL_RULES = rules();
|
||||
|
||||
// The cascade, for equal-specificity declarations of one property on one selector: the
|
||||
// last one whose query matches is the one the viewport gets.
|
||||
const winnerAt = (selector, prop, view) => {
|
||||
const winning = ALL_RULES
|
||||
.filter(r => r.selector === selector && matches(r.condition, view))
|
||||
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
|
||||
return winning[1].trim();
|
||||
};
|
||||
|
||||
const PHONES = [
|
||||
{ width: 360, height: 640, name: "Galaxy S8" },
|
||||
{ width: 375, height: 667, name: "iPhone SE" },
|
||||
{ width: 390, height: 844, name: "iPhone 14" },
|
||||
{ width: 412, height: 915, name: "Pixel 7" },
|
||||
{ width: 430, height: 932, name: "Pro Max" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the shape a phone gets", () => {
|
||||
test("is an oval standing up, on every one of them", () => {
|
||||
PHONES.forEach(phone => {
|
||||
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
|
||||
assert.ok(aspect > 1.2,
|
||||
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
|
||||
`tall and this is what uses it; anything at or under 1 is the square ` +
|
||||
`felt that threw the room away.`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a wide screen still gets one lying down", () => {
|
||||
// The desktop felt is the shape it always was. Whatever the phones do, this is
|
||||
// the one a poker table looks like when there is room for it.
|
||||
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
|
||||
});
|
||||
|
||||
test("with the middle widths in between left round", () => {
|
||||
// 701-900px is one column and wide enough that neither oval is called for. It
|
||||
// has to be written as a range: a bare max-width would match the phones too and,
|
||||
// sitting lower in the file, would win there.
|
||||
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
|
||||
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what stops the oval", () => {
|
||||
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
|
||||
|
||||
test("is the room and nothing but the room", () => {
|
||||
// The oval is a preference and the room is a fact. Whatever shape is asked for
|
||||
// above, the felt gives way to the measurement rather than the other way round,
|
||||
// because the thing on the other side of the cap is a seat behind the action bar.
|
||||
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
|
||||
});
|
||||
|
||||
test("and it is not floored, however tempting that is", () => {
|
||||
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
|
||||
// and measured out again. Twelve players all in with nine side pots is the
|
||||
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
|
||||
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
|
||||
// bar. Unfloored at 142 it is flat and all twelve are visible.
|
||||
//
|
||||
// The slit it was defending against wasn't reachable: that came from forcing
|
||||
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
|
||||
assert.doesNotMatch(phoneRule, /max\(/,
|
||||
"a floor here trades a flat table for a covered seat, which is the worse of " +
|
||||
"the two — see the comment above the rule");
|
||||
});
|
||||
|
||||
test("and the width is let go of, so the height can be the one that binds", () => {
|
||||
// The wide layout sizes the felt from its width and caps that. On a phone the
|
||||
// width is the column and the height is what's scarce, so the cap moves.
|
||||
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
// What beats what.
|
||||
//
|
||||
// This is the one module in the tool that can pay the wrong person without anybody
|
||||
// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
|
||||
// ticking the box; one that answers the question itself is wrong on its own account,
|
||||
// silently, and a table that has come to trust it will not check. So the ordering is
|
||||
// tested from both ends: every category against every other, the traps that a
|
||||
// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
|
||||
// that plays), and then a lot of random seven-card hands against a second
|
||||
// implementation written a different way.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HandRank } = require("./load");
|
||||
|
||||
const best = (...cards) => HandRank.best(cards.flat());
|
||||
const beats = (a, b) => HandRank.compare(a, b) > 0;
|
||||
const chops = (a, b) => HandRank.compare(a, b) === 0;
|
||||
|
||||
// A hand written as one string, for the tests that list a lot of them.
|
||||
const hand = str => str.trim().split(/\s+/);
|
||||
|
||||
describe("the five-card hand", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("each category is recognised", () => {
|
||||
const cases = [
|
||||
["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
|
||||
["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
|
||||
["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
|
||||
["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
|
||||
["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
|
||||
["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
|
||||
["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
|
||||
["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
|
||||
["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
|
||||
["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
|
||||
];
|
||||
cases.forEach(([cards, category, described]) => {
|
||||
const ranked = of(cards);
|
||||
assert.equal(ranked.category, category, cards);
|
||||
assert.equal(HandRank.describe(ranked), described, cards);
|
||||
});
|
||||
});
|
||||
|
||||
test("the categories are in the right order", () => {
|
||||
// Listed weakest first, then every pair checked both ways round. The order
|
||||
// itself is the thing being asserted, so it is written out rather than derived
|
||||
// from the constants it is meant to be checking.
|
||||
const ladder = [
|
||||
"AC JD 8H 5S 2C", // ace high
|
||||
"2C 2D 8H 5S 3C", // a pair of deuces
|
||||
"AC AD 8H 8S 4C", // two pair
|
||||
"3C 3D 3H 7S 2C", // trips
|
||||
"9C 8D 7H 6S 5C", // straight
|
||||
"AD JD 8D 5D 2D", // flush
|
||||
"2C 2D 2H 3S 3C", // full house
|
||||
"7C 7D 7H 7S 2C", // quads
|
||||
"9H 8H 7H 6H 5H", // straight flush
|
||||
];
|
||||
for (let low = 0; low < ladder.length; low++) {
|
||||
for (let high = low + 1; high < ladder.length; high++) {
|
||||
assert.ok(beats(of(ladder[high]), of(ladder[low])),
|
||||
`${ladder[high]} should beat ${ladder[low]}`);
|
||||
assert.ok(!beats(of(ladder[low]), of(ladder[high])),
|
||||
`${ladder[low]} should not beat ${ladder[high]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the same hand in different suits is a chop", () => {
|
||||
assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
|
||||
assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
|
||||
});
|
||||
|
||||
test("nothing but five real cards is a hand", () => {
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
|
||||
assert.equal(HandRank.evaluate5(null), null);
|
||||
});
|
||||
|
||||
test("case does not matter, because the picker is not the only way cards get in", () => {
|
||||
assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wheel", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
// The straight that runs through the ace at the bottom. An evaluator that scores a
|
||||
// straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
|
||||
// the best straight there is instead of the worst — which pays the wrong player
|
||||
// every time it comes up against a broadway.
|
||||
test("A-2-3-4-5 is a straight", () => {
|
||||
assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
|
||||
assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
|
||||
});
|
||||
|
||||
test("and it is the lowest one", () => {
|
||||
assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
|
||||
"six high beats the wheel");
|
||||
assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
|
||||
"broadway beats the wheel");
|
||||
});
|
||||
|
||||
test("the same goes for a steel wheel", () => {
|
||||
const steel = of("AH 2H 3H 4H 5H");
|
||||
assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
|
||||
assert.equal(HandRank.describe(steel), "Straight flush, five high");
|
||||
assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
|
||||
assert.ok(beats(of("AS KS QS JS TS"), steel));
|
||||
});
|
||||
|
||||
test("an ace with a four-card run either side of it is not a straight", () => {
|
||||
// K-A-2-3-4 does not wrap.
|
||||
assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
|
||||
assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kickers", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("the fifth card settles two pair", () => {
|
||||
assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
|
||||
assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
|
||||
});
|
||||
|
||||
test("and a pair, three kickers deep", () => {
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
|
||||
assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
|
||||
});
|
||||
|
||||
test("the higher pair beats the better kicker", () => {
|
||||
assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
|
||||
});
|
||||
|
||||
test("two pair is read by its higher pair first", () => {
|
||||
assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
|
||||
});
|
||||
|
||||
test("a full house is read by its trips first", () => {
|
||||
assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
|
||||
});
|
||||
|
||||
test("a flush is read all the way down", () => {
|
||||
assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
|
||||
assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the best five out of seven", () => {
|
||||
test("it finds the flush the two hole cards do not both belong to", () => {
|
||||
const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
|
||||
assert.equal(ranked.category, HandRank.FLUSH);
|
||||
assert.equal(HandRank.describe(ranked), "Flush, ace high");
|
||||
});
|
||||
|
||||
test("it plays the board when the board is better than anything you hold", () => {
|
||||
// Nobody can beat a royal on the board; both hands are the board itself.
|
||||
const board = ["AS", "KS", "QS", "JS", "TS"];
|
||||
const one = best(["2C", "3D"], board);
|
||||
const two = best(["7H", "8H"], board);
|
||||
assert.equal(HandRank.describe(one), "Royal flush");
|
||||
assert.ok(chops(one, two), "the board plays, so it is a chop");
|
||||
});
|
||||
|
||||
test("it does not use three hole cards, because there are only two", () => {
|
||||
const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
|
||||
assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
|
||||
});
|
||||
|
||||
test("a counterfeited two pair falls back on the board", () => {
|
||||
// Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
|
||||
// the best five are the board's two pair with the ace, and the sixes never play.
|
||||
const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
|
||||
assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
|
||||
assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
|
||||
});
|
||||
|
||||
test("five, six and seven cards are all rankable; four is not", () => {
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
|
||||
assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
|
||||
});
|
||||
|
||||
test("the same card twice is not a hand", () => {
|
||||
// A mis-entry, not a monster. Ranking it would mean ranking a deck that does
|
||||
// not exist, and the answer would look exactly as confident as a real one.
|
||||
assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
|
||||
assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the showdown", () => {
|
||||
const board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
|
||||
test("it names one winner and says how it read every hand", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
|
||||
{ id: "b", cards: ["9H", "9S"] }, // trips
|
||||
{ id: "c", cards: ["AC", "3D"] }, // a pair of aces
|
||||
]);
|
||||
assert.equal(result.complete, true);
|
||||
assert.deepEqual(result.winners, ["b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
|
||||
assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
|
||||
"Two pair, aces and kings",
|
||||
"Three of a kind, nines",
|
||||
"Pair of aces",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a chop comes back as two winners on the same place", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "QC"] },
|
||||
{ id: "b", cards: ["AD", "QH"] },
|
||||
{ id: "c", cards: ["7H", "5S"] },
|
||||
]);
|
||||
assert.deepEqual(result.winners.sort(), ["a", "b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
|
||||
"and the player behind them is third, not second");
|
||||
});
|
||||
|
||||
test("an incomplete board is not a showdown", () => {
|
||||
const result = HandRank.showdown(["AH", "KD", "9C"], [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.boardComplete, false);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, [], "it must not name one");
|
||||
});
|
||||
|
||||
test("one unknown hand and it will not say who won", () => {
|
||||
// Who is *ahead* and who *won* are different claims, and the second one is the
|
||||
// only one that can pay somebody. A player who never turned their cards over
|
||||
// could have anything.
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
assert.deepEqual(result.unknown, ["b"]);
|
||||
assert.ok(result.players[0].hand, "but it still reads the hands it can");
|
||||
assert.equal(result.players[1].hand, null);
|
||||
});
|
||||
|
||||
test("a card in two places stops it dead", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.duplicated, true);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
|
||||
test("no players at all is not a showdown either", () => {
|
||||
const result = HandRank.showdown(board, []);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("against a second opinion", () => {
|
||||
// The strongest check available without a poker library: a completely separate
|
||||
// ranking, written the other way round — every hand turned into a sortable string
|
||||
// by counting ranks, with no shared helper between it and the module. Where the two
|
||||
// disagree, one of them is wrong and the test says which hand it was.
|
||||
const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
|
||||
const val = card => VALUE[card[0]] || Number(card[0]);
|
||||
|
||||
function reference5(cards) {
|
||||
const ranks = cards.map(val).sort((x, y) => y - x);
|
||||
const suited = new Set(cards.map(c => c[1])).size === 1;
|
||||
|
||||
const tally = {};
|
||||
ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
|
||||
const bySize = Object.keys(tally)
|
||||
.map(Number)
|
||||
.sort((x, y) => tally[y] - tally[x] || y - x);
|
||||
const sizes = bySize.map(r => tally[r]).join("");
|
||||
|
||||
const unique = [...new Set(ranks)];
|
||||
let run = null;
|
||||
if (unique.length === 5) {
|
||||
if (unique[0] - unique[4] === 4) run = unique[0];
|
||||
else if (unique.join(",") === "14,5,4,3,2") run = 5;
|
||||
}
|
||||
|
||||
// [category, ...tiebreak], the same shape, arrived at from the group sizes
|
||||
// rather than from a chain of ifs about shape[0] and shape[1].
|
||||
if (suited && run !== null) return [8, run];
|
||||
if (sizes === "41") return [7, ...bySize];
|
||||
if (sizes === "32") return [6, ...bySize];
|
||||
if (suited) return [5, ...ranks];
|
||||
if (run !== null) return [4, run];
|
||||
if (sizes === "311") return [3, ...bySize];
|
||||
if (sizes === "221") return [2, ...bySize];
|
||||
if (sizes === "2111") return [1, ...bySize];
|
||||
return [0, ...ranks];
|
||||
}
|
||||
|
||||
const worse = (a, b) => {
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function referenceBest(cards) {
|
||||
let top = null;
|
||||
for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
|
||||
for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
|
||||
for (let e = d + 1; e < 7; e++) {
|
||||
const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
if (!top || worse(top, score)) top = score;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// A deck, and a deterministic shuffle — a failure has to be reproducible or it is
|
||||
// just an anecdote about one CI run.
|
||||
const DECK = [];
|
||||
"23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
|
||||
|
||||
let seed = 20260904;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
test("twenty thousand random seven-card hands rank the same way", () => {
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 20000; round++) {
|
||||
// Seven off the top of a shuffle of only what it needs.
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const mine = HandRank.best(drawn);
|
||||
const theirs = referenceBest(drawn);
|
||||
assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
|
||||
`disagreed on ${drawn.join(" ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and four thousand random pairs of hands are ordered the same way", () => {
|
||||
// The categories agreeing is not the same as the comparison agreeing: this is
|
||||
// the half that pays somebody.
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 4000; round++) {
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const board = drawn.slice(0, 5);
|
||||
const one = [...board, drawn[5], drawn[6]];
|
||||
const two = [...board, drawn[7], drawn[8]];
|
||||
|
||||
const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
|
||||
const a = referenceBest(one), b = referenceBest(two);
|
||||
const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
|
||||
assert.equal(mine, theirs,
|
||||
`board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saying it in a sentence", () => {
|
||||
// The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
|
||||
// its own article. Which article is not something the first letter can answer: it
|
||||
// is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
|
||||
// and nobody has ever said "an ace high".
|
||||
test("every category takes the article a table would give it", () => {
|
||||
const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
|
||||
assert.deepEqual([
|
||||
said("AS KS QS JS TS"),
|
||||
said("9H 8H 7H 6H 5H"),
|
||||
said("7C 7D 7H 7S 2C"),
|
||||
said("KC KD KH 3S 3C"),
|
||||
said("AD JD 8D 5D 2D"),
|
||||
said("9C 8D 7H 6S 5C"),
|
||||
said("QC QD QH 7S 2C"),
|
||||
said("AC AD 8H 8S 4C"),
|
||||
said("QC QD 9H 5S 2C"),
|
||||
said("AC JD 8H 5S 2C"),
|
||||
], [
|
||||
"a royal flush",
|
||||
"a straight flush, nine high",
|
||||
"four of a kind, sevens",
|
||||
"a full house, kings full of threes",
|
||||
"a flush, ace high",
|
||||
"a straight, nine high",
|
||||
"three of a kind, queens",
|
||||
"two pair, aces and eights",
|
||||
"a pair of queens",
|
||||
"ace high",
|
||||
]);
|
||||
});
|
||||
|
||||
test("with nothing to say it says something", () => {
|
||||
assert.equal(HandRank.phrase(null), "the best hand");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function load(name) {
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const HandRank = load("handRank");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
@@ -33,6 +34,10 @@ globalThis.HoldemState = HoldemState;
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
|
||||
// is plain state work and testable here. Only render() and open() touch the DOM.
|
||||
const CardPicker = load("cardPicker");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -78,6 +83,7 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
HandRank,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
@@ -86,6 +92,7 @@ module.exports = {
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// What a shut sidebar panel says about itself.
|
||||
//
|
||||
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
|
||||
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
|
||||
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
|
||||
// whether anyone is waiting for a seat, how many hands have been played: the page had
|
||||
// all three worked out and charged a tap for each of them, on rows two thirds empty.
|
||||
//
|
||||
// So each one now carries the fact you would have opened it for. Two rules hold this
|
||||
// together, and both are checked here.
|
||||
//
|
||||
// A glance appears only when it has something a dealer would act on. Eight rows that
|
||||
// always say something are eight rows that say nothing again, and a row reading
|
||||
// "Nobody waiting" all night is the clutter this went in to remove.
|
||||
//
|
||||
// And a glance never says something the panel under it would contradict. It is the
|
||||
// panel's own sentence with the explanation taken off, so it has to be read off the
|
||||
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
|
||||
// at all.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const glances = state => Render.panelGlances(state);
|
||||
const textOf = (state, id) => (glances(state)[id] || {}).text;
|
||||
|
||||
// A table part-way through a night: money on it, so the bank check has an opinion.
|
||||
const night = () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
|
||||
smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the rows that always have something to say", () => {
|
||||
test("the table's own shape and stakes", () => {
|
||||
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
|
||||
});
|
||||
|
||||
test("and the stakes drop out rather than reading as free", () => {
|
||||
// A table set up with no blinds yet is a real state — it is what the panel
|
||||
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
|
||||
// than an admission that nobody has said yet.
|
||||
const state = night();
|
||||
state.smallBlind = 0;
|
||||
state.bigBlind = 0;
|
||||
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
|
||||
});
|
||||
|
||||
test("the chip values, as a range rather than a count of rows", () => {
|
||||
// "Are the values right for tonight's set" is the question this answers.
|
||||
// Five colours is the answer to a different one.
|
||||
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
|
||||
});
|
||||
|
||||
test("with a chip nobody has priced yet left out of the range", () => {
|
||||
// A blank row in the legend is a chip the dealer hasn't got to, not a
|
||||
// zero-dollar chip — and letting it in would read the range as starting at $0.
|
||||
const state = night();
|
||||
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
|
||||
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
|
||||
});
|
||||
|
||||
test("and one chip is a value, not a range of one", () => {
|
||||
const state = night();
|
||||
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
|
||||
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check, which is the one worth a colour", () => {
|
||||
test("says so when the cash covers the chips", () => {
|
||||
const state = night();
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Balanced");
|
||||
assert.equal(tone, "ok");
|
||||
});
|
||||
|
||||
test("and names the gap when it doesn't", () => {
|
||||
// Chips on the table that nobody paid for. The glance is the alarm and the
|
||||
// amount; which seat is holding them is a paragraph, and that is the half
|
||||
// that stays behind the tap.
|
||||
const state = night();
|
||||
state.seats[0].buyIns = 100;
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Off by $100");
|
||||
assert.equal(tone, "warn");
|
||||
});
|
||||
|
||||
test("the same way round when the cash is the side that's over", () => {
|
||||
// One word covers both signs on purpose: the number and the alarm are what a
|
||||
// dealer acts on, and "short" against "over" is a direction they would have to
|
||||
// hold in their head to read. The panel says which way in a sentence.
|
||||
const state = night();
|
||||
state.seats[0].buyIns = 400;
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Off by $200");
|
||||
assert.equal(tone, "warn");
|
||||
});
|
||||
|
||||
test("and stays quiet over a table nobody has bought into", () => {
|
||||
// The panel hides its own check here for the same reason. A glance reading
|
||||
// "Balanced" over an empty table is answering for a count never taken.
|
||||
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
|
||||
assert.equal(textOf(state, "glanceStandings"), "");
|
||||
assert.equal(glances(state).glanceStandings.tone, "");
|
||||
});
|
||||
|
||||
test("and agrees with the sentence the panel would print", () => {
|
||||
// The two are read off one call apiece and must never disagree — a glance
|
||||
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
|
||||
// is worse than no glance at all.
|
||||
[0, 100, 400].forEach(buyIn => {
|
||||
const state = night();
|
||||
state.seats[0].buyIns = buyIn;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
|
||||
if (!bank.balanced) {
|
||||
assert.ok(text.includes(String(Math.abs(bank.difference))),
|
||||
`${text} doesn't carry the panel's own ${bank.difference}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rows that stay quiet until they have news", () => {
|
||||
test("nobody waiting, no note, no hands played yet", () => {
|
||||
const state = night();
|
||||
assert.equal(textOf(state, "glanceWaiting"), "");
|
||||
assert.equal(textOf(state, "glanceHands"), "");
|
||||
assert.equal(textOf(state, "glanceNotes"), "");
|
||||
});
|
||||
|
||||
test("and speak up the moment they do", () => {
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||||
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
|
||||
assert.equal(textOf(state, "glanceHands"), "1 hand");
|
||||
});
|
||||
|
||||
test("counting one of something as one of it", () => {
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
|
||||
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
|
||||
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
|
||||
assert.equal(textOf(state, "glanceHands"), "2 hands");
|
||||
});
|
||||
|
||||
test("and a note shows which note it is, not that there is one", () => {
|
||||
// "Has notes" costs a tap to learn anything, which is the whole failure this
|
||||
// is fixing. The text is what identifies it; the row's own width ellipsises.
|
||||
const state = night();
|
||||
state.notes = "Sam owes Dee $20 from last night";
|
||||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
|
||||
});
|
||||
|
||||
test("reading past the blank lines a note gets typed under", () => {
|
||||
// Notes get written as a list — a heading, a blank line, then the thing. The
|
||||
// first line of the string is regularly not the first line of the note.
|
||||
const state = night();
|
||||
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
|
||||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
|
||||
});
|
||||
|
||||
test("and a note of nothing but whitespace is no note", () => {
|
||||
const state = night();
|
||||
state.notes = "\n\n \t \n";
|
||||
assert.equal(textOf(state, "glanceNotes"), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the row is allowed to be", () => {
|
||||
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
|
||||
"glanceHands", "glanceChips", "glanceNotes"];
|
||||
|
||||
test("every glance the sidebar renders is one this knows about", () => {
|
||||
// The list drives the DOM writes, so a glance computed and never listed is a
|
||||
// row that silently keeps its old text for the rest of the night.
|
||||
const state = night();
|
||||
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
|
||||
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
|
||||
});
|
||||
|
||||
test("and none of them is a paragraph", () => {
|
||||
// The row is 44px of a 320px column with a label already on it. Anything
|
||||
// long enough to need the panel's room belongs in the panel.
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||||
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
|
||||
const text = textOf(state, id);
|
||||
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
|
||||
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
|
||||
});
|
||||
});
|
||||
|
||||
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
|
||||
// The one glance whose length isn't ours to choose. It must still be a single
|
||||
// line — a second one would make the row taller, and the row is what holds the
|
||||
// sidebar to eight equal slabs.
|
||||
const state = night();
|
||||
state.notes = "x".repeat(400) + "\nsecond line";
|
||||
const text = textOf(state, "glanceNotes");
|
||||
assert.ok(!text.includes("\n"));
|
||||
assert.equal(text, "x".repeat(400));
|
||||
});
|
||||
|
||||
test("and a state that isn't there yet asks for nothing", () => {
|
||||
// renderAll runs before the first load has finished on a cold open.
|
||||
assert.deepEqual(Render.panelGlances(null), {});
|
||||
assert.deepEqual(Render.panelGlances(undefined), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Who can win each pot, said in the fewest names that answer it.
|
||||
//
|
||||
// The breakdown used to list every eligible player for every pot. Pot eligibility is
|
||||
// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's
|
||||
// line was every name at the table and each side pot repeated it minus one. The lines
|
||||
// that were too long to fit were exactly the ones nobody needed spelled out, and the
|
||||
// short ones at the bottom were the side pots a dealer actually announces by name.
|
||||
//
|
||||
// Measured on an iPhone 14 before this: one all-in five-handed already clipped the main
|
||||
// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and
|
||||
// no title — a dealer reading "Alexandra…" could not tell whether three people or
|
||||
// eleven were eligible, which is the number the showdown turns on.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Names are "P<id>" so a test reads as ids and the joining is still exercised.
|
||||
const nameFor = id => `P${id}`;
|
||||
const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor);
|
||||
|
||||
describe("the eligible line", () => {
|
||||
test("says so plainly when the whole table can win it", () => {
|
||||
// The main pot, every hand. Twelve names to say "nobody is excluded yet" was
|
||||
// the single longest line in the component and the least informative.
|
||||
assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3");
|
||||
assert.equal(say(["a"], ["a"]), "all 1");
|
||||
});
|
||||
|
||||
test("names who is out when that is the shorter list", () => {
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa");
|
||||
assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb");
|
||||
assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc");
|
||||
});
|
||||
|
||||
test("names who is in when that is the shorter list", () => {
|
||||
// The far side pots, which are the ones a dealer announces by name: "side pot
|
||||
// is 80 between Ben and Carlos".
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["g", "h"], table), "2 in, Pg and Ph");
|
||||
assert.equal(say(["h"], table), "1 in, Ph");
|
||||
assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph");
|
||||
});
|
||||
|
||||
test("and when the two are the same length, says who is in", () => {
|
||||
// A tie is not a reason to make the dealer do the subtraction: "who can win
|
||||
// this" is the question, so answer it directly when it costs nothing.
|
||||
const table = ["a", "b", "c", "d"];
|
||||
assert.equal(say(["c", "d"], table), "2 in, Pc and Pd");
|
||||
});
|
||||
|
||||
test("leads with the count, because it is the tail that gets cut off", () => {
|
||||
// .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone
|
||||
// loses its end. Every form has to begin with the number of hands to ask for.
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
|
||||
[table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]]
|
||||
.forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/,
|
||||
`"${say(eligible, table)}" buries the count where a narrow screen can eat it`));
|
||||
});
|
||||
|
||||
test("never spells out more than half the table", () => {
|
||||
// The point of picking the shorter side. Nested eligibility makes the two lists
|
||||
// complements, so this is a guarantee rather than a hope.
|
||||
const table = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||
for (let inCount = 1; inCount <= 12; inCount++) {
|
||||
const line = say(table.slice(0, inCount), table);
|
||||
const named = (line.match(/P/g) || []).length;
|
||||
assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an
|
||||
// element to answer what the row is made of.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
describe("the row it builds", () => {
|
||||
const pots = [
|
||||
{ label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] },
|
||||
{ label: "Side pot 1", amount: 150, eligible: ["c", "d"] },
|
||||
];
|
||||
const textOf = row => row.children.map(c => c.textContent);
|
||||
|
||||
test("carries the full list in a title, for anyone with a pointer", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
const who = wrap.children.map(row => row.children[2]);
|
||||
assert.equal(who[0].title, "Pa, Pb, Pc, Pd");
|
||||
assert.equal(who[1].title, "Pc, Pd");
|
||||
// The visible text is the short form; the title is the long one. A tooltip
|
||||
// is not a mechanism a keyboard reaches, which is why it is the extra and
|
||||
// not the answer.
|
||||
assert.equal(who[0].textContent, "all 4");
|
||||
assert.equal(who[1].textContent, "2 in, Pc and Pd");
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("puts the label, the money and the people in that order", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]);
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("and an uncalled bet still reads as money going back", () => {
|
||||
// Not an eligibility question — nobody is competing for it — so it keeps its
|
||||
// own wording and gets no title to expand.
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor);
|
||||
const row = wrap.children[2];
|
||||
assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]);
|
||||
assert.equal(row.children[2].title, "");
|
||||
} finally { undo(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// The bar's pot list is not allowed to grow with the table.
|
||||
//
|
||||
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
|
||||
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
|
||||
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
|
||||
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
|
||||
// whose height depends on the state is a fault whatever it is showing, so the cap is
|
||||
// checked here rather than left to whoever next adds an all-in to the audit sweep.
|
||||
//
|
||||
// The two ends survive because they are the two that are live. Side pots are paid from
|
||||
// the newest one back down to the main pot, so the top layer is the next thing that
|
||||
// happens and the main pot is the last, and those are the two a dealer says out loud
|
||||
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
|
||||
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
|
||||
// passes no cap and still lists all of them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Money, PotCalculator } = require("./load");
|
||||
|
||||
const nameFor = id => `P${id}`;
|
||||
|
||||
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
|
||||
// is made of, and no jsdom in a suite that runs in a second.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
// A table where everyone is all-in for a different amount, which is the shape that
|
||||
// makes the most pot layers — the worst case and the one that was measured. `count`
|
||||
// players ladder into `count - 1` layers: two of them share a single undivided pot and
|
||||
// the breakdown declines to draw at all, which is why nothing below three appears here.
|
||||
const laddered = count => PotCalculator.layers(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `s${i}`, contribution: 100 + i * 130, folded: false,
|
||||
})));
|
||||
|
||||
const build = (pots, options) => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
|
||||
return wrap.children.map(row => ({
|
||||
className: row.className,
|
||||
label: row.children[0].textContent,
|
||||
amount: row.children[1].textContent,
|
||||
who: row.children[2].textContent,
|
||||
title: row.children[2].title,
|
||||
}));
|
||||
} finally { undo(); }
|
||||
};
|
||||
|
||||
describe("the bar's copy of the pot list", () => {
|
||||
test("stops growing once there are more pots than it has room for", () => {
|
||||
// The property that matters: rows are a function of the cap, not of the table.
|
||||
// Twelve-handed and six-handed have to render the same number of lines.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const rows = build(laddered(players), { maxLayers: 4 });
|
||||
assert.ok(rows.length <= 4,
|
||||
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and the number of rows never depends on how many people are all-in", () => {
|
||||
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
|
||||
assert.deepEqual(wide, [3, 3, 3, 3, 3],
|
||||
"past the cap every table should draw main, the elision and the top pot");
|
||||
});
|
||||
|
||||
test("keeps the two pots a dealer actually announces", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
|
||||
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
|
||||
"the newest side pot is the first one paid");
|
||||
});
|
||||
|
||||
test("and leaves them in the order the pots were made", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
assert.match(rows[1].className, /potLayerMore/,
|
||||
"the elision belongs where the gap is, not appended after the list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the money in the rows that were elided", () => {
|
||||
test("is still on the screen, added up", () => {
|
||||
// The one thing a summary row must not do is lose chips. Whatever is drawn has
|
||||
// to account for every pot, or a dealer counting the announcement against the
|
||||
// chips in the middle finds a shortfall the tool caused.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const pots = laddered(players);
|
||||
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||
const drawn = build(pots, { maxLayers: 4 })
|
||||
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
|
||||
assert.equal(drawn, total,
|
||||
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and says how many it stands for", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
const pots = laddered(12);
|
||||
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
|
||||
});
|
||||
|
||||
test("with the list itself in a title, for anyone with a pointer", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
pots.slice(1, -1).forEach(pot => {
|
||||
assert.ok(rows[1].title.includes(pot.label),
|
||||
`${pot.label} vanished without trace`);
|
||||
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
|
||||
`${pot.label}'s amount vanished without trace`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and points at where the whole list is", () => {
|
||||
// The title is a pointer affordance, so it cannot be the answer on a phone. The
|
||||
// visible text has to say where the rest lives.
|
||||
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when eliding would not buy anything", () => {
|
||||
test("a list that fits is left alone", () => {
|
||||
for (let players = 3; players <= 5; players++) {
|
||||
const pots = laddered(players);
|
||||
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
|
||||
}
|
||||
});
|
||||
|
||||
test("and a single middle layer is not replaced by a line saying there is one", () => {
|
||||
// At exactly the cap the middle is one row, and swapping one row for one row is
|
||||
// pure indirection: the dealer loses an amount and gains a sentence.
|
||||
const pots = laddered(5);
|
||||
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, 4);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard, which has room", () => {
|
||||
test("still gets every layer", () => {
|
||||
// Same builder, no cap — this is the announcement read out before anybody shows
|
||||
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
|
||||
// pots at the moment they are being paid.
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, undefined);
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
|
||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||
});
|
||||
});
|
||||
|
||||
// The pots come out of layers() in one order and with one relationship between them,
|
||||
// and the action bar's wording now depends on both. If eligibility were not nested,
|
||||
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
|
||||
// pot who was never eligible for the main one, and naming the complement would hide
|
||||
// them. It is a property of how the layers are built rather than an accident of the
|
||||
// examples above, so it is checked as one.
|
||||
describe("pot eligibility is nested", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test("every side pot's players are a strict subset of the pot below it", () => {
|
||||
const random = rng(20260905);
|
||||
let multiPotTables = 0;
|
||||
let pairs = 0;
|
||||
|
||||
for (let table = 0; table < 20000; table++) {
|
||||
const n = 2 + Math.floor(random() * 11);
|
||||
const players = Array.from({ length: n }, (_, i) => p(
|
||||
`p${i}`,
|
||||
// A sixth of the table never puts anything in, which is a table mid-deal.
|
||||
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
|
||||
random() < 0.2));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
if (layers.length > 1) multiPotTables++;
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const below = layers[i - 1].eligible;
|
||||
const belowSet = new Set(below);
|
||||
const here = layers[i].eligible;
|
||||
pairs++;
|
||||
assert.ok(here.every(id => belowSet.has(id)),
|
||||
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
|
||||
JSON.stringify(players));
|
||||
assert.ok(here.length < below.length,
|
||||
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
|
||||
`layers with the same eligibility are supposed to be merged into one pot\n` +
|
||||
JSON.stringify(players));
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the generator rather than the code: a shape that stopped producing side
|
||||
// pots would make every assertion above vacuous and the test would still pass.
|
||||
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
|
||||
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
|
||||
});
|
||||
|
||||
test("so the main pot is everyone still live, and it is the widest", () => {
|
||||
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
|
||||
layers.slice(1).forEach(layer =>
|
||||
assert.ok(layer.eligible.length < layers[0].eligible.length));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Reading the showdown: the picker that takes the cards in, and the pot maths that
|
||||
// pays somebody on the strength of them.
|
||||
//
|
||||
// The evaluator is tested to death next door in handRank.test.js. What is tested here
|
||||
// is the join — the two places where a correct ranking can still pay the wrong player:
|
||||
// a side pot, where the question is asked again of a smaller field, and a card entered
|
||||
// in two places, where the ranking is of a deck that does not exist.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load");
|
||||
|
||||
describe("the picker, filling the board", () => {
|
||||
const table = (board = ["", "", "", "", ""]) => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = board;
|
||||
return state;
|
||||
};
|
||||
|
||||
// The sheet's own drawing is the DOM's business; the slot bookkeeping under it is
|
||||
// not, and that is what these ask about.
|
||||
const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; };
|
||||
|
||||
const onBoard = (state, cardIndex = 0, limit = 5) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = limit;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
const onSeat = (state, seatIndex, cardIndex = 0) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = seatIndex;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("it only offers the slots that have actually been turned", () => {
|
||||
const state = table();
|
||||
assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three");
|
||||
assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four");
|
||||
assert.equal(onBoard(state, 0, 5).slotValues().length, 5);
|
||||
});
|
||||
|
||||
test("a card goes into the board, not into a seat", () => {
|
||||
const state = table();
|
||||
onBoard(state, 1, 3).assign("KD");
|
||||
assert.deepEqual(state.board, ["", "KD", "", "", ""]);
|
||||
assert.deepEqual(state.seats[0].cards, ["", ""]);
|
||||
});
|
||||
|
||||
test("it walks on to the next empty slot, so a flop is three taps", () => {
|
||||
const state = table();
|
||||
const picker = onBoard(state, 0, 3);
|
||||
picker.assign("KD");
|
||||
assert.equal(picker.cardIndex, 1);
|
||||
picker.assign("9C");
|
||||
assert.equal(picker.cardIndex, 2);
|
||||
picker.assign("4S");
|
||||
assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]);
|
||||
});
|
||||
|
||||
test("and it does not walk past the slots it was given", () => {
|
||||
// Filling the turn must not run on to the river: that card is still in the deck.
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
const picker = onBoard(state, 3, 4);
|
||||
picker.assign("AH");
|
||||
assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]);
|
||||
});
|
||||
|
||||
test("clearing a slot empties that one and nothing else", () => {
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
onBoard(state, 1, 3).clearSlot();
|
||||
assert.deepEqual(state.board, ["KD", "", "4S", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a card cannot be in two places", () => {
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = ["KD", "9C", "4S", "", ""];
|
||||
state.seats[0].cards = ["AH", "AS"];
|
||||
state.seats[1].cards = ["", ""];
|
||||
return state;
|
||||
};
|
||||
|
||||
const picker = (state, setup) => {
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
setup();
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("the board's cards are unavailable to a hand", () => {
|
||||
// The half of this that did not exist before: the picker knew about every
|
||||
// player's cards and nothing about the middle of the table, so the king of
|
||||
// diamonds on the flop could be dealt to somebody as well.
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 1;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.get("KD"), "the board");
|
||||
assert.equal(used.get("9C"), "the board");
|
||||
assert.ok(used.has("AH"), "and the other players' are still in there");
|
||||
});
|
||||
|
||||
test("and a hand's cards are unavailable to the board", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 5;
|
||||
CardPicker.cardIndex = 3;
|
||||
}).cardsInUse();
|
||||
assert.ok(used.has("AH"), "the ace in seat 1's hand");
|
||||
assert.ok(used.has("KD"), "and the rest of the board");
|
||||
});
|
||||
|
||||
test("but the slot being edited is not competing with itself", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 3;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate");
|
||||
assert.equal(used.has("9C"), true);
|
||||
});
|
||||
|
||||
test("the same goes for a seat's own slot", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 0;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("AH"), false);
|
||||
assert.equal(used.has("AS"), true, "the player's other card still is");
|
||||
});
|
||||
});
|
||||
|
||||
describe("who wins a side pot", () => {
|
||||
// The case a single ranking of the whole table gets wrong. The best hand takes the
|
||||
// main pot and is then out of the question: the side pot goes to the best of who is
|
||||
// left, which is a different answer, and it is the answer the wizard has to ask for
|
||||
// again each round rather than reading off one list.
|
||||
//
|
||||
// Three players, and the short stack has the best hand of the three.
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
const hands = {
|
||||
short: ["9H", "9S"], // trips — the best hand at the table
|
||||
big: ["AS", "KC"], // two pair, aces and kings
|
||||
other: ["AC", "3D"], // a pair of aces
|
||||
};
|
||||
|
||||
const entries = () => PotCalculator.buildEntries([
|
||||
{ id: "short", contribution: 20, folded: false },
|
||||
{ id: "big", contribution: 100, folded: false },
|
||||
{ id: "other", contribution: 100, folded: false },
|
||||
]);
|
||||
|
||||
// What the wizard does: read the hands still in for *this* round.
|
||||
const readRound = state => HandRank.showdown(board,
|
||||
PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] })));
|
||||
|
||||
test("the short stack takes the main pot with the best hand", () => {
|
||||
const round = readRound(entries());
|
||||
assert.equal(round.complete, true);
|
||||
assert.deepEqual(round.winners, ["short"]);
|
||||
});
|
||||
|
||||
test("and the side pot goes to the best of the two who are left", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
// 20 from each of three: the short stack is paid 60 and is out of it.
|
||||
assert.equal(Money.round(first.perWinner[0].amount), 60);
|
||||
|
||||
const round = readRound(first.updatedEntries);
|
||||
assert.deepEqual(round.winners, ["big"],
|
||||
"not the trips, which are no longer eligible for this money");
|
||||
assert.equal(round.players.length, 2, "and the short stack is not even offered");
|
||||
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(second.perWinner[0].amount), 160);
|
||||
});
|
||||
|
||||
test("every chip is accounted for across the two rounds", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(first.payoutPool + second.payoutPool), 220);
|
||||
assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to answer", () => {
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
|
||||
test("a folded player's cards are not in the running", () => {
|
||||
// They are not a contender, so they are never handed in — which means a hand
|
||||
// that would have won cannot take a pot its owner threw away.
|
||||
const table = PotCalculator.buildEntries([
|
||||
{ id: "live", contribution: 50, folded: false },
|
||||
{ id: "mucked", contribution: 50, folded: true },
|
||||
]);
|
||||
const contenders = PotCalculator.getContenders(table);
|
||||
assert.deepEqual(contenders.map(c => c.id), ["live"]);
|
||||
|
||||
const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] })));
|
||||
assert.deepEqual(round.winners, ["live"], "even holding nothing");
|
||||
});
|
||||
|
||||
test("one hand not turned over and it hands the question back", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
assert.deepEqual(round.unknown, ["b"]);
|
||||
});
|
||||
|
||||
test("a board still being dealt is not a showdown", () => {
|
||||
const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("a card entered twice stops it rather than guessing", () => {
|
||||
// The failure mode that matters: a mis-typed card produces a hand that ranks
|
||||
// perfectly well and is not the one on the table. Refusing is the only honest
|
||||
// answer, and the dealer picks the winner the way they always could.
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("and two players holding the same card is caught as well", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["QH", "QS"] },
|
||||
{ id: "b", cards: ["QH", "JC"] },
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table that never asked for any of this", () => {
|
||||
test("has no board, tracks nothing, and reads no showdown", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(state.trackBoard, false);
|
||||
// Which is what the wizard checks before it reads anything at all — see
|
||||
// readShowdown() in potWizard.js. With it off, an empty board would decline
|
||||
// anyway, so the tool cannot start answering a question nobody asked.
|
||||
const round = HandRank.showdown(state.board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
});
|
||||
+178
-35
@@ -34,26 +34,64 @@ const OVERLAYS = [
|
||||
];
|
||||
|
||||
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
||||
function seedTable() {
|
||||
//
|
||||
// Two of them. The sweep ran on one table shape for its whole life — five players at a
|
||||
// nine-handed table — and how much room the felt has depends on how many people are
|
||||
// sitting down more than on anything a viewport can tell you. A full table is where the
|
||||
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
||||
// of fifty-four tables had one player's name or stack painted under another.
|
||||
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
|
||||
|
||||
const TABLES = [
|
||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], false],
|
||||
["packed", 12, PACKED],
|
||||
// The same twelve, every one of them all-in for a different amount. This is the
|
||||
// shape that makes a pot layer per player, and the sweep had never reached it:
|
||||
// every table here was driven with Call and Check, so the side-pot breakdown — the
|
||||
// one thing on the bar whose height depends on the table — was empty at all 294
|
||||
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
|
||||
// left 96px of felt for twelve seats.
|
||||
["all in", 12, PACKED, false, 12],
|
||||
// The half-full table again, with the board turned on and five cards on it. The
|
||||
// board is furniture in the middle of the felt that nothing else was ever measured
|
||||
// against — and where the middle cannot hold it, it moves to a strip above the
|
||||
// table and takes the felt's height with it, which is the seats' problem too.
|
||||
//
|
||||
// Deliberately the same five players in the same nine chairs as the shape above, so
|
||||
// the only thing that differs between them is the board. A seventh player would have
|
||||
// told us that a seven-handed table crowds, which is true, already known, and not
|
||||
// what this row is here to find out.
|
||||
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], true],
|
||||
];
|
||||
|
||||
function seedTable([seats, players, board]) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
|
||||
.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
players.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
for (let hand = 1; hand <= 4; hand++) {
|
||||
state.handHistory.push({
|
||||
handNumber: hand, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, players.length)
|
||||
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
if (board) {
|
||||
state.trackBoard = true;
|
||||
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
}
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
@@ -85,33 +123,138 @@ async function main() {
|
||||
const hand = touch ? "touch" : "mouse";
|
||||
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
for (const [shape, seats, players, board, shoves] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
// Deal it, and get chips in front of a few players. The sweep used to
|
||||
// audit a table that had never had a bet on it, so the bets layer was
|
||||
// empty at every viewport it visited and nothing it drew was ever looked
|
||||
// at — which is a third of what is on the felt during a hand.
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
await page.waitForTimeout(220);
|
||||
// Everyone's whole stack in, which is what stacks up the side pots.
|
||||
// There is no one-tap shove: a player who can't cover the bet gets
|
||||
// "Call all-in", and anyone else has to open the raise pad, take its
|
||||
// All-in preset and confirm.
|
||||
for (let i = 0; i < (shoves || 0); i++) {
|
||||
const step = await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const short = find(/^Call all-in/);
|
||||
if (short) { short.click(); return "done"; }
|
||||
const open = find(/^Raise|^Bet/);
|
||||
if (!open) return null;
|
||||
open.click();
|
||||
return "pad";
|
||||
});
|
||||
if (!step) break;
|
||||
await page.waitForTimeout(120);
|
||||
if (step === "pad") {
|
||||
await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const max = find(/^All-in/);
|
||||
if (max) max.click();
|
||||
const go = find(/^Raise to|^Bet/);
|
||||
if (go) go.click();
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < (shoves ? 0 : 3); i++) {
|
||||
const acted = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
});
|
||||
if (!acted) break;
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
if (board) {
|
||||
await page.evaluate(() => {
|
||||
const s = HoldemState.load();
|
||||
s.street = "river";
|
||||
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
||||
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
||||
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
||||
});
|
||||
HoldemState.save(s);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
// The raise pad, which is part of the bar rather than an overlay and so
|
||||
// was never once measured by any of this. It is the tallest thing the
|
||||
// bar ever holds and the only place in the tool a dealer types an
|
||||
// amount, and it had a 36px-tall field on every phone in the list: the
|
||||
// `hover: none` rule that sizes fields for a thumb names the settings
|
||||
// panels and the seat form, and the one field on the thumb surface was
|
||||
// not in the list. Nothing here could see that, because nothing here
|
||||
// ever opened it.
|
||||
if (await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})) {
|
||||
await page.waitForTimeout(200);
|
||||
checks++;
|
||||
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
||||
occlusionSelector: "#actionBar button, #actionBar input",
|
||||
}));
|
||||
if (!pad.clean) {
|
||||
failures.push({
|
||||
where: `raise pad · ${label} ${width}x${height}`,
|
||||
offenders: pad.failed.flatMap(f => f.offenders),
|
||||
});
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const cancel = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Cancel/.test(b.textContent.trim()));
|
||||
if (cancel) cancel.click();
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// The overlays don't change with the table's shape, so they are only
|
||||
// opened over the half-full one — twice the viewports is enough time
|
||||
// spent already.
|
||||
if (shape !== TABLES[0][0]) continue;
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Whether two seats can end up wanting the same pixels.
|
||||
//
|
||||
// The felt used to decide this from one number: the straight-line distance between the
|
||||
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
|
||||
// halves of that were wrong, and the running page showed it. Measured across nine
|
||||
// viewports and six table sizes:
|
||||
//
|
||||
// seventeen of fifty-four tables had one seat's name or stack painted under another
|
||||
// ten of them had the collision between two seats that are not neighbours
|
||||
//
|
||||
// The second number is the interesting one. On a phone held sideways the felt is 528px
|
||||
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
|
||||
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
|
||||
// every neighbouring pair was fine and the check said the table was clean.
|
||||
//
|
||||
// The first is what a distance test can't fix even looking at every pair: distance has
|
||||
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
|
||||
// is generous side by side and nowhere near enough on the diagonal.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
describe("two boxes on a ring", () => {
|
||||
// A phone held sideways, once the action bar has taken its share: a felt far wider
|
||||
// than it is tall, on which the ring is very nearly a straight line.
|
||||
const felt = { width: 400, height: 70 };
|
||||
const flat = { rx: 44, ry: 42 };
|
||||
|
||||
test("a pair that is not neighbours still counts", () => {
|
||||
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
|
||||
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
|
||||
// you here is the one across the table, because the rim doubles back at the ends
|
||||
// of a shape this flat and a long walk apart is no distance at all.
|
||||
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
|
||||
|
||||
const neighbours = (count, w, h) => {
|
||||
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
|
||||
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
|
||||
return points.some((point, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
|
||||
});
|
||||
};
|
||||
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
|
||||
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
|
||||
});
|
||||
|
||||
test("and the answer is the box, not a radius", () => {
|
||||
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
|
||||
// between them is 69px, so the test this replaces — "are these two further apart
|
||||
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
|
||||
const boxes = { width: 200, height: 100 };
|
||||
const twoSeats = (dx, dy) => {
|
||||
const original = Render.ringPositions;
|
||||
Render.ringPositions = () => [
|
||||
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
|
||||
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
|
||||
];
|
||||
try {
|
||||
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
|
||||
} finally {
|
||||
Render.ringPositions = original;
|
||||
}
|
||||
};
|
||||
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
|
||||
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
|
||||
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
|
||||
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
|
||||
});
|
||||
|
||||
test("with daylight, so rounded corners don't read as touching", () => {
|
||||
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at can't collide with itself", () => {
|
||||
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
|
||||
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths the felt may choose from", () => {
|
||||
test("run widest first, because the widest that fits is the one wanted", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
|
||||
});
|
||||
|
||||
test("and span the whole declared range", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
});
|
||||
|
||||
test("the floor is still a name, not a smudge", () => {
|
||||
// At 44px the tight layout shows about five characters and an ellipsis. Below
|
||||
// that a puck stops telling the dealer who is sitting there, which is the one
|
||||
// thing on the felt it exists to say.
|
||||
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
|
||||
});
|
||||
|
||||
test("and there are few enough of them to measure after every tap", () => {
|
||||
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
|
||||
assert.ok(Render.seatWidthCandidates().length <= 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the stylesheet has to keep holding up", () => {
|
||||
test("the puck's width comes from the property, not from a breakpoint", () => {
|
||||
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
|
||||
// at a number that depends on how many people are sitting down, which no media
|
||||
// query can see. The same 390px phone has room for 90px at four seats and 61 at
|
||||
// nine.
|
||||
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
|
||||
.map(m => m[3]);
|
||||
const hardWidths = seatRules
|
||||
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
|
||||
.filter(decl => !decl.includes("var(--seatPuckW"));
|
||||
assert.deepEqual(hardWidths, []);
|
||||
});
|
||||
|
||||
test("the puck measures what it says it measures", () => {
|
||||
// As content-box a 64px puck came out 74 across once its padding and border were
|
||||
// counted, and the search compares that number against distances on the felt.
|
||||
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
|
||||
assert.ok(rule, ".seat rule not found");
|
||||
assert.match(rule[0], /box-sizing:\s*border-box/);
|
||||
});
|
||||
|
||||
test("and a blind badge never reaches sideways off its own puck", () => {
|
||||
// This is what makes the footprint the puck and nothing more. The badge's
|
||||
// remaining overhang is vertical, and it points away from the table at both ends
|
||||
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
|
||||
// other across the felt wear their badges back to back.
|
||||
//
|
||||
// It was not always so: at `left: -6px` the small blind hung off the puck's left
|
||||
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
|
||||
// "SB" was painted across the next player's $1,137.
|
||||
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
|
||||
assert.equal(badges.length, 3, "expected all three blind badges");
|
||||
badges.forEach(([, which, body]) => {
|
||||
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
|
||||
assert.ok(left, `.${which}Badge should place itself horizontally`);
|
||||
assert.ok(Number(left[1]) >= 0,
|
||||
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and all three sit in the same place, so there is one spot to look at", () => {
|
||||
// Two badges in two positions means the dealer's eye has to find which of them
|
||||
// this seat is using before it can read which blind it is, forty times a night.
|
||||
// A seat only ever wears one: the blinds are different players by construction,
|
||||
// and the straddle chain starts left of the big blind and stops short of the
|
||||
// small one.
|
||||
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
|
||||
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
|
||||
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the blind badge counts", () => {
|
||||
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
||||
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
||||
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
||||
// them back to back and it costs nothing. That much is true.
|
||||
//
|
||||
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
||||
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
||||
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
||||
// pointing away from the table on one of them points at the corner of the other.
|
||||
// Measured on the running page, three tables in the sweep had a player's stack
|
||||
// painted under the next player's SB or BB — and the badge outranks everything else
|
||||
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
||||
test("a seat needs room for the badge it might be wearing", () => {
|
||||
const puck = { width: 64, height: 40 };
|
||||
assert.deepEqual(Render.seatFootprint(puck),
|
||||
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
||||
});
|
||||
|
||||
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
||||
// It sits inside the puck's own width — see "all three sit in the same place"
|
||||
// above, which holds the stylesheet to it.
|
||||
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
||||
});
|
||||
|
||||
test("the number here is the number the stylesheet draws", () => {
|
||||
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
||||
// puck by it, render.js charges every chair in the ring for it. They are wired
|
||||
// together by nothing but this test. Drift either way is silent — too small and
|
||||
// badges land on neighbours again, too large and every puck on a nine-handed
|
||||
// phone gives up a size step for clearance nothing needs.
|
||||
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the badge should hang off both arcs");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
||||
"the same distance at both ends, or one arc is charged for the other's badge");
|
||||
});
|
||||
|
||||
test("and it is counted once, not once per seat", () => {
|
||||
// seatsCollide is given one box and compares centres against it, so the
|
||||
// separation it asks for is one puck plus one badge. That is exactly right for
|
||||
// two seats on the same arc each wearing one, and generous for the far commoner
|
||||
// case where only one of the two is in the blinds at all. Counting it on both
|
||||
// sides is what made a landscape table unsatisfiable the first time this was
|
||||
// tried, back when that felt was 87px tall.
|
||||
const puck = { width: 64, height: 40 };
|
||||
const grew = Render.seatFootprint(puck).height - puck.height;
|
||||
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("nothing here throws", () => {
|
||||
assert.equal(Render.measuredSeatBox(), null);
|
||||
assert.equal(Render.seatFootprint(null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// What the ring is allowed to be sized from.
|
||||
//
|
||||
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
|
||||
// shows what a player has put in. Both used to hold their space anyway, and the pill,
|
||||
// which has a background and a pill radius and side padding, painted an 8x11px green
|
||||
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
|
||||
// table, a coloured mark on almost every seat standing for nothing.
|
||||
//
|
||||
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
|
||||
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
|
||||
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
|
||||
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
|
||||
// the seats overlapped by 1556px² and now overlap by 87px².
|
||||
//
|
||||
// The collapse is only safe if the layout re-measures when one of those rows fills,
|
||||
// because resolveSeatLayout caches its ten forced layouts against this signature. That
|
||||
// is what is checked here: the cache key has to move when the puck's shape can, and
|
||||
// stay still when it cannot — a signature that changed on every action would throw the
|
||||
// cache away on every tap, which is the cost it exists to avoid.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
|
||||
const signatureOf = state => {
|
||||
const before = Render.state;
|
||||
Render.state = state;
|
||||
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
|
||||
};
|
||||
|
||||
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
||||
|
||||
describe("the signature the ring layout is cached against", () => {
|
||||
test("moves when the first chips go in", () => {
|
||||
// Before the blinds every pill is empty and the pucks are short; after them two
|
||||
// seats are ~15px taller. A ring still sized from the first measurement is sized
|
||||
// for a puck that no longer exists.
|
||||
const state = table();
|
||||
const empty = signatureOf(state);
|
||||
state.seats[1].committed = 10;
|
||||
assert.notEqual(signatureOf(state), empty,
|
||||
"the ring would still be sized for pucks with no amount on them");
|
||||
});
|
||||
|
||||
test("and when a hole card is entered", () => {
|
||||
const state = table();
|
||||
const none = signatureOf(state);
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
assert.notEqual(signatureOf(state), none);
|
||||
});
|
||||
|
||||
test("but not again for the second player to put chips in", () => {
|
||||
// The whole point of a boolean over the table. The ring is sized from the
|
||||
// tallest puck on it, so once anybody has an amount the answer cannot change —
|
||||
// and re-measuring here would mean ten forced layouts on every single action.
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const first = signatureOf(state);
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(signatureOf(state), first);
|
||||
state.seats[3].committed = 250;
|
||||
assert.equal(signatureOf(state), first);
|
||||
});
|
||||
|
||||
test("nor when an amount merely changes size", () => {
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const small = signatureOf(state);
|
||||
state.seats[1].committed = 999999;
|
||||
assert.equal(signatureOf(state), small,
|
||||
"a wider number is the amount fitter's problem, not the ring's");
|
||||
});
|
||||
|
||||
test("and it still moves for the things it always moved for", () => {
|
||||
// Renaming can wrap a name onto a second line, and the button's chip floats
|
||||
// into the name's first line, so both change how tall a puck measures.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.seats[0].name = "Bartholomew Fairweather";
|
||||
const renamed = signatureOf(state);
|
||||
assert.notEqual(renamed, base);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.notEqual(signatureOf(state), renamed);
|
||||
});
|
||||
|
||||
test("and two tables that differ only in where a name is split stay different", () => {
|
||||
// The names are joined on a control character no name can contain. Joined on
|
||||
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
|
||||
// tables would be laid out with the other's measurements — different names, so
|
||||
// different wrapping, so different puck heights.
|
||||
//
|
||||
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
|
||||
// never collide: the capital letters land in different places, so it passed with
|
||||
// the separator taken out and proved nothing at all. Lower case is what makes it
|
||||
// a real test.
|
||||
const a = table();
|
||||
const b = table();
|
||||
a.seats[0].name = "ann"; a.seats[1].name = "abel";
|
||||
b.seats[0].name = "anna"; b.seats[1].name = "bel";
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table nobody has sat down at", () => {
|
||||
test("has no amounts and no cards on it", () => {
|
||||
// The empty-table case is the one the collapse is most visible on, and it is
|
||||
// also the one where a stray default could quietly keep both rows open forever.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
assert.match(signatureOf(state), /\|$/,
|
||||
"an untouched table should carry neither flag");
|
||||
});
|
||||
|
||||
test("but a table with an ante on it does have an amount", () => {
|
||||
// computeContribution answers for a seat that has never been given a stack —
|
||||
// the table's base pot stands in for it — so a table dealt with "everyone antes
|
||||
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.basePotAmount = 5;
|
||||
state.seats.forEach(p => { p.inHand = true; });
|
||||
assert.match(signatureOf(state), /a$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// How wide a seat puck is allowed to get, and on which screens.
|
||||
//
|
||||
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
|
||||
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
|
||||
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
|
||||
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
|
||||
//
|
||||
// Two things stop the fix from costing more than it buys, and both are checked here.
|
||||
//
|
||||
// Every candidate width is a forced layout, and raising the ceiling for everybody took
|
||||
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
|
||||
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
|
||||
// is half the width of the table: the first version of this let phones through, and
|
||||
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
|
||||
// stack of cards rather than a table with people around it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
|
||||
// viewports the sweep visits.
|
||||
const RINGS = {
|
||||
"Galaxy S8": { width: 233, height: 282 },
|
||||
"iPhone 14": { width: 263, height: 470 },
|
||||
"Pro Max": { width: 303, height: 535 },
|
||||
"phone sideways": { width: 565, height: 257 },
|
||||
iPad: { width: 689, height: 689 },
|
||||
desktop: { width: 857, height: 617 },
|
||||
"desktop 1440": { width: 992, height: 714 },
|
||||
};
|
||||
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
|
||||
const LARGE = ["iPad", "desktop", "desktop 1440"];
|
||||
|
||||
describe("which felts may have wider pucks at all", () => {
|
||||
test("no phone does, held either way up", () => {
|
||||
// The guarantee the phones are owed: they measure the widths they always
|
||||
// measured and settle on the puck they always settled on.
|
||||
SMALL.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
|
||||
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
|
||||
[2, 4, 6, 9, 12].forEach(count => assert.equal(
|
||||
Render.roomForWiderPucks(count, RINGS[name]), false,
|
||||
`${name} ${count}-handed`));
|
||||
});
|
||||
});
|
||||
|
||||
test("and every tablet and desktop does", () => {
|
||||
LARGE.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
|
||||
});
|
||||
});
|
||||
|
||||
test("with a comfortable margin either side of the line", () => {
|
||||
// The rule is a fifth of the shorter span, and the point of five is that it is
|
||||
// not near either group. If a change to the felt ever moves these together,
|
||||
// this says so before the phones quietly start paying for it.
|
||||
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
assert.ok(smallestLarge > biggestSmall * 1.8,
|
||||
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
|
||||
// Every divisor that keeps the phones out is at least the widest phone span over
|
||||
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
|
||||
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
|
||||
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
|
||||
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test("and a felt with no size at all is not one of them", () => {
|
||||
assert.equal(Render.roomForWiderPucks(9, null), false);
|
||||
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths a felt actually measures", () => {
|
||||
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
|
||||
|
||||
test("a phone measures exactly the ten it measured before", () => {
|
||||
const expected = [];
|
||||
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
|
||||
expected.push(w);
|
||||
}
|
||||
expected.push(Render.SEAT_WIDTH_MIN);
|
||||
SMALL.forEach(name => [6, 9, 12].forEach(count =>
|
||||
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
|
||||
});
|
||||
|
||||
test("a desktop measures a handful more, and no more than a handful", () => {
|
||||
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
|
||||
assert.ok(extra > 0, "the wider widths never got offered");
|
||||
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
|
||||
});
|
||||
|
||||
test("never wider than the felt's own ceiling", () => {
|
||||
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
|
||||
const widest = Math.max(...widthsFor(name, count));
|
||||
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
|
||||
`${name} ${count}-handed offered ${widest}px`);
|
||||
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
|
||||
}));
|
||||
});
|
||||
|
||||
test("always widest first, always down to the floor", () => {
|
||||
// The search takes the first width that fits, so the order is the whole
|
||||
// algorithm — and the floor has to be on the list or a crowded table has
|
||||
// nothing left to fall back to.
|
||||
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
|
||||
const widths = widthsFor(name, count);
|
||||
widths.forEach((w, i) => {
|
||||
if (i === 0) return;
|
||||
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
|
||||
});
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
}));
|
||||
});
|
||||
|
||||
test("and a crowded table on a big felt is told no like any other", () => {
|
||||
// Room on the felt is necessary, not sufficient: the seats still have to clear
|
||||
// each other. Enough of them on any ring and the wider widths stop being
|
||||
// offered, which is the arithmetic doing the refusing rather than the
|
||||
// measurement having to.
|
||||
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
|
||||
assert.deepEqual(crowded, [false, false, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// How wide the ring of seats is allowed to be.
|
||||
//
|
||||
// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked
|
||||
// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141
|
||||
// one on a phone held sideways. A fixed pair can only be right for one ratio of puck
|
||||
// to felt, so measured on the running page:
|
||||
//
|
||||
// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px
|
||||
// 844x390, six-handed all six, worst 20px
|
||||
// 430x932, six-handed two, worst 12px
|
||||
//
|
||||
// Seats floating off the felt onto the page read as the table being too small for the
|
||||
// game rather than as players at a rail, and it was a different amount of wrong on
|
||||
// every screen — which is what made it look like a mistake instead of a choice.
|
||||
//
|
||||
// The radius is derived from what has to fit now. Whether it *actually* fits is a
|
||||
// question about a laid-out browser, and it is checked there: uiAudit's "seats and
|
||||
// chips stay on the table" runs at every viewport in the sweep. What is checked here
|
||||
// is the contract that function has to honour, including the two ways it is allowed
|
||||
// to give up.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("it falls back to the ring the tool has always used", () => {
|
||||
// Every call from this suite has no DOM, and so does the very first render,
|
||||
// before the felt has been laid out. Neither should throw or return nonsense.
|
||||
assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY });
|
||||
});
|
||||
|
||||
test("so seatPosition still answers", () => {
|
||||
const pos = Render.seatPosition(0, 9, 1);
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top));
|
||||
});
|
||||
|
||||
test("and nothing is measured for the puck height either", () => {
|
||||
assert.equal(Render.tooTallForTheTable(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ceiling and the floor", () => {
|
||||
test("the old ring is the widest it can ever be", () => {
|
||||
// Nothing gets looser than it was; this can only pull seats in.
|
||||
assert.ok(Render.RING_MAX_RX <= 44);
|
||||
assert.ok(Render.RING_MAX_RY <= 42);
|
||||
});
|
||||
|
||||
test("and it never collapses the ring into the pot", () => {
|
||||
// A puck wider than the table it sits on has no honest answer. Overhanging is
|
||||
// the lesser evil — seats stacked on the pot would be unreadable, and the
|
||||
// density rule shrinks the pucks before it ever gets this far.
|
||||
assert.ok(Render.RING_MIN_R > 0);
|
||||
assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY);
|
||||
});
|
||||
|
||||
test("with a margin that keeps the outermost seat off the rail", () => {
|
||||
assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ring the radii describe", () => {
|
||||
const spread = (radii, count = 9, aspect = 1.4) =>
|
||||
Render.ringPositions(count, radii.rx, radii.ry, aspect);
|
||||
|
||||
test("a smaller radius puts every seat nearer the middle", () => {
|
||||
const wide = spread({ rx: 44, ry: 42 });
|
||||
const tight = spread({ rx: 30, ry: 28 });
|
||||
wide.forEach((seat, i) => {
|
||||
const wideOut = Math.hypot(seat.left - 50, seat.top - 50);
|
||||
const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50);
|
||||
assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the seats stay evenly spread however far in they are", () => {
|
||||
// Pulling the ring in must not bunch them: the spacing rule is arc length, and
|
||||
// shrinking an ellipse uniformly keeps every gap in proportion.
|
||||
const gaps = ring => ring.map((p, i) => {
|
||||
const next = ring[(i + 1) % ring.length];
|
||||
return Math.hypot(p.left - next.left, p.top - next.top);
|
||||
});
|
||||
const tight = gaps(spread({ rx: 30, ry: 28 }));
|
||||
const smallest = Math.min(...tight), largest = Math.max(...tight);
|
||||
assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bets follow the seats", () => {
|
||||
test("a chip sits on its own player's spoke, proportionally in", () => {
|
||||
// The chip is a fraction of the seat's reach rather than of the felt, so when
|
||||
// the ring pulls in the chips come with it instead of being left by the rail.
|
||||
const radii = { rx: 30, ry: 28 };
|
||||
const seat = Render.seatPosition(2, 8, 1.4, radii);
|
||||
const bet = Render.betPosition(2, 8, 1.4, radii);
|
||||
const seatOut = Math.hypot(seat.left - 50, seat.top - 50);
|
||||
const betOut = Math.hypot(bet.left - 50, bet.top - 50);
|
||||
assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is");
|
||||
assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt");
|
||||
});
|
||||
|
||||
test("and it is the same spoke, not just the same distance", () => {
|
||||
const radii = { rx: 30, ry: 28 };
|
||||
const seat = Render.seatPosition(5, 9, 1.2, radii);
|
||||
const bet = Render.betPosition(5, 9, 1.2, radii);
|
||||
const angleOf = p => Math.atan2(p.top - 50, p.left - 50);
|
||||
assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Who the tool asks to turn over, and in what order.
|
||||
//
|
||||
// The board half of the card feature always worked: every street, a sheet asks which
|
||||
// cards came off the deck. The hands half never did. A dealer entered five community
|
||||
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
|
||||
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
|
||||
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
|
||||
// puck, find the card slots in the seat editor, and repeat it for each player, with the
|
||||
// pot sitting on the table.
|
||||
//
|
||||
// So the showdown button now asks, the same way the street button does. This file is
|
||||
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
|
||||
// question of who is on the list, and in which order, is state and lives here.
|
||||
//
|
||||
// Three rules hold it together:
|
||||
//
|
||||
// Only when asked for. The setting is off by default and the list is empty when it
|
||||
// is off, so a table that never wanted this never sees a sheet.
|
||||
//
|
||||
// Only what isn't known. A hand already entered is an answer already given, and
|
||||
// asking again offers to overwrite it.
|
||||
//
|
||||
// In the order the cards actually come over — the showdown order the action bar
|
||||
// already announces, then round the table from there. A dealer looking at the seat
|
||||
// the bar just named should not be asked about the one behind them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A hand played to the river with everyone still in, which is the only shape that
|
||||
// has a showdown to ask about.
|
||||
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
|
||||
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "river";
|
||||
state.board = ["2C", "7D", "9S", "JH", "3S"];
|
||||
state.trackBoard = tracking;
|
||||
state.seats.forEach(player => { player.hasActed = true; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const queue = state => Render.showdownCardQueue(state);
|
||||
|
||||
describe("only when the dealer asked to be asked", () => {
|
||||
test("the setting off means nobody is asked anything", () => {
|
||||
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
|
||||
});
|
||||
|
||||
test("the setting on asks everyone still in", () => {
|
||||
assert.strictEqual(queue(atShowdown()).length, 4);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at is not a showdown", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.inHand = false; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a hand won without a showdown", () => {
|
||||
// Everyone else folded, so there is one hand left and it never comes over. The
|
||||
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
|
||||
// over a hand nobody paid to see is asking for a card the tool will never use.
|
||||
test("one player left is asked for nothing", () => {
|
||||
const state = atShowdown();
|
||||
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
|
||||
test("two left is a showdown, however it was arrived at", () => {
|
||||
const state = atShowdown();
|
||||
[2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.strictEqual(queue(state).length, 2);
|
||||
});
|
||||
|
||||
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
|
||||
// the board runs out, and the hands are read exactly as they would have been.
|
||||
test("all-in with the betting finished is still asked about", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.stack = 0; });
|
||||
assert.ok(!Betting.canStillBet(state));
|
||||
assert.strictEqual(queue(state).length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only the hands it doesn't already have", () => {
|
||||
test("a hand already entered is not asked for again", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", "AH"];
|
||||
assert.ok(!queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("half a hand entered is still asked for", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", ""];
|
||||
assert.ok(queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("every hand entered means the sheet never opens", () => {
|
||||
const state = atShowdown();
|
||||
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
|
||||
state.seats.forEach((player, i) => { player.cards = hands[i]; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the order the cards come over", () => {
|
||||
// The bar already says "Mike shows first — last to bet or raise" out loud. The
|
||||
// sheet asking about somebody else first would be the tool contradicting itself
|
||||
// in the same breath.
|
||||
test("it starts with whoever the bar says shows first", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.strictEqual(Betting.showdownOrder(state).index, 2);
|
||||
assert.strictEqual(queue(state)[0], 2);
|
||||
});
|
||||
|
||||
test("and then goes round the table from there", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
|
||||
});
|
||||
|
||||
test("nobody checked the river, so it starts left of the button", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
const first = Betting.showdownOrder(state);
|
||||
assert.strictEqual(first.reason, "leftOfButton");
|
||||
assert.strictEqual(queue(state)[0], first.index);
|
||||
});
|
||||
|
||||
test("a player already entered drops out of the order without moving the rest", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
state.seats[3].cards = ["KS", "KH"];
|
||||
assert.deepStrictEqual(queue(state), [2, 0, 1]);
|
||||
});
|
||||
|
||||
test("everyone still in is asked exactly once", () => {
|
||||
const state = atShowdown({ seats: 9 });
|
||||
const asked = queue(state);
|
||||
assert.strictEqual(new Set(asked).size, asked.length);
|
||||
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// What the sidebar is a list of.
|
||||
//
|
||||
// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and
|
||||
// "Session" was a container word rather than a name. It held the standings, a waiting
|
||||
// list, the hand log and the save/load/reset buttons — a topic, a task, a log and three
|
||||
// utilities under one label, which is the "don't mix apples and oranges" failure: a
|
||||
// navigation layer with more than one logic in it. Measured at 1206px and 103 visible
|
||||
// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px
|
||||
// down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
// scrolling past the money and the log.
|
||||
//
|
||||
// This is a markup test because that is where the fault lived. Nothing was broken —
|
||||
// every control worked and every id resolved — so nothing failed. What was wrong was
|
||||
// which panel a thing was in and what that panel was called.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// A sidebar panel never contains another one — Table Setup's sub-panels carry a
|
||||
// different class — so splitting on the opening tag is enough to say which panel any
|
||||
// piece of markup is in, without parsing the document.
|
||||
const panels = () => PAGE
|
||||
.split(/<details class="sidebarPanel"/)
|
||||
.slice(1)
|
||||
.map(chunk => {
|
||||
const name = chunk.match(/<summary>\s*(?:<h2>)?([^<]+)/);
|
||||
return { name: name ? name[1].trim() : "(no summary)", markup: chunk };
|
||||
});
|
||||
|
||||
const panelHolding = id => {
|
||||
const found = panels().filter(p => p.markup.includes(`id="${id}"`));
|
||||
assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`);
|
||||
return found[0].name;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sidebar's names", () => {
|
||||
test("say what is inside them", () => {
|
||||
// Every one of these is a thing a dealer goes looking for. None of them is a
|
||||
// word that could hold anything — which is the test "Session" fails.
|
||||
assert.deepEqual(panels().map(p => p.name), [
|
||||
"Table Setup",
|
||||
"Standings",
|
||||
"Waiting list",
|
||||
"Hands",
|
||||
"Chip Legend",
|
||||
"Keyboard",
|
||||
"Table Notes",
|
||||
"Save & reset",
|
||||
]);
|
||||
});
|
||||
|
||||
test("and each one is a heading, not only a button", () => {
|
||||
// A <summary> is announced as a button. The sidebar's names are the whole of its
|
||||
// navigation, so without a heading inside, none of them is reachable by anyone
|
||||
// moving through the page by heading — and splitting Session removed the four
|
||||
// subheads that used to be the only headings down here.
|
||||
panels().forEach(p => assert.match(p.markup, /<summary><h2>/,
|
||||
`the ${p.name} panel's name is not a heading`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where things live", () => {
|
||||
test("the money is together, and the warning about losing it is with it", () => {
|
||||
assert.equal(panelHolding("standingsList"), "Standings");
|
||||
assert.equal(panelHolding("settleUp"), "Standings");
|
||||
assert.equal(panelHolding("bankReconcile"), "Standings");
|
||||
// renderStorageWarning() says this "sits at the top of the panel a dealer opens
|
||||
// to check the money". That panel is now called Standings.
|
||||
assert.equal(panelHolding("storageWarning"), "Standings");
|
||||
});
|
||||
|
||||
test("the queue for a seat is its own panel", () => {
|
||||
assert.equal(panelHolding("waitingList"), "Waiting list");
|
||||
assert.equal(panelHolding("waitingListForm"), "Waiting list");
|
||||
});
|
||||
|
||||
test("the log and the two ways out of it are together", () => {
|
||||
assert.equal(panelHolding("handHistoryList"), "Hands");
|
||||
assert.equal(panelHolding("handHistoryFilter"), "Hands");
|
||||
assert.equal(panelHolding("exportHistoryBtn"), "Hands");
|
||||
assert.equal(panelHolding("clearHistoryBtn"), "Hands");
|
||||
});
|
||||
|
||||
test("and the file is last, with the danger on the label", () => {
|
||||
assert.equal(panelHolding("saveTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("loadTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("resetTableBtn"), "Save & reset");
|
||||
assert.equal(panels()[panels().length - 1].name, "Save & reset",
|
||||
"the panel that ends the night belongs at the end, away from the working ones");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no panel goes back to being a grab-bag", () => {
|
||||
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
|
||||
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
|
||||
// headings to say what its parts are is a panel holding more than one thing, which
|
||||
// is what Session was and how it got that way.
|
||||
test("at most one subject needs a heading inside a panel", () => {
|
||||
panels().forEach(p => {
|
||||
const subheads = [...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => m[1].trim());
|
||||
assert.ok(subheads.length <= 1,
|
||||
`${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` +
|
||||
`if a panel needs headings to list its parts, its name is not doing the work`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the ones that remain are a detail of their panel, not a second subject", () => {
|
||||
// Settle up is the standings once the night ends; Stacks in chips is the chip
|
||||
// legend counted out. Both are the same subject at a different moment, which is
|
||||
// the case a subhead is for.
|
||||
const kept = panels().flatMap(p =>
|
||||
[...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => `${p.name}: ${m[1].trim()}`));
|
||||
assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// The toast that outlived the thing it was talking about.
|
||||
//
|
||||
// Toasts are news about the last action, and they carry an Undo. Take another action
|
||||
// and both of those go wrong at once: the sentence describes a step that is now two
|
||||
// steps back, and the Undo behind it restores a snapshot from before everything since.
|
||||
// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
|
||||
// players call is an offer to move the button, wired to a button that deletes the hand.
|
||||
//
|
||||
// It is also simply in the way. The toast is a fixed strip near the bottom of the
|
||||
// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
|
||||
// felt and the action bar, so a stale one covers seats and controls for eight seconds.
|
||||
//
|
||||
// So the bar drops it as it takes the next action. These tests hold that funnel shut:
|
||||
// every action a dealer can take from the bar goes through `commit`, and `commit`
|
||||
// clears the toast before it does anything else.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Betting, makeTable } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
|
||||
// A bar sat at a real table, with the toast replaced by something that keeps a log
|
||||
// and the re-render stubbed out — none of these rules need the DOM back.
|
||||
function onTable() {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||
const log = [];
|
||||
globalThis.Toast = {
|
||||
onScreen: null,
|
||||
show(message) { this.onScreen = message; log.push(`show ${message}`); },
|
||||
dismiss() { this.onScreen = null; log.push("dismiss"); },
|
||||
};
|
||||
ActionBar.state = state;
|
||||
ActionBar.render = () => {};
|
||||
ActionBar.hooks = {
|
||||
beginAction: label => log.push(`begin ${label}`),
|
||||
save: () => log.push("save"),
|
||||
rerender: () => log.push("rerender"),
|
||||
};
|
||||
return { bar: ActionBar, state, log };
|
||||
}
|
||||
|
||||
test.afterEach(() => { delete globalThis.Toast; });
|
||||
|
||||
describe("a toast does not outlive its own news", () => {
|
||||
test("the next action from the bar clears it", () => {
|
||||
const { bar, state, log } = onTable();
|
||||
Toast.show("Next hand — button on seat 2.");
|
||||
|
||||
bar.commit("dealing the hand", () => Betting.beginHand(state));
|
||||
|
||||
assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
|
||||
assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
|
||||
`the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
|
||||
});
|
||||
|
||||
test("a toast raised by the action itself survives", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("something that talks back", () => Toast.show("Bob is all in."));
|
||||
|
||||
assert.equal(Toast.onScreen, "Bob is all in.",
|
||||
"commit clears what was there when it started, not what its own work puts up");
|
||||
assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
|
||||
});
|
||||
|
||||
test("with no toast on screen it is still just one dismiss", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("a fold", () => {});
|
||||
assert.equal(log.filter(l => l === "dismiss").length, 1);
|
||||
});
|
||||
|
||||
test("a page with no Toast at all does not throw", () => {
|
||||
const { bar, state } = onTable();
|
||||
delete globalThis.Toast;
|
||||
assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("every bar action really does go through that funnel", () => {
|
||||
// The dismiss is worth nothing if some button reaches past `commit` to mutate the
|
||||
// table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
|
||||
// straight from a click handler would be one that skips the clean-up — and the
|
||||
// undo history with it, which is the older reason this funnel exists.
|
||||
test("no click handler calls the betting engine directly", () => {
|
||||
const strays = barSource
|
||||
.split("\n")
|
||||
.map((line, i) => [i + 1, line])
|
||||
.filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
|
||||
.filter(([, line]) => !/this\.commit\(/.test(line))
|
||||
.map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
|
||||
|
||||
assert.deepEqual(strays, [], "these change the table without going through commit");
|
||||
});
|
||||
|
||||
test("commit is what clears the toast, not each button", () => {
|
||||
const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
|
||||
// Two: this one, and the raise pad — which opens without committing anything,
|
||||
// and needs the strip clear because the pad makes the bar tall enough to push
|
||||
// a toast up over the seats.
|
||||
assert.equal(dismissals, 2,
|
||||
"a third dismissal means a button is doing by hand what commit already does");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
|
||||
// one.
|
||||
//
|
||||
// The layout audit is what proved this was broken and what proves it is fixed — it
|
||||
// measures the drawn page, which is the only place a layout is really true. What is
|
||||
// here is the part underneath that can be wrong on its own: which shape the bar is in,
|
||||
// how much of the bottom that leaves spoken for, how a name is shortened when it will
|
||||
// not fit, and how the ring is pulled back in when a puck lands past the rail.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("which shape the bar is in", () => {
|
||||
// The layout is chosen by a media query — orientation, height and width together.
|
||||
// The one thing that must not happen is a second copy of that query in JavaScript,
|
||||
// because the first thing anybody changes is the breakpoint, in one of the two
|
||||
// places. So the stylesheet declares which layout it picked and actionBar.js reads
|
||||
// the answer back.
|
||||
const withComputed = (value, run) => {
|
||||
const host = { id: "actionBar" };
|
||||
global.getComputedStyle = el => ({
|
||||
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
|
||||
});
|
||||
try { return run(host); } finally { delete global.getComputedStyle; }
|
||||
};
|
||||
|
||||
test("the stylesheet says, and this reads", () => {
|
||||
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
|
||||
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("anything it does not recognise is the bar it has always been", () => {
|
||||
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
|
||||
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("and with no browser at all, likewise", () => {
|
||||
assert.equal(ActionBar.isRail(null), false);
|
||||
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
|
||||
"no getComputedStyle to ask — this runs in the unit suite too");
|
||||
});
|
||||
|
||||
test("the stylesheet declares both halves of it", () => {
|
||||
// If either of these goes, isRail() quietly answers "bar" forever and the felt
|
||||
// is measured against a bar that is not underneath it.
|
||||
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
|
||||
"the default, on the base rule");
|
||||
assert.match(CSS, /--actionBarLayout:\s*rail/,
|
||||
"and the rail, inside the landscape query");
|
||||
});
|
||||
|
||||
test("the rail is scoped to a phone, not to any short window", () => {
|
||||
// Above 900px the sidebar is already a right-hand column and a rail would land
|
||||
// on top of it.
|
||||
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
|
||||
assert.ok(query, "the rail lives in a landscape media query");
|
||||
assert.match(query[0], /max-width:\s*900px/);
|
||||
assert.match(query[0], /max-height:\s*560px/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the bar lines up with the table", () => {
|
||||
// On a desktop the bar was centred on the window and the table on its own column,
|
||||
// and the sidebar takes 320px out of the right of that — so the two things a dealer
|
||||
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
|
||||
// between, because the offset is half the sidebar and the sidebar is a constant.
|
||||
//
|
||||
// The rule that fixes it is four numbers that have to keep agreeing with four other
|
||||
// numbers, none of them anywhere near each other in the file. That is what this
|
||||
// block is for: the arithmetic is checked on the running page by measurement (bar
|
||||
// centre against table-column centre, which came out 0px off at every width), and
|
||||
// the numbers it depends on are checked here so they cannot drift apart afterwards.
|
||||
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
|
||||
|
||||
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
|
||||
// Below 901px the sidebar is not a column at all — it is stacked under the table
|
||||
// — so there is nothing to lean away from and the bar is centred as it was.
|
||||
assert.ok(rule, "no min-width: 901px rule for #actionBar");
|
||||
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"the one-column layout should start exactly where this rule stops");
|
||||
});
|
||||
|
||||
test("it leans away from the sidebar by the sidebar's own width", () => {
|
||||
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
|
||||
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
|
||||
});
|
||||
|
||||
test("and the sidebar's width is one number, not two", () => {
|
||||
// The grid draws the column and the bar leans away from it. Written out twice,
|
||||
// the day somebody widens the sidebar is the day the bar goes crooked and no
|
||||
// test says so.
|
||||
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
|
||||
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
|
||||
"the grid should draw the column from the token");
|
||||
});
|
||||
|
||||
test("no 100vw anywhere in it", () => {
|
||||
// The bar's own centring comment warns about this: on a page with a scrollbar
|
||||
// 100vw is wider than the viewport, and the overflow drags the table sideways.
|
||||
// The offsets are exact without it — see the derivation in the stylesheet.
|
||||
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the button seat's name", () => {
|
||||
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
|
||||
// from under it with `padding-right` takes the chip's width off *every* line of the
|
||||
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px,
|
||||
// "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56
|
||||
// and broke as "Christop / her". The button moves every hand, so that is a player
|
||||
// whose name reads properly for four hands and comes apart on the fifth.
|
||||
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
|
||||
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
|
||||
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
|
||||
|
||||
test("a ring with room for two lines indents only the line beside the chip", () => {
|
||||
assert.ok(roomy && float, "the roomy regime should use a float");
|
||||
assert.match(float[1], /float:\s*right/);
|
||||
assert.match(roomy[1], /padding-right:\s*0/);
|
||||
});
|
||||
|
||||
test("the float is exactly one line tall", () => {
|
||||
// Tall enough to cover the chip's reach into the name, and no taller: a float
|
||||
// that spills into the second line indents that one too, which puts the whole
|
||||
// problem back.
|
||||
assert.match(float[1], /height:\s*1\.2em/);
|
||||
});
|
||||
|
||||
test("break-word stands down where the float is doing the work", () => {
|
||||
// It is what stops the float working — it breaks a word to fit the short line
|
||||
// rather than letting it move past the float to the long one.
|
||||
assert.match(roomy[1], /overflow-wrap:\s*normal/);
|
||||
});
|
||||
|
||||
test("a crowded ring keeps the padding, because it has only one line", () => {
|
||||
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
|
||||
// nowhere to move to — the only way to keep it off the chip is to shorten it.
|
||||
// Trying the float on both cost a whole sweep to find out: the name dropped onto
|
||||
// a second line that the chip's lower half was still sitting on, and the audit
|
||||
// called it at every viewport in the list.
|
||||
assert.ok(base, "the base rule should still reserve the chip's width");
|
||||
assert.match(base[1], /padding-right:\s*22px/);
|
||||
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
|
||||
});
|
||||
|
||||
test("the float is as wide as the chip it stands in for", () => {
|
||||
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
|
||||
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
|
||||
const floatW = float[1].match(/width:\s*(\d+)px/);
|
||||
assert.ok(chip && inset && floatW);
|
||||
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
|
||||
"the float should cover the chip and the gap it is inset by");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much of the bottom is spoken for", () => {
|
||||
// feltRoomFor asks one question — what is under the felt — and gets two different
|
||||
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
|
||||
// the table hint and the two links, because the bar is off to the side.
|
||||
const fakeSection = (wrapBottom, sectionBottom) => {
|
||||
const wrap = {
|
||||
getBoundingClientRect: () => ({ bottom: wrapBottom }),
|
||||
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
|
||||
};
|
||||
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
test("it is the gap under the felt, links and all", () => {
|
||||
fakeSection(300, 342);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 42);
|
||||
});
|
||||
|
||||
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
|
||||
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
|
||||
// as "how tall is everything under the felt" off the section's height it would
|
||||
// move every time the felt did, and settle nowhere.
|
||||
fakeSection(300, 342);
|
||||
const first = ActionBar.roomBelowFelt();
|
||||
fakeSection(500, 542); // the felt grew by 200; what is under it did not
|
||||
assert.equal(ActionBar.roomBelowFelt(), first);
|
||||
});
|
||||
|
||||
test("nothing under the felt is nothing to reserve", () => {
|
||||
fakeSection(300, 300);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("and a section that measures shorter than its own child is not negative room", () => {
|
||||
fakeSection(300, 288);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("with no table on the page it asks for nothing", () => {
|
||||
global.document = { getElementById: () => null };
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("the felt gets the height the bar used to take", () => {
|
||||
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
|
||||
// and the felt got 85. Beside the table instead, the same window gives the felt
|
||||
// everything but the header and the links.
|
||||
const asABar = ActionBar.feltRoomFor(360, 205, 70);
|
||||
const asARail = ActionBar.feltRoomFor(360, 30, 70);
|
||||
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a name that will not fit", () => {
|
||||
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
|
||||
// a nine-handed phone, and what the ellipsis produced for the four names on the test
|
||||
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
|
||||
// away something the dealer does not need: he is Mike, and the rest of it is how
|
||||
// this table tells him from the other Mike.
|
||||
//
|
||||
// The label element is faked here rather than measured. What is under test is the
|
||||
// ladder — which words go, in what order, and when it stops — and the browser's part
|
||||
// is one boolean per pass.
|
||||
const label = (text, fits) => {
|
||||
const el = {
|
||||
textContent: text,
|
||||
title: "",
|
||||
// The browser's answer, standing in for a real one: this name fits if
|
||||
// `fits` says the version currently written to it does.
|
||||
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
|
||||
get clientWidth() { return 10; },
|
||||
get scrollHeight() { return 10; },
|
||||
get clientHeight() { return 10; },
|
||||
querySelector: () => el,
|
||||
};
|
||||
return el;
|
||||
};
|
||||
|
||||
const layer = (labels) => ({
|
||||
children: labels,
|
||||
childElementCount: labels.length,
|
||||
});
|
||||
|
||||
const seatsNamed = (...names) => {
|
||||
Render.state = { seats: names.map(name => ({ name })) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; });
|
||||
|
||||
test("trailing words go until it fits", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher John");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher John");
|
||||
});
|
||||
|
||||
test("and it will go all the way to the first word", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher");
|
||||
});
|
||||
|
||||
test("a name never ends on a word a name cannot end on", () => {
|
||||
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
|
||||
// shorter name — it is a name that looks cut off, and it reads worse than the
|
||||
// ellipsis this was meant to replace. He is Mike.
|
||||
const el = label("Mike from work", () => false);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike");
|
||||
});
|
||||
|
||||
test("and it takes a run of them in one go", () => {
|
||||
const el = label("Al with the hat", () => false);
|
||||
seatsNamed("Al with the hat");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Al");
|
||||
});
|
||||
|
||||
test("but a joining word inside a name is nobody's business", () => {
|
||||
// The list is closed and small on purpose: it is not trying to understand names,
|
||||
// only the handful of English joining words people label each other with at a
|
||||
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
|
||||
// a name that keeps a word it could have dropped is merely long.
|
||||
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
|
||||
seatsNamed("Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Katarzyna Nowak");
|
||||
});
|
||||
|
||||
test("dropping never goes below the first word", () => {
|
||||
// Every word after the first being a joining word is not a real name, but it is
|
||||
// typeable, and it must not come out empty.
|
||||
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
|
||||
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
|
||||
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
|
||||
});
|
||||
|
||||
test("a single word is left to the ellipsis it always had", () => {
|
||||
// There is no honest way to shorten "Christopher" that a dealer glancing across
|
||||
// a table would read faster than "Christo…".
|
||||
const el = label("Christopher", () => false);
|
||||
seatsNamed("Christopher");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
|
||||
});
|
||||
|
||||
test("a name that fits is not touched", () => {
|
||||
const el = label("Mike from work", () => true);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike from work");
|
||||
});
|
||||
|
||||
test("the whole name is on the tooltip whether it was shortened or not", () => {
|
||||
// A label clamped to two lines was already hiding some of it before any of this.
|
||||
const shortened = label("Mike from work", text => text === "Mike");
|
||||
const untouched = label("Bob", () => true);
|
||||
seatsNamed("Mike from work", "Bob");
|
||||
Render.fitSeatNames(layer([shortened, untouched]));
|
||||
assert.equal(shortened.title, "Mike from work");
|
||||
assert.equal(untouched.title, "Bob");
|
||||
});
|
||||
|
||||
test("every seat is written to before any of them is measured", () => {
|
||||
// Three passes over all the seats, not a loop per seat: each pass writes to
|
||||
// every puck and then reads back from every puck, so the browser lays the felt
|
||||
// out three times whatever the table's size. Interleaved, nine seats would cost
|
||||
// nine layouts, and this runs on every render.
|
||||
const reads = [];
|
||||
const watched = (text, fits) => {
|
||||
const el = label(text, fits);
|
||||
Object.defineProperty(el, "scrollWidth", {
|
||||
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
|
||||
});
|
||||
return el;
|
||||
};
|
||||
const a = watched("Christopher John Robinson", t => t === "Christopher");
|
||||
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
|
||||
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([a, b]));
|
||||
|
||||
// Both names are read at each length before either is written down again.
|
||||
assert.deepEqual(reads.slice(0, 2),
|
||||
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
|
||||
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
|
||||
assert.equal(a.textContent, "Christopher");
|
||||
assert.equal(b.textContent, "Katarzyna");
|
||||
});
|
||||
|
||||
test("an empty chair has no name to shorten", () => {
|
||||
const el = label("", () => false);
|
||||
seatsNamed("");
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
|
||||
});
|
||||
|
||||
test("and with no layer there is nothing to do", () => {
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(null));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("pulling the ring back onto the table", () => {
|
||||
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
|
||||
// the rail, and it is right about that — but it is right about a puck it measured
|
||||
// before the ring moved, before the width search settled, and before a name was
|
||||
// shortened or wrapped to a second line. Each of those changes a puck's height by up
|
||||
// to 15px after the arithmetic is done.
|
||||
//
|
||||
// Measured on the running page, four of the thirteen viewports finished with the top
|
||||
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
|
||||
// the ring is settled the way the audit judges it, by looking at where the pucks
|
||||
// actually landed.
|
||||
//
|
||||
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
|
||||
// percentage into style.left/top, which this reads back to work out where the puck
|
||||
// would be — the same relationship the browser has, without a browser.
|
||||
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
|
||||
|
||||
const seat = (puckW, puckH, box) => ({
|
||||
style: { left: "", top: "" },
|
||||
classList: { toggle() {} },
|
||||
getBoundingClientRect() {
|
||||
// Whatever percentage was last written, resolved against the ring box.
|
||||
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
|
||||
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
|
||||
return {
|
||||
left: cx - puckW / 2, right: cx + puckW / 2,
|
||||
top: cy - puckH / 2, bottom: cy + puckH / 2,
|
||||
width: puckW, height: puckH,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The ring box the percentages resolve against, deliberately the same as the felt so
|
||||
// the arithmetic in the test stays readable.
|
||||
const ringBox = { ...felt };
|
||||
|
||||
const setUp = (count, puckW, puckH) => {
|
||||
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
|
||||
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
|
||||
global.document = { getElementById: id => (id === "pokerTable"
|
||||
? { getBoundingClientRect: () => felt }
|
||||
: { getBoundingClientRect: () => ringBox }) };
|
||||
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
|
||||
return { children: seats, childElementCount: seats.length };
|
||||
};
|
||||
|
||||
const place = (layer, radii) => {
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
Render.placeSeat(layer.children[i], i, 1, radii);
|
||||
}
|
||||
};
|
||||
|
||||
const worstOverhang = layer => {
|
||||
let over = 0;
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
const b = layer.children[i].getBoundingClientRect();
|
||||
over = Math.max(over, felt.left - b.left, b.right - felt.right,
|
||||
felt.top - b.top, b.bottom - felt.bottom);
|
||||
}
|
||||
return over;
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; delete global.document; });
|
||||
|
||||
test("a ring already inside the rail is left exactly where it is", () => {
|
||||
const layer = setUp(6, 60, 50);
|
||||
const radii = { rx: 30, ry: 30 };
|
||||
place(layer, radii);
|
||||
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("a puck past the rail brings the whole ring in", () => {
|
||||
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
|
||||
// another 30, against a felt whose half-height is 200. Six pixels over, at the
|
||||
// top and the bottom and both sides — which is the size the running page was
|
||||
// actually wrong by.
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
|
||||
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.ok(settled.ry < radii.ry, "the ring came in");
|
||||
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
|
||||
`still ${worstOverhang(layer).toFixed(1)}px over`);
|
||||
});
|
||||
|
||||
test("it comes in by what it was over, not by a step it guessed", () => {
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
const over = worstOverhang(layer);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
|
||||
// measurement, not a step size that would have to be tuned per screen.
|
||||
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
|
||||
assert.equal(settled.ry, 42.5);
|
||||
});
|
||||
|
||||
test("and it stops at the floor rather than collapsing into the pot", () => {
|
||||
// A puck taller than the felt has an overhang no ring can fix. Pulling in
|
||||
// forever would put every seat on top of the pot badge, which is a worse table
|
||||
// than one with a puck touching the rail.
|
||||
const layer = setUp(4, 60, 600);
|
||||
const radii = { rx: 40, ry: 40 };
|
||||
place(layer, radii);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.equal(settled.ry, Render.RING_MIN_R);
|
||||
assert.ok(settled.ry > 0);
|
||||
});
|
||||
|
||||
test("a table with nothing laid out yet is handed back untouched", () => {
|
||||
Render.state = { seats: [], seatCount: 0 };
|
||||
global.document = { getElementById: () => null };
|
||||
const radii = { rx: 44, ry: 42 };
|
||||
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("it is bounded — this runs after every tap a dealer makes", () => {
|
||||
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
|
||||
"each pass is a forced layout of the whole felt");
|
||||
assert.ok(Render.SEAT_NAME_PASSES <= 3);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,204 @@ const LayoutAudit = {
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Every control says what it is ----
|
||||
|
||||
// WCAG 4.1.2 wants a name on every control, and empty button names are among the
|
||||
// most common failures on the web — WebAIM's 2025 sweep found them on a bit over a
|
||||
// quarter of home pages. This tool had one: the × that removes a chip colour, five
|
||||
// of them in a column, identical, each deleting a different colour, and one click
|
||||
// of the wrong one took $22,500 off a table.
|
||||
//
|
||||
// A symbol counts as no name here even though it is technically text. "×" is
|
||||
// announced as "times" and names nothing — the rule is that a name has to contain a
|
||||
// letter or a digit, which is the difference between "×" and "Undo" or "+ Add chip
|
||||
// color". Anything with aria-hidden is not in the tree at all and is skipped rather
|
||||
// than judged.
|
||||
controlNames() {
|
||||
const named = el => {
|
||||
const aria = (el.getAttribute("aria-label") || "").trim();
|
||||
if (aria) return aria;
|
||||
|
||||
const by = el.getAttribute("aria-labelledby");
|
||||
if (by) {
|
||||
const text = by.split(/\s+/)
|
||||
.map(id => document.getElementById(id))
|
||||
.filter(Boolean)
|
||||
.map(node => (node.textContent || "").trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
// A field's name usually comes from its label rather than from itself.
|
||||
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
|
||||
const wrapping = el.closest("label");
|
||||
const pointing = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null;
|
||||
const text = [wrapping, pointing]
|
||||
.filter(Boolean)
|
||||
.map(node => (node.textContent || "").trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
const placeholder = (el.getAttribute("placeholder") || "").trim();
|
||||
if (placeholder) return placeholder;
|
||||
}
|
||||
|
||||
const own = (el.textContent || "").trim();
|
||||
if (own) return own;
|
||||
|
||||
const image = el.querySelector("img[alt]");
|
||||
if (image) return (image.getAttribute("alt") || "").trim();
|
||||
|
||||
return (el.getAttribute("title") || "").trim();
|
||||
};
|
||||
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => !el.closest("[aria-hidden='true']"))
|
||||
// A seat puck is in CONTROL_SELECTOR for the touch checks; its own name is
|
||||
// the player's, and an empty chair is deliberately unnamed.
|
||||
.filter(el => !el.classList.contains("seat"))
|
||||
.map(el => ({ el, name: named(el) }))
|
||||
.filter(({ name }) => !/[\p{L}\p{N}]/u.test(name))
|
||||
.map(({ el, name }) => `${this.describe(el)} :: ${name ? `named only "${name}"` : "no accessible name"}`);
|
||||
|
||||
return { name: "every control has a name a screen reader can read", offenders };
|
||||
},
|
||||
|
||||
// ---- Focus has somewhere to land ----
|
||||
|
||||
// Everything fixed to the bottom of the window, as one band. The action bar and the
|
||||
// toast stack, so what matters is the top edge of the highest of them.
|
||||
//
|
||||
// Only things crossing the middle of the screen count. A landscape phone turns the
|
||||
// bar into a rail down the right-hand side, which is bottom-anchored and full height
|
||||
// — measuring that as "furniture at the bottom" would demand scroll padding the
|
||||
// height of the whole window.
|
||||
bottomFurniture() {
|
||||
const middle = innerWidth / 2;
|
||||
// A sticky element is anchored to whatever scrolls it, which is only the window
|
||||
// when nothing between it and the root scrolls. The standings table has a sticky
|
||||
// header inside a panel that scrolls on its own; it comes to rest against the
|
||||
// bottom of the window often enough to be picked up here, and it is not
|
||||
// furniture — scroll-padding on the document would do nothing about it.
|
||||
const anchoredToWindow = node => {
|
||||
if (getComputedStyle(node).position === "fixed") return true;
|
||||
for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) {
|
||||
if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [...document.querySelectorAll("*")].filter(node => {
|
||||
const cs = getComputedStyle(node);
|
||||
if (cs.position !== "fixed" && cs.position !== "sticky") return false;
|
||||
if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false;
|
||||
const b = node.getBoundingClientRect();
|
||||
if (b.width === 0 || b.height === 0) return false;
|
||||
if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false;
|
||||
return anchoredToWindow(node);
|
||||
});
|
||||
},
|
||||
|
||||
// WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the
|
||||
// page put on top of it.
|
||||
//
|
||||
// A fixed bar is not in the flow, so the browser does not know it is there: Tab to a
|
||||
// control near the bottom and it is scrolled flush against the edge of the window,
|
||||
// under the bar, and the browser considers it done. Tabbing the whole page on a
|
||||
// 390x844 phone found 16 stops resting completely covered and 27 more partly.
|
||||
//
|
||||
// Checked as the fix rather than the symptom, because the symptom needs a keyboard
|
||||
// and this file has no automation of its own: scroll-padding-bottom on the scroll
|
||||
// container has to reserve at least as much room as the furniture standing there.
|
||||
focusHasRoom() {
|
||||
const furniture = this.bottomFurniture();
|
||||
if (furniture.length === 0) {
|
||||
return { name: "focus has room under the fixed bar", offenders: [],
|
||||
skipped: "nothing is fixed to the bottom here" };
|
||||
}
|
||||
|
||||
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||||
const needed = Math.round(innerHeight - highest);
|
||||
const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom);
|
||||
const reserved = Number.isFinite(declared) ? Math.round(declared) : 0;
|
||||
|
||||
const offenders = reserved >= needed ? [] : [
|
||||
`${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` +
|
||||
`and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` +
|
||||
`comes to rest ${needed - reserved}px inside it`,
|
||||
];
|
||||
return { name: "focus has room under the fixed bar", offenders };
|
||||
},
|
||||
|
||||
// ---- The furniture is not allowed to eat the table ----
|
||||
|
||||
// How much of the window the fixed bottom band is taking.
|
||||
//
|
||||
// The action bar's height is not a constant: it carries the pot breakdown, which had
|
||||
// one row per all-in and no ceiling. Measured on a 360x640 phone — five players
|
||||
// idle, 151px; six all-in, 303px; twelve all-in, 411px, which is 64% of the screen,
|
||||
// leaving 96px of felt to draw twelve seats in. Nothing in the sweep could see it,
|
||||
// because every check here asks whether a thing is covered or clipped and none of
|
||||
// them asked how much room the covering thing had taken.
|
||||
//
|
||||
// Two lines, because there are two different claims. Half the window is the one
|
||||
// worth failing on: furniture bigger than the content it serves is indefensible
|
||||
// whatever it is showing, and it is a shape fault rather than a matter of taste.
|
||||
// Thirty percent is the published guidance for persistent chrome and it is a warning
|
||||
// here, because the shortest phone in the list does not meet it at rest — the bar is
|
||||
// 151px of a 640px screen before anything happens — and a sweep that is always red
|
||||
// stops being read.
|
||||
furnitureBudget() {
|
||||
const name = "the fixed bar leaves the table its room";
|
||||
const furniture = this.bottomFurniture();
|
||||
if (furniture.length === 0) {
|
||||
return { name, offenders: [], skipped: "nothing is fixed to the bottom here" };
|
||||
}
|
||||
|
||||
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||||
const tall = Math.round(innerHeight - highest);
|
||||
const share = Math.round((100 * tall) / innerHeight);
|
||||
const what = furniture.map(n => this.describe(n)).join(" + ");
|
||||
|
||||
return {
|
||||
name,
|
||||
offenders: share > 50
|
||||
? [`${what} stands ${tall}px of a ${innerHeight}px window — ${share}%, ` +
|
||||
`leaving the felt less room than the furniture`]
|
||||
: [],
|
||||
warnings: share > 30
|
||||
? [`${name}: ${what} is ${share}% of the window (${tall}px of ${innerHeight}px); ` +
|
||||
`persistent chrome is usually kept under 30%`]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
|
||||
// The other half: a control that scrolling can never reach at all.
|
||||
//
|
||||
// The seat editor is a bottom sheet on a phone, and it was put away with
|
||||
// `transform: translateY(105%)` — which moves it and nothing else. Parked below an
|
||||
// 844px window at y=850, its Done button was still in the tab order and still on the
|
||||
// accessibility tree, and because a fixed element does not move when the page does,
|
||||
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
|
||||
// transform only moves it.
|
||||
focusablesReachable() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => {
|
||||
for (let node = el; node; node = node.parentElement) {
|
||||
if (getComputedStyle(node).position === "fixed") return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.filter(el => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
|
||||
})
|
||||
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
|
||||
|
||||
return { name: "no control is parked where scrolling can't reach it", offenders };
|
||||
},
|
||||
|
||||
// ---- Touch (see the mobile round) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
@@ -93,6 +291,20 @@ const LayoutAudit = {
|
||||
return false;
|
||||
},
|
||||
|
||||
// The scrolling ancestor whose box this element has fallen outside of, if any.
|
||||
// Wholly outside — an element straddling the edge is still half on show, and the
|
||||
// half that is showing is worth hit-testing.
|
||||
clippedOutBy(el, rect) {
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue;
|
||||
const box = node.getBoundingClientRect();
|
||||
if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 ||
|
||||
rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// The modal that is currently on top, if any.
|
||||
//
|
||||
// Every check below asks "is this thing reachable", and while a sheet or dialog is
|
||||
@@ -143,6 +355,22 @@ const LayoutAudit = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Still on screen, but clipped away by a panel that scrolls. Hit-testing it
|
||||
// is meaningless: the point lands on whatever the panel's own edge has next
|
||||
// to it, and the answer comes back as a diagnosis of something that isn't
|
||||
// happening. The pot wizard's third winner, below the fold in a list that
|
||||
// scrolls, was reported for four viewports running as "fully covered by
|
||||
// button Confirm Winner(s)" — the button is simply what is painted at that
|
||||
// height. Whether it is a fault is the same question as for anything else
|
||||
// out of view: can it be scrolled to.
|
||||
const clip = this.clippedOutBy(el, rect);
|
||||
if (clip) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const points = [
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
|
||||
@@ -739,6 +967,414 @@ const LayoutAudit = {
|
||||
return { name: "hidden means hidden", offenders };
|
||||
},
|
||||
|
||||
// A mark on the felt that stands for nothing.
|
||||
//
|
||||
// The amount pill inside every seat puck has a background, a pill radius and side
|
||||
// padding, so a player who had not put chips in yet got an 8x11px green lozenge
|
||||
// where their bet would go — measured on seven of nine seats at an ordinary table.
|
||||
// One of those is a component holding its place; nine of them is a rash, and a
|
||||
// dealer reading the felt has to learn to ignore a colour that on three other seats
|
||||
// means "this player has money out".
|
||||
//
|
||||
// It cost room as well as attention. The ring is sized from the tallest puck on it,
|
||||
// so a row nobody had filled was setting how far apart the seats had to sit.
|
||||
//
|
||||
// The rule is about paint, not about space: an element may hold its place invisibly,
|
||||
// and several here do. What it may not do is draw a background or a border with
|
||||
// nothing inside it.
|
||||
//
|
||||
// One real exemption, and it is on the list because the check reported it rather
|
||||
// than because it was predicted: .betChipDisc is a 13x13px circle filled with the
|
||||
// denomination colour of the chip beside it, published per chip from render.js. It
|
||||
// has no text because the colour *is* the content. Anything carrying its own
|
||||
// picture in a background-image is exempt on the same grounds, without needing to be
|
||||
// named. Nothing else here needs an entry — three others were guessed at first and
|
||||
// matched no element on the page, which is its own warning about writing this list
|
||||
// from memory instead of from a run.
|
||||
emptyDecoration() {
|
||||
const DECORATIVE = new Set(["betChipDisc"]);
|
||||
const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value)
|
||||
&& value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
|
||||
|
||||
const offenders = [...document.querySelectorAll("#pokerTableWrap *")]
|
||||
.filter(el => {
|
||||
if (el.children.length || (el.textContent || "").trim()) return false;
|
||||
if ([...el.classList].some(c => DECORATIVE.has(c))) return false;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width < 2 || box.height < 2) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
||||
if (opaque(cs.backgroundImage)) return false;
|
||||
return opaque(cs.backgroundColor) ||
|
||||
["Top", "Right", "Bottom", "Left"].some(side =>
|
||||
parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`]));
|
||||
})
|
||||
.map(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return `${this.describe(el)} :: nothing in it, painted ` +
|
||||
`${Math.round(box.width)}x${Math.round(box.height)}px`;
|
||||
});
|
||||
|
||||
return { name: "nothing on the felt is painted for nothing", offenders };
|
||||
},
|
||||
|
||||
// One green, and it is the thing to do next.
|
||||
//
|
||||
// The accent is a promise: this is the step. It only works while it is spent on one
|
||||
// control at a time, and it was being spent on two. Measured over a whole hand,
|
||||
// seventeen steps, before this went in: two accent controls at thirteen of them —
|
||||
// the bar's Call and the header's Resolve Pot, the identical gradient at opposite
|
||||
// ends of a phone — and at the other four the header alone, while the bar held the
|
||||
// only button there was to press. At showdown the bar's own "Showdown — resolve
|
||||
// pot" was blue-grey and the corner shortcut was green.
|
||||
//
|
||||
// A modal is its own view, so while one is open only what is inside it is judged —
|
||||
// a wizard's confirm is the primary action of the wizard, and the bar underneath it
|
||||
// is not on screen in any sense that matters. That is the same scoping rule the
|
||||
// occlusion check uses, and it comes from the same helper.
|
||||
//
|
||||
// Zero is not a failure. Between hands with nobody seated there is genuinely no next
|
||||
// step, and inventing one to keep a colour company would be worse than the silence.
|
||||
//
|
||||
// Six broken versions were run at it and six were caught, but two of them only after
|
||||
// the injection itself was fixed, and both failures were the same mistake: painting
|
||||
// a control green is not the same as putting a green control in front of anybody.
|
||||
// The first attempt turned every sidebar button green with the panels shut, so the
|
||||
// rule landed on nothing that was rendered — `getComputedStyle` reported the accent
|
||||
// gradient and `offsetParent` was still null. The second opened the panels first,
|
||||
// which rendered the button and left it four hundred pixels below the fold, where
|
||||
// this check is meant to ignore it. Scrolled into view, the same green "Add" is
|
||||
// named in the same breath as the bar's Call, and taking the green off goes clean
|
||||
// again. So: read back the computed paint AND the box, on both counts, before
|
||||
// believing a clean result means the check is looking.
|
||||
ACCENT_FILL: [22, 120, 58],
|
||||
ACCENT_TOLERANCE: 24,
|
||||
|
||||
wearsTheAccent(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
const paint = `${cs.backgroundColor} ${cs.backgroundImage}`;
|
||||
return [...paint.matchAll(/rgba?\(([^)]+)\)/g)]
|
||||
.map(m => m[1].split(",").map(Number))
|
||||
.some(([r, g, b, a]) => (a === undefined || a > 0.5) &&
|
||||
Math.abs(r - this.ACCENT_FILL[0]) <= this.ACCENT_TOLERANCE &&
|
||||
Math.abs(g - this.ACCENT_FILL[1]) <= this.ACCENT_TOLERANCE &&
|
||||
Math.abs(b - this.ACCENT_FILL[2]) <= this.ACCENT_TOLERANCE);
|
||||
},
|
||||
|
||||
oneNextStep() {
|
||||
const scope = this.modalScope();
|
||||
const root = scope || document;
|
||||
const onScreen = el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || +cs.opacity === 0) return false;
|
||||
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
|
||||
};
|
||||
|
||||
const green = [...root.querySelectorAll("button, a.holdemBtn")]
|
||||
.filter(el => el.offsetParent !== null && onScreen(el) && this.wearsTheAccent(el));
|
||||
|
||||
if (green.length <= 1) {
|
||||
return { name: "one green, and it is the next step", offenders: [] };
|
||||
}
|
||||
return {
|
||||
name: "one green, and it is the next step",
|
||||
offenders: [`${green.length} controls wear the accent at once` +
|
||||
`${scope ? " inside the open sheet" : ""}: ` +
|
||||
green.map(el => `"${(el.textContent || "").trim().slice(0, 24)}" in ` +
|
||||
`${el.closest("#holdemHeader") ? "the header"
|
||||
: el.closest("#actionBar") ? "the action bar"
|
||||
: el.closest("#holdemSidebar") ? "the sidebar" : "the page"}`).join(", ")],
|
||||
};
|
||||
},
|
||||
|
||||
// The status a shut sidebar panel wears, and the three ways it can go wrong.
|
||||
//
|
||||
// Each of the eight <details> in the sidebar now carries the one fact you would
|
||||
// have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the
|
||||
// sidebar's whole navigation, and what makes them scannable is that they are eight
|
||||
// identical slabs: a glance that pushes one row taller than the rest costs more in
|
||||
// rhythm than the fact it added is worth. Two of the strings aren't ours to keep
|
||||
// short — a table note is whatever the dealer typed, and the amounts grow with the
|
||||
// stakes — so this is checked on the page rather than argued about in the markup.
|
||||
//
|
||||
// Four questions, and they fail in different directions. A row that grew is the
|
||||
// layout one. A glance sitting on its own label is the narrow-screen one, and it is
|
||||
// the one that will come back: it takes a long label and a long glance in the same
|
||||
// panel, so it depends on the state and the viewport together. And a glance
|
||||
// ellipsised down to "Sam…" is worth less than no glance at all — it has spent a
|
||||
// row's width to say that a note exists, which the panel's own presence already
|
||||
// said.
|
||||
GLANCE_MIN_READABLE_PX: 60,
|
||||
|
||||
glancesFitTheirRows() {
|
||||
// Rows that are actually drawn. The Keyboard panel is display: none under
|
||||
// `pointer: coarse` — a touch device has no keys to press — and a panel that
|
||||
// isn't rendered measures 0px, which the shortest-row rule below happily took
|
||||
// as its idea of a bare row. Every real row then read as grown: caught by the
|
||||
// sweep on its first clean run, 91 offenders, seven rows across each of the
|
||||
// thirteen viewports visited with a thumb.
|
||||
const rows = [...document.querySelectorAll(".sidebarPanel > summary")]
|
||||
.filter(row => row.getBoundingClientRect().height > 0);
|
||||
if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" };
|
||||
|
||||
// The shortest row in the sidebar is what a row with nothing on it measures,
|
||||
// whichever one that happens to be — taken rather than declared, so a change to
|
||||
// the header's padding doesn't quietly make this check pass by moving the bar.
|
||||
const heights = rows.map(row => row.getBoundingClientRect().height);
|
||||
const bare = Math.min(...heights);
|
||||
|
||||
const offenders = [];
|
||||
rows.forEach((row, index) => {
|
||||
const glance = row.querySelector(".panelGlance");
|
||||
const label = row.querySelector("h2");
|
||||
const rowBox = row.getBoundingClientRect();
|
||||
const name = (label && label.textContent.trim()) || `panel ${index + 1}`;
|
||||
|
||||
if (heights[index] > bare + 1) {
|
||||
offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` +
|
||||
`${Math.round(bare)}px for a row with nothing on it`);
|
||||
}
|
||||
if (!glance || glance.hidden || !glance.textContent.trim()) return;
|
||||
|
||||
const box = glance.getBoundingClientRect();
|
||||
if (label) {
|
||||
const labelBox = label.getBoundingClientRect();
|
||||
if (box.left < labelBox.right) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` +
|
||||
`${Math.round(labelBox.right - box.left)}px into it`);
|
||||
}
|
||||
}
|
||||
// Both axes, and the vertical one is the half that matters. The row's own
|
||||
// height cannot be trusted to report this: the first version of this check
|
||||
// asked only whether a row had grown, and a glance floated back into flow
|
||||
// and allowed to wrap sailed straight through it — a float doesn't expand
|
||||
// the box it sits in, so two lines of note hung 9px clear of a row that
|
||||
// still measured exactly 44px. Asking where the glance actually
|
||||
// ended up is the question that has an answer.
|
||||
const out = [
|
||||
box.right > rowBox.right + 1 ? "off the right" : null,
|
||||
box.left < rowBox.left - 1 ? "off the left" : null,
|
||||
box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null,
|
||||
box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null,
|
||||
].filter(Boolean);
|
||||
if (out.length) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`);
|
||||
}
|
||||
if (box.width < this.GLANCE_MIN_READABLE_PX &&
|
||||
glance.scrollWidth > glance.clientWidth + 1) {
|
||||
offenders.push(`${name} :: "${glance.textContent}" is cut down to ` +
|
||||
`${Math.round(box.width)}px, which says less than nothing`);
|
||||
}
|
||||
});
|
||||
|
||||
return { name: "panel glances fit their rows", offenders };
|
||||
},
|
||||
|
||||
// A seat's name, painted under the decoration on its own puck.
|
||||
//
|
||||
// The chip that marks the button sits in the puck's top-right corner on a layer
|
||||
// above the text, and the name is centred across the whole width. A long one
|
||||
// therefore ran underneath it and stopped — measured, "Christopher" on the button
|
||||
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
|
||||
// concerned it had all the room it needed. Nothing else here would have caught it:
|
||||
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||
// a dealer has to read.
|
||||
// Measured against the lines of text as they were painted, not against the box they
|
||||
// live in. This used to take the box and subtract its padding, on the reasoning that
|
||||
// "padding is room the text has already been kept out of" — which is true, and is
|
||||
// not the only way to keep text out of somewhere. A right float does it too, and it
|
||||
// does it per line, which is the point of using one: the name on the button seat is
|
||||
// now indented past the chip on the line beside it and full width on the line below.
|
||||
// Box-minus-padding cannot see that, and called every seat in the sweep an offender
|
||||
// while the text sat exactly where it was supposed to.
|
||||
//
|
||||
// Range rects are the direct question — where did this text actually end up — so
|
||||
// padding, floats, centring, wrapping and a short label in a wide box all come out
|
||||
// right without any of them being modelled. Line boxes rather than one bounding box,
|
||||
// too: a two-line name that clears the chip on the line beside it is not covered
|
||||
// because its *other* line happens to sit under where the chip is.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(name);
|
||||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||
if (!lines.length) return;
|
||||
|
||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||
const over = mark.getBoundingClientRect();
|
||||
if (!over.width || !over.height) return;
|
||||
const marked = getComputedStyle(mark);
|
||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||
parseFloat(marked.opacity) < 0.05) return;
|
||||
|
||||
let worst = 0;
|
||||
lines.forEach(line => {
|
||||
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
|
||||
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
|
||||
if (across > 1 && down > 1) worst = Math.max(worst, across);
|
||||
});
|
||||
if (worst > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { name: "seat names aren't painted under their own badges", offenders };
|
||||
},
|
||||
|
||||
// A seat's writing, covered by something belonging to a different seat.
|
||||
//
|
||||
// seatLabelsClear() above asks whether a puck's own decoration sits on its own name.
|
||||
// This asks the other half, and the other half was where the tool was actually
|
||||
// failing: measured across nine viewports and six table sizes before this went in,
|
||||
// seventeen of fifty-four tables had one seat's name or stack painted under a
|
||||
// neighbour. On a Galaxy S8 at the most ordinary table there is — six players at a
|
||||
// nine-handed table — seat 2's stack was under seat 3's puck, and five pairs of
|
||||
// seats overlapped, the worst by 61px.
|
||||
//
|
||||
// Two ways in, and it takes both:
|
||||
// - the pucks themselves overlapping, which the layout used to permit on the
|
||||
// reasoning that rounded corners forgive a little; what they were forgiving was
|
||||
// a four-figure stack.
|
||||
// - a blind badge hanging off its puck onto the seat next door, which no amount
|
||||
// of space between the pucks fixes on its own.
|
||||
//
|
||||
// Hit-tested against the glyph run rather than the element box. A name is centred,
|
||||
// so its box is wider than its ink and testing the box would fail on empty air.
|
||||
//
|
||||
// The glyph run is then clipped back to what is actually painted, which is not the
|
||||
// same thing and cost this check its first honest-looking failure. A puck's name is
|
||||
// a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every
|
||||
// line the text *would* have taken — including the two that the clamp throws away.
|
||||
// Measured at 640x360 with twelve players: "Mike from work" reported a line 30px
|
||||
// below its own puck, which duly hit-tested onto the seat underneath, and the seats
|
||||
// were not touching at all. Nothing is painted there, so nothing can cover it.
|
||||
seatsKeepTheirText() {
|
||||
const name = "no seat covers another seat's writing";
|
||||
if (this.modalScope()) {
|
||||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||||
}
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
if (!seats.length) return { name, offenders: [], skipped: "no seats on this page" };
|
||||
|
||||
const offenders = [];
|
||||
seats.forEach((seat, index) => {
|
||||
seat.querySelectorAll(".seatPuckName, .seatPuckStack, .seatPuckTotal").forEach(label => {
|
||||
const text = label.textContent.trim();
|
||||
if (!text) return;
|
||||
const painted = label.getBoundingClientRect();
|
||||
if (!painted.width || !painted.height) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(label);
|
||||
[...range.getClientRects()].forEach(raw => {
|
||||
const line = {
|
||||
left: Math.max(raw.left, painted.left),
|
||||
right: Math.min(raw.right, painted.right),
|
||||
top: Math.max(raw.top, painted.top),
|
||||
bottom: Math.min(raw.bottom, painted.bottom),
|
||||
};
|
||||
if (line.right - line.left <= 1 || line.bottom - line.top <= 1) return;
|
||||
// Along the line rather than at its middle: a badge takes the start
|
||||
// of it and a neighbouring puck the end, and a single centre probe
|
||||
// walks past both.
|
||||
//
|
||||
// Every probe, not the first one that hits. How much of the line is
|
||||
// gone is the difference between a stack a dealer cannot read and a
|
||||
// hairline where a badge's rounded corner grazes the last digit, and
|
||||
// reporting both as "is under" gave no way to tell them apart — the
|
||||
// one residual this sweep has carried for three rounds turns out to
|
||||
// be one probe of seven.
|
||||
let hits = 0;
|
||||
let culprit = null;
|
||||
let probes = 0;
|
||||
for (let across = 0.08; across <= 0.92; across += 0.14) {
|
||||
const x = line.left + (line.right - line.left) * across;
|
||||
const y = (line.top + line.bottom) / 2;
|
||||
probes++;
|
||||
const over = document.elementFromPoint(x, y);
|
||||
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
||||
hits++;
|
||||
if (!culprit) culprit = over;
|
||||
}
|
||||
if (culprit) {
|
||||
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
|
||||
+ `under ${this.describe(culprit)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { name, offenders };
|
||||
},
|
||||
|
||||
// The other thing on the felt that carries a number.
|
||||
//
|
||||
// A chip says what a player has put in this street, which is what a call costs. Two
|
||||
// of them on top of each other is two figures a dealer can't read, and one behind a
|
||||
// puck is a figure that isn't there at all.
|
||||
//
|
||||
// Geometry rather than hit-testing, which is the opposite of how every other check
|
||||
// here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint
|
||||
// never returns a chip — it returns whatever is behind it, for every chip, always.
|
||||
// A first version of this probe hit-tested and duly reported all forty-five
|
||||
// configurations as broken, including the ones that were fine. Paint order is known
|
||||
// and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat
|
||||
// carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind.
|
||||
chipsKeepTheirAmounts() {
|
||||
const name = "no bet chip is hidden behind a seat or another chip";
|
||||
if (this.modalScope()) {
|
||||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||||
}
|
||||
const chips = [...document.querySelectorAll(".betChip")]
|
||||
.filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
||||
if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" };
|
||||
|
||||
const overlap = (A, B) =>
|
||||
Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 &&
|
||||
Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5;
|
||||
const offenders = [];
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
|
||||
chips.forEach(chip => {
|
||||
const label = chip.textContent.trim() || "a chip";
|
||||
const box = chip.getBoundingClientRect();
|
||||
seats.forEach((seat, index) => {
|
||||
// Its own puck is the one thing it is allowed to touch: on a narrow felt
|
||||
// the chip clings to the seat rather than taking a ring of its own.
|
||||
if (seat.contains(chip)) return;
|
||||
if (overlap(box, seat.getBoundingClientRect())) {
|
||||
offenders.push(`chip ${label} is behind seat ${index + 1}`);
|
||||
}
|
||||
});
|
||||
// A chip wider than the puck it is clinging to is on its neighbours by
|
||||
// construction, whatever the ring does.
|
||||
const host = chip.closest(".seat");
|
||||
if (host && chip.classList.contains("betChipAnchored")) {
|
||||
const puck = host.getBoundingClientRect();
|
||||
if (box.width > puck.width + 0.5) {
|
||||
offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` +
|
||||
`wider than its own puck`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let a = 0; a < chips.length; a++) {
|
||||
for (let b = a + 1; b < chips.length; b++) {
|
||||
if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) {
|
||||
offenders.push(`chip ${chips[a].textContent.trim()} and chip ` +
|
||||
`${chips[b].textContent.trim()} are on top of each other`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, offenders: [...new Set(offenders)] };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
@@ -755,8 +1391,102 @@ const LayoutAudit = {
|
||||
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
|
||||
// on it — but "not measured" and "measured and fine" are different answers and the
|
||||
// runner now prints which one it got.
|
||||
// The table has to be big enough for the game around it.
|
||||
//
|
||||
// The seat ring sat at a flat 44% by 42% of the seat layer at every size — two
|
||||
// numbers picked against a phone. A fixed pair can only be right for one ratio of
|
||||
// puck to felt, and measured on the running page it was wrong nearly everywhere:
|
||||
// at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the
|
||||
// table, the worst by 49px; on a phone held sideways all six did. Seats floating
|
||||
// off the felt onto the page read as the table being too small for the game rather
|
||||
// than as players at a rail — and it was a different amount of wrong on every
|
||||
// screen, which is what made it look like a mistake instead of a choice.
|
||||
//
|
||||
// Checked against the table's outer edge, border included: sitting *on* the rail is
|
||||
// fine, hanging off the far side of it is not. The bet chips are held to the same
|
||||
// rule — a chip is meant to be on the felt between its player and the pot.
|
||||
seatsOnTheTable(slackPx = 1) {
|
||||
const name = "seats and chips stay on the table";
|
||||
if (this.modalScope()) {
|
||||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||||
}
|
||||
const table = document.getElementById("pokerTable");
|
||||
if (!table) return { name, offenders: [], skipped: "no table on this page" };
|
||||
|
||||
const felt = table.getBoundingClientRect();
|
||||
if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" };
|
||||
|
||||
const offenders = [];
|
||||
const check = (el, what) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
const over = Math.max(
|
||||
felt.left - r.left, r.right - felt.right,
|
||||
felt.top - r.top, r.bottom - felt.bottom);
|
||||
if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`);
|
||||
};
|
||||
|
||||
[...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`));
|
||||
[...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) =>
|
||||
check(chip, `bet chip ${i + 1}`));
|
||||
|
||||
return { name, offenders };
|
||||
},
|
||||
|
||||
// An open dialog's buttons have to be on screen without hunting for them.
|
||||
//
|
||||
// The pot wizard — the one dialog in this tool that divides money — had its Confirm
|
||||
// below a scroll at every size: measured with six players and a side pot, 244px off
|
||||
// the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, 172
|
||||
// on a desktop. Nothing on screen said there was anything below the list of names,
|
||||
// so the only control that finishes the hand was invisible with the table waiting.
|
||||
//
|
||||
// Checked by hit-testing rather than by geometry alone: a button can be inside the
|
||||
// viewport and still be under something. Only dialogs that are actually open are
|
||||
// considered, and only their buttons — a dialog with no buttons has nothing to find.
|
||||
dialogActionsReachable() {
|
||||
const name = "an open dialog's buttons are on screen";
|
||||
const dialogs = [...document.querySelectorAll("[role='dialog'], [role='alertdialog']")]
|
||||
.filter(d => {
|
||||
const rect = d.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 && getComputedStyle(d).visibility !== "hidden";
|
||||
});
|
||||
if (dialogs.length === 0) return { name, offenders: [], skipped: "no dialog is open" };
|
||||
|
||||
const offenders = [];
|
||||
dialogs.forEach(dialog => {
|
||||
const buttons = [...dialog.querySelectorAll("button")].filter(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
});
|
||||
buttons.forEach(button => {
|
||||
const r = button.getBoundingClientRect();
|
||||
const label = this.describe(button);
|
||||
if (r.bottom > innerHeight || r.top < 0) {
|
||||
const off = r.bottom > innerHeight
|
||||
? Math.round(r.bottom - innerHeight) + "px below the fold"
|
||||
: Math.round(-r.top) + "px above it";
|
||||
offenders.push(`${label} is ${off}`);
|
||||
return;
|
||||
}
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
if (hit && hit !== button && !button.contains(hit)) {
|
||||
offenders.push(`${label} is covered by ${this.describe(hit)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { name, offenders };
|
||||
},
|
||||
|
||||
runAll(options = {}) {
|
||||
const checks = [
|
||||
this.seatsOnTheTable(options.seatSlack),
|
||||
this.dialogActionsReachable(),
|
||||
this.controlNames(),
|
||||
this.focusHasRoom(),
|
||||
this.focusablesReachable(),
|
||||
this.furnitureBudget(),
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
@@ -768,6 +1498,12 @@ const LayoutAudit = {
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.emptyDecoration(),
|
||||
this.oneNextStep(),
|
||||
this.glancesFitTheirRows(),
|
||||
this.seatLabelsClear(),
|
||||
this.seatsKeepTheirText(),
|
||||
this.chipsKeepTheirAmounts(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user