Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c052ee32c8 | ||
|
|
1f3dda180f | ||
|
|
3ffce9d7c1 | ||
|
|
5d5093f3cc | ||
|
|
aaf96543a8 | ||
|
|
2e9c55af21 | ||
|
|
8b43614b1c | ||
|
|
42b9e83e8f | ||
|
|
419c1de2a0 | ||
|
|
470fbc0d26 | ||
|
|
c4e842f3e3 | ||
|
|
1d9353c94e | ||
|
|
8616636212 | ||
|
|
6174981cdc | ||
|
|
4db6f6e4d3 | ||
|
|
0ad5592687 | ||
|
|
1c2e716508 | ||
|
|
236c52567e | ||
|
|
1689ecb4f0 | ||
|
|
b8655be258 | ||
|
|
55eb569d3b | ||
|
|
7e420ce747 | ||
|
|
86886ac126 | ||
|
|
c36d7f60bd | ||
|
|
f966d8c340 | ||
|
|
15cec18256 | ||
|
|
9c8d981e2d | ||
|
|
907e0165db | ||
|
|
86e1570618 | ||
|
|
6df400322e | ||
|
|
ca9ac7b6af | ||
|
|
80dae56f0d | ||
|
|
6ca99fde6b | ||
|
|
b2262f8604 | ||
|
|
6ad9923aae | ||
|
|
168e8a380c | ||
|
|
edc08d7213 |
@@ -7,17 +7,23 @@
|
||||
|
||||
<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 to the
|
||||
Session panel beside Clear history and the save/load pair, 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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -36,6 +42,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>
|
||||
|
||||
@@ -51,7 +58,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="sidebarPanel" open>
|
||||
@* 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>Table Setup</summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
@@ -145,6 +156,14 @@
|
||||
</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>
|
||||
@@ -162,7 +181,12 @@
|
||||
<span>House take</span>
|
||||
<span id="bankRaked"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
|
||||
<span>Paid out</span>
|
||||
<span id="bankPaidOut"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
@@ -195,6 +219,11 @@
|
||||
<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 />
|
||||
@* Down from the header, where it sat in the same weight as Resolve Pot
|
||||
— the most-pressed control in a session next to the one that throws
|
||||
the night away. Here it is beside Clear history, which is the other
|
||||
thing that destroys records, and behind a panel you have to open. *@
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -551,7 +580,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>
|
||||
|
||||
@@ -573,13 +612,43 @@
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
@@ -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 |
@@ -113,12 +113,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 +149,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 +311,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.
|
||||
|
||||
@@ -459,6 +459,115 @@ 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
|
||||
// bet leaves the stack, sits in front of the player, and is swept into their hand
|
||||
// total at the end of the street. The other way is the "without live betting" panel
|
||||
// — a base pot amount, or chips counted out on a seat — where the dealer is
|
||||
// recording a hand they are watching rather than driving, and nothing ever leaves
|
||||
// anybody's stack.
|
||||
//
|
||||
// The pot wizard prices both the same way, and awarding it credits the winner. So
|
||||
// in that second mode the table simply grew: measured at a three-handed table with
|
||||
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
|
||||
// left the bank check blaming a rebuy that never happened.
|
||||
//
|
||||
// So before the pot is pushed, whatever was assumed rather than taken comes off the
|
||||
// stacks that assumed it. Chips already committed or swept are left alone; they
|
||||
// have been paid once already, and taking them twice is the same bug the other way
|
||||
// round.
|
||||
collectAssumedContributions(state) {
|
||||
const taken = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
|
||||
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
const owed = this.round(priced - alreadyPaid);
|
||||
if (owed <= this.EPSILON_CHIPS) return;
|
||||
|
||||
// Never more than they are holding. A seat priced above its own stack is a
|
||||
// dealer's typo or a player who is short, and inventing the difference is
|
||||
// what this whole function exists to stop.
|
||||
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
|
||||
if (moved <= this.EPSILON_CHIPS) return;
|
||||
|
||||
player.stack = this.round(this.stackOf(state, player) - moved);
|
||||
// Recorded where a swept bet lives, so the money is in exactly the place
|
||||
// the rest of the tool looks for chips that are in the middle this hand.
|
||||
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
|
||||
});
|
||||
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
|
||||
},
|
||||
|
||||
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
|
||||
// of the first two cards exposed — and the tool had no way to do it. The only
|
||||
// control that ended a live hand was Next Hand, which says out loud that it takes
|
||||
// the money off the table without paying anybody. That is right for an abandoned
|
||||
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
|
||||
// who put them in.
|
||||
//
|
||||
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
|
||||
// while the action history is still there. It lives in memory, so a dealer who
|
||||
// refreshes the page mid-hand, or who is three calls in, has nothing.
|
||||
//
|
||||
// What is deliberately kept: the button, the blinds, the straddle and anyone
|
||||
// posting in. A misdeal is the *same* hand dealt again, not the next one.
|
||||
takeHandBack(state) {
|
||||
const returned = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
if (back > 0) {
|
||||
player.stack = this.round(this.stackOf(state, player) + back);
|
||||
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
|
||||
}
|
||||
player.committed = 0;
|
||||
player.amountOverride = null;
|
||||
player.hasActed = false;
|
||||
player.folded = false;
|
||||
player.cards = ["", ""];
|
||||
});
|
||||
|
||||
state.bettingOpen = false;
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
// 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;
|
||||
state.postedStraddles = [];
|
||||
|
||||
return {
|
||||
returned,
|
||||
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Pulls the blinds back off the table and posts them again from wherever the
|
||||
// button and the blind assignment now say they belong. Refunding first is what
|
||||
// keeps the chip count straight when the button gets passed round three or four
|
||||
|
||||
@@ -54,7 +54,10 @@ const HandHistoryExport = {
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
// Somebody who has gone home is marked in the file the same way the
|
||||
// panel marks them, because this is the copy that gets read at the
|
||||
// end of the night when nobody remembers who has already been paid.
|
||||
row.cashedOut ? `${row.name} (paid out)` : row.name,
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
|
||||
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||
|
||||
// The waiting list, from the Session panel and from an empty chair's menu.
|
||||
// The state change goes inside runUndoable, not before it: the snapshot is
|
||||
// taken as the first thing that happens, so anything done ahead of it is
|
||||
// already baked into what Undo would restore.
|
||||
seatWaiting: (id, index) => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
const seat = state.seats[index];
|
||||
if (!entry || !seat) {
|
||||
Toast.show("They aren't on the list any more.", { duration: 3000 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
const occupied = HoldemState.seatInPlay(seat);
|
||||
if (occupied) {
|
||||
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
|
||||
hooks.rerender();
|
||||
return;
|
||||
}
|
||||
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
|
||||
() => HoldemState.seatFromWaitingList(state, id, index));
|
||||
},
|
||||
|
||||
dropWaiting: id => {
|
||||
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||
if (!entry) return;
|
||||
runUndoable(`${entry.name} taken off the waiting list.`,
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
};
|
||||
|
||||
@@ -96,6 +125,39 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
},
|
||||
|
||||
// Somebody is done for the night. The dealer counts their chips out, the chair
|
||||
// opens, and both halves of what happened stay on the record — the cash the box
|
||||
// took from them, and the chips it handed back. Wiping the seat by hand did
|
||||
// neither: it left the bank check declaring their whole stack missing for the
|
||||
// rest of the night.
|
||||
cashOut: async index => {
|
||||
const allowed = HoldemState.canCashOut(state, index);
|
||||
if (!allowed.ok) {
|
||||
Toast.show(allowed.reason, { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = HoldemState.cashOutPreview(state, index);
|
||||
const result = preview.net === 0
|
||||
? "even for the night"
|
||||
: preview.net > 0
|
||||
? `up ${Money.format(preview.net)}`
|
||||
: `down ${Money.format(Math.abs(preview.net))}`;
|
||||
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Cash out ${preview.name}?`,
|
||||
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
||||
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
||||
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
||||
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
||||
() => HoldemState.cashOut(state, index));
|
||||
},
|
||||
|
||||
// Two players change chairs. Undoable like every other seat action, because a
|
||||
// swap is easy to aim wrong and the seats look alike at a glance.
|
||||
swapSeats: (from, to) => {
|
||||
@@ -114,8 +176,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.`
|
||||
@@ -142,18 +203,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),
|
||||
|
||||
@@ -185,6 +257,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.
|
||||
@@ -199,6 +287,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (rake > 0) {
|
||||
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
|
||||
}
|
||||
// Anything the pot was priced with but nobody actually paid — the
|
||||
// base pot and counted chips of the no-live-betting flow — comes off
|
||||
// the stacks that owe it before the winner is paid. Otherwise the
|
||||
// table gains the whole pot out of nowhere.
|
||||
Betting.collectAssumedContributions(state);
|
||||
if (payouts.length > 0) {
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
@@ -224,6 +317,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();
|
||||
@@ -304,12 +407,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// in progress. Shrinking the table throws these away permanently, and it also
|
||||
// orphans them in the session standings, where they keep their winnings but lose
|
||||
// the stack and buy-in the settle-up is worked out from.
|
||||
const seatHasSomeoneIn = player =>
|
||||
!!(player.name && player.name.trim()) ||
|
||||
player.inHand ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0;
|
||||
// One question, asked in one place. This predicate existed here, in the seat menu,
|
||||
// in the action bar and twice in the state module, in four slightly different
|
||||
// spellings — and a chair that counts as occupied for the waiting list but empty
|
||||
// for the seat count is how somebody's chips get thrown away.
|
||||
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||
@@ -368,6 +470,59 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
||||
// same button deals the same hand again.
|
||||
// The seats holding the table's starting stack, written down as cash taken.
|
||||
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
||||
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
if (starting.length === 0) return;
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Write down ${Money.format(total)} taken?`,
|
||||
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
||||
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
||||
confirmLabel: "Write it down",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
||||
() => HoldemState.recordStartingBuyIns(state));
|
||||
});
|
||||
|
||||
document.getElementById("waitingListForm").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const field = document.getElementById("waitingNameInput");
|
||||
const name = field.value.trim();
|
||||
if (!name) return;
|
||||
field.value = "";
|
||||
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
|
||||
field.focus();
|
||||
});
|
||||
|
||||
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
||||
const inPlay = HoldemState.unresolvedContributions(state);
|
||||
const acted = state.seats.filter(p => p.hasActed).length;
|
||||
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Misdeal — take the hand back?",
|
||||
message: inPlay > Betting.EPSILON_CHIPS
|
||||
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
||||
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
||||
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
||||
confirmLabel: "Take it back",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// The label is both the toast a dealer reads now and the words on the undo
|
||||
// control afterwards, so it says what happened to the money rather than naming
|
||||
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
||||
const label = inPlay > Betting.EPSILON_CHIPS
|
||||
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
||||
: "Misdeal — nothing was in yet, same button deals again.";
|
||||
runUndoable(label, () => Betting.takeHandBack(state));
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||
@@ -718,4 +873,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 });
|
||||
});
|
||||
|
||||
@@ -87,6 +87,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());
|
||||
|
||||
@@ -191,9 +196,13 @@ const PotWizard = {
|
||||
this.entries = result.updatedEntries;
|
||||
this.renderStep();
|
||||
});
|
||||
round.appendChild(confirmBtn);
|
||||
|
||||
body.appendChild(round);
|
||||
footer.appendChild(confirmBtn);
|
||||
},
|
||||
|
||||
// The row of buttons that never scrolls away.
|
||||
footer() {
|
||||
return document.getElementById("potWizardFooter");
|
||||
},
|
||||
|
||||
recordRound(result) {
|
||||
@@ -331,12 +340,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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const Render = {
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderHeaderActions();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
@@ -62,6 +63,42 @@ const Render = {
|
||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||
},
|
||||
|
||||
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
||||
// is nothing to take back, and afterwards the header would be offering to unwind a
|
||||
// hand that has already been paid — so the button comes and goes with the hand
|
||||
// rather than sitting there all night waiting to be pressed by accident.
|
||||
renderHeaderActions() {
|
||||
const misdeal = document.getElementById("misdealBtn");
|
||||
if (!misdeal) return;
|
||||
misdeal.hidden = !this.state.bettingOpen;
|
||||
|
||||
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
|
||||
// Pot has no pot — but Resolve Pot is the accent green, so the loudest control
|
||||
// on a new dealer's first screen was the one that could do the least. It is
|
||||
// now visibly not-yet, which puts the only green left on the felt where the
|
||||
// actual next step is.
|
||||
//
|
||||
// Disabled rather than hidden: hiding them would move the header the moment
|
||||
// the first player is named, and a control that vanishes teaches nothing about
|
||||
// what it was for.
|
||||
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
|
||||
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
|
||||
.forEach(([id, why]) => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
// Next Hand explains itself in the markup, and that sentence has to
|
||||
// survive being borrowed for the disabled reason and handed back.
|
||||
if (button.dataset.titleWhenUsable === undefined) {
|
||||
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
|
||||
}
|
||||
button.disabled = nothingToRunYet;
|
||||
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
|
||||
if (title) button.setAttribute("title", title);
|
||||
else button.removeAttribute("title");
|
||||
});
|
||||
},
|
||||
|
||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||
// generated from a touch screen, so on a phone the last clause of the hint below
|
||||
// described something impossible — on the device the rest of the sentence was
|
||||
@@ -77,6 +114,19 @@ const Render = {
|
||||
hint.textContent = this.canDragSeats()
|
||||
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
|
||||
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
|
||||
|
||||
// But only on the first hand. Two lines of instructions under the felt are worth
|
||||
// having on a table nobody has dealt at yet, and worth nothing on the fifth hand
|
||||
// of the night — by then the dealer either knows the gestures or is not going to
|
||||
// learn them from a caption they have scrolled past forty times.
|
||||
//
|
||||
// Keyed to the hand number rather than to whether a hand is currently out, on
|
||||
// purpose. `bettingOpen` was the obvious test and it is the wrong one: the hint
|
||||
// would vanish at every deal and come back at every payout, so a two-line block
|
||||
// under the felt would appear and disappear twice a hand, shunting the rulings
|
||||
// link and the whole panel stack up and down all night. This way it goes once,
|
||||
// at the first deal of the session, and stays gone.
|
||||
hint.hidden = (Number(this.state.handNumber) || 1) > 1 || !!this.state.bettingOpen;
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
@@ -95,8 +145,67 @@ const Render = {
|
||||
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
|
||||
seatPosition(index, count, aspect = this.feltAspect()) {
|
||||
return this.positionOnRing(index, count, 44, 42, aspect);
|
||||
// The widest the ring has ever been allowed to be. These are the numbers the ring
|
||||
// used to be pinned at, kept as a ceiling so nothing gets looser than it was.
|
||||
RING_MAX_RX: 44,
|
||||
RING_MAX_RY: 42,
|
||||
// A little daylight between the outermost puck and the rail.
|
||||
SEAT_EDGE_MARGIN_PX: 6,
|
||||
// Below this the ring has collapsed into the pot, which is worse than an overhang.
|
||||
RING_MIN_R: 18,
|
||||
|
||||
// How far out the ring can sit and still keep a whole seat on the table.
|
||||
//
|
||||
// It used to be 44% by 42%, flat, at every size — two numbers picked against a
|
||||
// phone. A fixed pair can only be right for one ratio of puck to felt, and the
|
||||
// felt's ratio changes with every viewport: measured, at 1280x900 nine-handed,
|
||||
// five of nine pucks finished past the outer edge of the table, the worst by 49px,
|
||||
// and 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 is a different amount of wrong on every screen, which is what makes
|
||||
// it look like a mistake rather than a choice.
|
||||
//
|
||||
// So it is derived rather than declared: the widest ring whose outermost seat still
|
||||
// finishes inside the table. Falls back to the old pair whenever there is nothing to
|
||||
// measure, which is every call from the unit tests and the first render before the
|
||||
// felt has been laid out.
|
||||
seatRingRadii() {
|
||||
const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY };
|
||||
if (typeof document === "undefined") return fallback;
|
||||
|
||||
const box = this.ringBox();
|
||||
const table = document.getElementById("pokerTable");
|
||||
const puck = document.querySelector(".seat");
|
||||
if (!box || !box.width || !box.height || !table || !puck) return fallback;
|
||||
|
||||
const tableBox = table.getBoundingClientRect();
|
||||
const puckBox = puck.getBoundingClientRect();
|
||||
if (!tableBox.width || !puckBox.width) return fallback;
|
||||
|
||||
// Worked in absolute pixels rather than from half-widths, because the seat
|
||||
// layer is *not* centred on the table: its insets differ top from bottom (the
|
||||
// landscape rule sets a floor on the top edge and a ceiling on the bottom), so
|
||||
// assuming symmetry left the shorter side still hanging over. Measured that
|
||||
// way, a phone still had 12px of puck past the rail after the first attempt.
|
||||
//
|
||||
// The reach that matters is to the nearer of the two edges.
|
||||
const reach = (layerCentre, nearEdge, farEdge, puckSide, boxSide) => {
|
||||
const room = Math.min(layerCentre - nearEdge, farEdge - layerCentre);
|
||||
return ((room - puckSide / 2 - this.SEAT_EDGE_MARGIN_PX) / boxSide) * 100;
|
||||
};
|
||||
|
||||
return {
|
||||
rx: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RX,
|
||||
reach(box.left + box.width / 2, tableBox.left, tableBox.right,
|
||||
puckBox.width, box.width))),
|
||||
ry: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RY,
|
||||
reach(box.top + box.height / 2, tableBox.top, tableBox.bottom,
|
||||
puckBox.height, box.height))),
|
||||
};
|
||||
},
|
||||
|
||||
seatPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
|
||||
return this.positionOnRing(index, count, radii.rx, radii.ry, aspect);
|
||||
},
|
||||
|
||||
// How many times wider than tall the felt actually is, which is the one thing the
|
||||
@@ -129,8 +238,11 @@ const Render = {
|
||||
// The two answers are within half a degree of each other, so this is not a fix for
|
||||
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||
// and it saves the second walk.
|
||||
betPosition(index, count, aspect = this.feltAspect()) {
|
||||
const seat = this.seatPosition(index, count, aspect);
|
||||
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
|
||||
const seat = this.seatPosition(index, count, aspect, radii);
|
||||
// Fractions of the seat's own reach, not of the felt — so when the ring pulls in
|
||||
// to keep the pucks on the table, the chips come with it and stay on the same
|
||||
// spoke, the same proportion of the way to the pot.
|
||||
return {
|
||||
left: 50 + (seat.left - 50) * (27 / 44),
|
||||
top: 50 + (seat.top - 50) * (25 / 42),
|
||||
@@ -229,10 +341,15 @@ const Render = {
|
||||
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
|
||||
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
|
||||
// and two seats a long walk apart are stacked one above the other.
|
||||
closestSeatGapPx(count, box = this.ringBox()) {
|
||||
closestSeatGapPx(count, box = this.ringBox(), radii = this.seatRingRadii()) {
|
||||
if (count < 2 || !box || !box.width || !box.height) return Infinity;
|
||||
|
||||
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
|
||||
// The ring the seats are actually drawn on, not the widest one they could be.
|
||||
// This asked about a flat 44/42 while the renderer had pulled the ring in to
|
||||
// keep the pucks on the table, so it measured gaps that did not exist: in
|
||||
// landscape it reported plenty of room and the seats were overlapping each
|
||||
// other's centres, which the audit caught as taps landing on the wrong player.
|
||||
const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).map(seat => ({
|
||||
x: (seat.left / 100) * box.width,
|
||||
y: (seat.top / 100) * box.height,
|
||||
}));
|
||||
@@ -250,11 +367,94 @@ const Render = {
|
||||
// Called on every render and again whenever the felt is resized — the action bar
|
||||
// growing mid-hand shortens the table, and the seats have to answer that without
|
||||
// waiting for the next render to come along.
|
||||
updateSeatDensity() {
|
||||
// Which layout the felt gets, and how wide its ring is.
|
||||
//
|
||||
// These two answers depend on each other, which is what made the first attempt at
|
||||
// this wobble: the ring's radius is limited by the size of a puck, the puck's size
|
||||
// is set by the `seatsTight` class, and that class was being decided from the gaps
|
||||
// between seats — measured at a radius that hadn't been worked out yet. Fixing the
|
||||
// gap measurement to use the real radius then made a nine-handed phone overhang
|
||||
// that had been fine, because now the two were chasing each other.
|
||||
//
|
||||
// So it is settled by trying the candidates in order of preference rather than by
|
||||
// solving it in one pass: the roomy layout if everything fits, the tight one if it
|
||||
// doesn't, and if neither fits, the tight pucks on a ring wide enough that seats
|
||||
// don't overlap — because two seats fighting over a tap is a worse failure than a
|
||||
// puck sitting slightly over the rail. A dealer can see an overhang; they cannot
|
||||
// see that the seat they meant to press wasn't the one that answered.
|
||||
// How much space two neighbouring seats need between their centres.
|
||||
//
|
||||
// This was a flat 84px, which is the figure a 96px-wide puck was judged against —
|
||||
// seats allowed to sit a little closer than their own width, since the pucks are
|
||||
// rounded and a small overlap at the corners reads fine. Asking the *tight* layout
|
||||
// to clear the same 84px is asking a 68px puck to keep a 96px puck's distance: it
|
||||
// could never pass, so every landscape table fell through to the fallback and took
|
||||
// an overhang it didn't need. The ratio is what carries over, not the number.
|
||||
SEAT_ROOM_RATIO: 84 / 96,
|
||||
|
||||
seatRoomNeededPx() {
|
||||
if (typeof document === "undefined") return this.SEAT_ROOM_PX;
|
||||
const puck = document.querySelector(".seat");
|
||||
if (!puck) return this.SEAT_ROOM_PX;
|
||||
const width = puck.getBoundingClientRect().width;
|
||||
return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX;
|
||||
},
|
||||
|
||||
resolveSeatLayout(count) {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
if (!layer || !this.state) return;
|
||||
layer.classList.toggle("seatsTight",
|
||||
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
|
||||
if (!layer) return { radii: this.seatRingRadii() };
|
||||
|
||||
for (const tight of [false, true]) {
|
||||
layer.classList.toggle("seatsTight", tight);
|
||||
const radii = this.seatRingRadii();
|
||||
const roomy = this.closestSeatGapPx(count, this.ringBox(), radii) >= this.seatRoomNeededPx();
|
||||
if (roomy && !this.tooTallForTheTable()) return { radii };
|
||||
}
|
||||
|
||||
// Neither fits — a phone held sideways gives the felt about 140px of height and
|
||||
// there is no ring that puts a dozen readable pucks inside that. Keep the small
|
||||
// pucks and widen the ring only as far as it takes to stop the seats colliding,
|
||||
// rather than all the way back out: going straight to the full width bought an
|
||||
// overhang of 14px where 4 would have done.
|
||||
layer.classList.add("seatsTight");
|
||||
const contained = this.seatRingRadii();
|
||||
const box = this.ringBox();
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
const t = step / 8;
|
||||
const radii = {
|
||||
rx: contained.rx + (this.RING_MAX_RX - contained.rx) * t,
|
||||
ry: contained.ry + (this.RING_MAX_RY - contained.ry) * t,
|
||||
};
|
||||
if (this.closestSeatGapPx(count, box, radii) >= this.seatRoomNeededPx()) return { radii };
|
||||
}
|
||||
return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
|
||||
},
|
||||
|
||||
updateSeatDensity() {
|
||||
if (!this.state) return;
|
||||
this.resolveSeatLayout(this.state.seatCount);
|
||||
},
|
||||
|
||||
// Room is not only sideways.
|
||||
//
|
||||
// "Tight" used to mean one thing: seats close enough together to bump into each
|
||||
// other. On a phone held sideways the felt is 141px tall and the pucks had grown to
|
||||
// 88 — two-line names — so six seats had all the horizontal room in the world and
|
||||
// could not fit top to bottom whatever the ring did. The gap check said everything
|
||||
// was fine and 20px of every seat hung over the rail.
|
||||
//
|
||||
// A puck more than half the table's height is the honest test: two seats on
|
||||
// opposite sides of the ring would then have to overlap the pot to fit at all, so
|
||||
// the pucks are what has to give.
|
||||
tooTallForTheTable() {
|
||||
if (typeof document === "undefined") return false;
|
||||
const table = document.getElementById("pokerTable");
|
||||
const puck = document.querySelector(".seat");
|
||||
if (!table || !puck) return false;
|
||||
const tableBox = table.getBoundingClientRect();
|
||||
const puckBox = puck.getBoundingClientRect();
|
||||
if (!tableBox.height || !puckBox.height) return false;
|
||||
return puckBox.height > tableBox.height * 0.5;
|
||||
},
|
||||
|
||||
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
||||
@@ -335,18 +535,23 @@ const Render = {
|
||||
layer.innerHTML = "";
|
||||
|
||||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||||
// Measured once for the whole ring. Asked per seat, this reads the layout back
|
||||
// nine times in a loop that is also writing to it, which is the read-after-write
|
||||
// pattern that makes the browser recompute layout on every iteration.
|
||||
const aspect = this.feltAspect();
|
||||
const radii = this.seatRingRadii();
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const bet = Betting.committed(player);
|
||||
if (bet <= 0) return;
|
||||
|
||||
const chip = this.makeBetChip(bet);
|
||||
const pos = this.betPosition(index, this.state.seatCount);
|
||||
const pos = this.betPosition(index, this.state.seatCount, aspect, radii);
|
||||
|
||||
if (anchorToSeat) {
|
||||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (!seat) return;
|
||||
const seatPos = this.seatPosition(index, this.state.seatCount);
|
||||
const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||
seat.appendChild(chip);
|
||||
return;
|
||||
@@ -362,11 +567,59 @@ const Render = {
|
||||
return HoldemState.displayName(player, seatIndex);
|
||||
},
|
||||
|
||||
// Builds the pucks once and keeps them. Everything below this line updates the
|
||||
// nodes that are already on the felt.
|
||||
//
|
||||
// A seat's node belongs to its index for as long as the table is that size, so the
|
||||
// listeners can close over the index the way they always did. Only a change in the
|
||||
// number of chairs throws the row away.
|
||||
buildSeatNodes(layer, count) {
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const nodes = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.seatIndex = String(index);
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
node.querySelector(".dealerChip").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
this.hooks.onGiveButton(index);
|
||||
});
|
||||
|
||||
this.wireSeatGestures(node, index);
|
||||
this.wireDragAndDrop(node, index);
|
||||
nodes.push(node);
|
||||
}
|
||||
layer.replaceChildren(...nodes);
|
||||
},
|
||||
|
||||
// Drawing the seats used to mean throwing all of them away and building them again:
|
||||
// `innerHTML = ""`, then a template clone, three sets of event listeners and a
|
||||
// dozen attributes per chair, on every render. Nine seats is ninety nodes, and
|
||||
// renderAll() runs after every single tap a dealer makes.
|
||||
//
|
||||
// Measured on a nine-handed table: renderAll was 12.4ms, of which this function was
|
||||
// 7.3. That is most of a frame gone before the browser has drawn anything, on a
|
||||
// desktop — a phone is several times slower, which is where the tool actually runs
|
||||
// and where a dealer's tap felt like it landed a moment late.
|
||||
//
|
||||
// Nothing about what gets drawn changes here. The pucks are built once and then
|
||||
// updated in place, which is what the rest of this function was already doing to
|
||||
// their contents anyway.
|
||||
renderSeats() {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
layer.innerHTML = "";
|
||||
this.updateSeatDensity();
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const count = this.state.seats.length;
|
||||
if (layer.childElementCount !== count) this.buildSeatNodes(layer, count);
|
||||
// Settles the tight class and the ring radius together, and hands back the
|
||||
// radius it settled on — so the loop below doesn't have to ask again. Both of
|
||||
// these read the layout back, so they are taken once, here, before the loop
|
||||
// starts writing to it: asked per seat they would force the browser to
|
||||
// recompute the layout on every iteration.
|
||||
const { radii } = this.resolveSeatLayout(count);
|
||||
const aspect = this.feltAspect();
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
// A button on an empty chair normally means the table has drifted and wants
|
||||
// fixing. Once it's the dead button it means the opposite — it's exactly where
|
||||
@@ -376,11 +629,11 @@ const Render = {
|
||||
!this.state.seats[dealerIndex].inHand;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
const pos = this.seatPosition(index, this.state.seatCount);
|
||||
const node = layer.children[index];
|
||||
if (!node) return;
|
||||
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
node.style.left = `${pos.left}%`;
|
||||
node.style.top = `${pos.top}%`;
|
||||
node.dataset.seatIndex = String(index);
|
||||
|
||||
// Which side of the seat the pot is on, which is the side its bet chip
|
||||
// arrives from — the chip sits on the same spoke, closer in. For a seat on
|
||||
@@ -392,11 +645,18 @@ const Render = {
|
||||
// telling the dealer who the big blind is was simply gone.
|
||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
||||
|
||||
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||
// The chair number is set once, in buildSeatNodes — a seat's index is the
|
||||
// one thing about it that cannot change while the node exists.
|
||||
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
// Marked on the seat as well as the chip, so the puck's own text can keep
|
||||
// out from under it. A name is centred across the whole puck and the chip
|
||||
// sits in the corner on a layer above: measured, "Christopher" on the
|
||||
// button lost 20px of its 64px box to the chip and showed no ellipsis,
|
||||
// because the text believed it had the room it was painted out of.
|
||||
node.classList.toggle("seatHasButton", hasButton);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Every one of these announced itself as "D", so a screen reader heard a row
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
@@ -410,13 +670,6 @@ const Render = {
|
||||
: `Give the dealer button to ${who}`;
|
||||
dealerChip.setAttribute("aria-label", chipLabel);
|
||||
dealerChip.title = chipLabel;
|
||||
// Tapping the puck is the same act as "Give the button" in the seat menu,
|
||||
// so it goes through the same hook — otherwise this one path would move
|
||||
// the button without moving the blinds that follow it.
|
||||
dealerChip.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
this.hooks.onGiveButton(index);
|
||||
});
|
||||
|
||||
const sbBadge = node.querySelector(".sbBadge");
|
||||
const bbBadge = node.querySelector(".bbBadge");
|
||||
@@ -441,11 +694,6 @@ const Render = {
|
||||
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
this.wireSeatGestures(node, index);
|
||||
this.wireDragAndDrop(node, index);
|
||||
|
||||
layer.appendChild(node);
|
||||
});
|
||||
|
||||
this.renderBets();
|
||||
@@ -487,8 +735,24 @@ const Render = {
|
||||
updatePuckContent(node, player, index) {
|
||||
const empty = this.isEmptySeat(player);
|
||||
const nameEl = node.querySelector(".seatPuckName");
|
||||
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
||||
// An open chair used to say so, in capitals, on every seat that had nobody in
|
||||
// it — which on a table a dealer has just opened is the word EMPTY ten times
|
||||
// across the felt, and it is the first thing they ever see this tool do.
|
||||
//
|
||||
// The puck already says it without a word: dashed instead of solid, faded, no
|
||||
// stack under it. That reads at a glance, which is how a dealer looks at the
|
||||
// felt, and it reads the same on the one open chair at a full table — which is
|
||||
// the case the label was actually for. Repeating a word ten times is not
|
||||
// emphasis, it's noise, and it was drowning the seat numbers underneath it.
|
||||
nameEl.textContent = empty ? "" : (player.name || "").trim();
|
||||
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||||
// Dashed-and-faded is a thing you can see, not a thing you can hear, so the
|
||||
// word an open chair no longer prints goes here instead. Anyone reading the
|
||||
// felt by ear gets more than they had: the seat number too, which the sighted
|
||||
// version has always shown in the corner.
|
||||
node.setAttribute("aria-label", empty
|
||||
? `Seat ${index + 1}, empty`
|
||||
: `Seat ${index + 1}, ${(player.name || "").trim() || "unnamed"}`);
|
||||
|
||||
const cardsEl = node.querySelector(".seatPuckCards");
|
||||
cardsEl.innerHTML = "";
|
||||
@@ -536,8 +800,21 @@ const Render = {
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const length = (el.textContent || "").length;
|
||||
const formatted = el.textContent || "";
|
||||
// The separators go before the type size does. "$1,234,567" is ten characters,
|
||||
// which used to drop the puck to 7px — and 7px is not a size anybody reads a
|
||||
// stack off a felt at, so the shrink-rather-than-abbreviate rule above was
|
||||
// buying precision the dealer then couldn't see. Without its commas the same
|
||||
// number is eight characters, and eight fits at 10px with every digit intact.
|
||||
// Grouping is a reading aid for a column of figures; this is one number on a
|
||||
// 64px puck, and the aid costs more than it gives.
|
||||
const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted;
|
||||
if (text !== formatted) el.textContent = text;
|
||||
|
||||
const length = text.length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
// Only a stack of $100,000,000 or more still lands here. It is an overflow
|
||||
// guard rather than a size anybody is expected to read.
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
},
|
||||
|
||||
@@ -737,6 +1014,12 @@ const Render = {
|
||||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
||||
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
// And the header answers the same question the bar does — Resolve Pot and Next
|
||||
// Hand are switched off until somebody is at the table, and typing a name is
|
||||
// exactly the keystroke that changes that. Caught by walking the first-run flow:
|
||||
// after naming a player both stayed greyed out, still captioned "nobody is at
|
||||
// the table yet", because this path deliberately doesn't redraw everything.
|
||||
this.renderHeaderActions();
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
@@ -925,14 +1208,20 @@ const Render = {
|
||||
const list = document.getElementById("colourUpList");
|
||||
if (!host || !list) return;
|
||||
|
||||
// What each player is holding, asked the same way the felt asks it — which
|
||||
// includes the table's starting stack standing in for a seat that has never
|
||||
// been given one. Reading `player.stack` here meant that a table opened the
|
||||
// ordinary way, with "everyone starts with 200", showed an empty colour-up
|
||||
// panel: nobody to count out, at the table where every single stack is a
|
||||
// round number made of the largest chips there are.
|
||||
const legend = this.state.chipLegend || [];
|
||||
const rows = this.state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
|
||||
.map(({ player, index }) => ({
|
||||
.map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) }))
|
||||
.filter(({ held }) => held > 0)
|
||||
.map(({ player, index, held }) => ({
|
||||
name: HoldemState.displayName(player, index),
|
||||
stack: Betting.round(Number(player.stack) || 0),
|
||||
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
|
||||
stack: held,
|
||||
chips: Money.formatChipBreakdown(held, legend),
|
||||
}))
|
||||
.filter(row => row.chips);
|
||||
|
||||
@@ -1012,7 +1301,14 @@ const Render = {
|
||||
const template = document.getElementById("standingsRowTemplate");
|
||||
standings.forEach(row => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".standingsName").textContent = row.name;
|
||||
// Somebody who has gone home is still on the list — they played, and the
|
||||
// box took their cash — but the number beside them is money already handed
|
||||
// over, not chips waiting to be counted. Marked, or a dealer settling up at
|
||||
// 2am pays them twice.
|
||||
node.classList.toggle("standingsGone", !!row.cashedOut);
|
||||
node.querySelector(".standingsName").textContent = row.cashedOut
|
||||
? `${row.name} · paid out`
|
||||
: row.name;
|
||||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||||
|
||||
@@ -1040,9 +1336,63 @@ const Render = {
|
||||
|
||||
this.renderBankReconciliation();
|
||||
this.renderSettleUp(standings);
|
||||
this.renderWaitingList();
|
||||
this.renderStorageWarning();
|
||||
},
|
||||
|
||||
// Who is standing behind the chairs, in the order they turned up. Each one carries
|
||||
// the two things a dealer does with them: sit them down, or take them off the list
|
||||
// because they've gone to the bar.
|
||||
renderWaitingList() {
|
||||
const list = document.getElementById("waitingList");
|
||||
const hint = document.getElementById("waitingEmptyHint");
|
||||
if (!list) return;
|
||||
|
||||
const waiting = this.state.waitingList || [];
|
||||
const open = HoldemState.openSeats(this.state);
|
||||
list.innerHTML = "";
|
||||
hint.hidden = waiting.length > 0;
|
||||
|
||||
waiting.forEach((entry, position) => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "waitingRow";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "waitingName";
|
||||
name.textContent = entry.name;
|
||||
row.appendChild(name);
|
||||
|
||||
// Only the person at the front is offered a seat, because a list nobody
|
||||
// keeps in order is just a pile of names — and jumping the queue is the
|
||||
// thing the dealer gets shouted at for.
|
||||
if (position === 0 && open.length > 0) {
|
||||
const seat = document.createElement("button");
|
||||
seat.type = "button";
|
||||
seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
|
||||
seat.textContent = `Seat ${open[0] + 1}`;
|
||||
seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
|
||||
seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
|
||||
row.appendChild(seat);
|
||||
} else if (position === 0) {
|
||||
const full = document.createElement("span");
|
||||
full.className = "waitingNote";
|
||||
full.textContent = "table full";
|
||||
row.appendChild(full);
|
||||
}
|
||||
|
||||
const drop = document.createElement("button");
|
||||
drop.type = "button";
|
||||
drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
|
||||
drop.textContent = "✕";
|
||||
drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
|
||||
drop.title = `Take ${entry.name} off the list`;
|
||||
drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
|
||||
row.appendChild(drop);
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
// A toast is a moment; this table not being saved is a fact about the rest of the
|
||||
// night. It sits at the top of the panel a dealer opens to check the money, and it
|
||||
// stays there, because the raise pad dismisses toasts and the one time this warning
|
||||
@@ -1169,25 +1519,66 @@ const Render = {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips handed back to people who have gone home. The third way money leaves
|
||||
// this table on purpose, and the one a dealer is asked about most: "where did
|
||||
// Ann's four hundred go?"
|
||||
const paidRow = document.getElementById("bankPaidOutRow");
|
||||
if (paidRow) {
|
||||
paidRow.hidden = !(bank.paidOut > 0);
|
||||
if (bank.paidOut > 0) {
|
||||
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
// What has left the table on purpose, whichever way it went.
|
||||
const accountedFor = [
|
||||
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
|
||||
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
|
||||
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
|
||||
].filter(Boolean);
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = accountedFor.length > 0
|
||||
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
|
||||
: "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
|
||||
// dealer's next move is to go to a particular seat, and the tool knows
|
||||
// which. The commonest cause by far is the table's own starting stack:
|
||||
// seats holding chips that were never bought, because "everyone starts
|
||||
// with 200" is how a night opens.
|
||||
const owing = HoldemState.seatsOwingCash(this.state);
|
||||
const names = owing.map(row => row.name);
|
||||
const who = names.length === 1
|
||||
? names[0]
|
||||
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
verdict.textContent = owing.length > 0
|
||||
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
|
||||
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
|
||||
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
} else {
|
||||
verdict.textContent =
|
||||
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
||||
}
|
||||
verdict.classList.toggle("bankBalanced", bank.balanced);
|
||||
verdict.classList.toggle("bankOff", !bank.balanced);
|
||||
|
||||
// One tap for the seats that are only holding the table's starting stack —
|
||||
// which is the dealer's own instruction, said once in the setup panel, rather
|
||||
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
|
||||
// sentence above and left to them, because only they know what it was.
|
||||
const record = document.getElementById("bankRecordBuyInsBtn");
|
||||
if (record) {
|
||||
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
|
||||
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||
record.hidden = starting.length === 0;
|
||||
if (starting.length > 0) {
|
||||
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
|
||||
record.title = starting.length === 1
|
||||
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
|
||||
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
@@ -47,6 +47,27 @@ const SeatMenu = {
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
// An empty chair with people waiting: the dealer taps the chair somebody is
|
||||
// about to sit in, which is what their hand is doing anyway. Only the front of
|
||||
// the queue is offered, for the same reason the panel only offers them — a list
|
||||
// that isn't kept in order is just a pile of names.
|
||||
const waiting = (this.state.waitingList || [])[0];
|
||||
const emptyChair = !HoldemState.seatInPlay(player);
|
||||
if (waiting && emptyChair) {
|
||||
actions.push({
|
||||
label: `Seat ${waiting.name}`,
|
||||
className: "menuAccent",
|
||||
run: () => this.hooks.seatWaiting(waiting.id, index),
|
||||
});
|
||||
}
|
||||
|
||||
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||
// that has somebody in it, since an empty chair has nobody to pay.
|
||||
if (HoldemState.seatInPlay(player)) {
|
||||
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||
}
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
@@ -110,6 +110,15 @@ const HoldemState = {
|
||||
// What it has taken so far tonight. A session total, not a per-hand one:
|
||||
// it is the number the box is checked against at the end.
|
||||
rakeTaken: 0,
|
||||
// People who have taken their chips and gone. Kept for the night rather
|
||||
// than forgotten with the seat: the box took their cash and paid it back
|
||||
// out, and both halves have to stay on the record or the bank check reads
|
||||
// as a shortfall for the rest of the evening.
|
||||
cashedOut: [],
|
||||
// People standing behind the chairs. A dealer at a full table keeps this
|
||||
// list on a napkin; keeping it here means the tool can offer the next seat
|
||||
// that opens to whoever has been waiting longest.
|
||||
waitingList: [],
|
||||
// Which seats actually put a straddle out when the hand was dealt, so the
|
||||
// felt can badge them. Read off the deal rather than worked out again
|
||||
// later, the same way the posted blinds are.
|
||||
@@ -228,6 +237,27 @@ const HoldemState = {
|
||||
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
||||
};
|
||||
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
||||
parsed.waitingList = Array.isArray(parsed.waitingList)
|
||||
? parsed.waitingList
|
||||
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
|
||||
.filter(row => row && String(row.name || "").trim())
|
||||
.map(row => ({
|
||||
id: row.id || this.makeId("waiting"),
|
||||
name: String(row.name).trim().slice(0, 24),
|
||||
addedAt: Number(row.addedAt) || 0,
|
||||
}))
|
||||
: [];
|
||||
parsed.cashedOut = Array.isArray(parsed.cashedOut)
|
||||
? parsed.cashedOut
|
||||
.filter(row => row && row.id)
|
||||
.map(row => ({
|
||||
id: row.id,
|
||||
name: String(row.name || "").slice(0, 40),
|
||||
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
|
||||
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
|
||||
at: Number(row.at) || 0,
|
||||
}))
|
||||
: [];
|
||||
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
@@ -753,6 +783,15 @@ const HoldemState = {
|
||||
// Cash bought in and chips currently in front don't come from the hand log at
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
// And for anyone who has gone home, off the record their cash-out left behind.
|
||||
// Without this they read as a player who bought in for nothing and is holding
|
||||
// nothing, in the very list the dealer settles the night from.
|
||||
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
|
||||
|
||||
goneById.forEach((row, id) => {
|
||||
if (totals.has(id)) return;
|
||||
totals.set(id, { id, name: row.name, in: 0, won: 0 });
|
||||
});
|
||||
|
||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
@@ -764,7 +803,7 @@ const HoldemState = {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
this.chipsHeldBy(state, player) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
@@ -774,8 +813,16 @@ const HoldemState = {
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||
const gone = goneById.get(t.id);
|
||||
const boughtIn = seat
|
||||
? Money.round(Number(seat.buyIns) || 0)
|
||||
: (gone ? gone.buyIns : 0);
|
||||
// What a departed player is "holding" is what the box handed them on
|
||||
// the way out. It is no longer on the table, so it is marked as paid
|
||||
// rather than counted again at the end of the night.
|
||||
const chipsNow = seat
|
||||
? this.chipsHeldBy(state, seat)
|
||||
: (gone ? gone.chips : 0);
|
||||
// Chips already pushed into the hand in progress are still this
|
||||
// player's money until it's won. Leaving them out makes the cash
|
||||
// position disagree with the hand log for as long as a hand is open —
|
||||
@@ -806,6 +853,10 @@ const HoldemState = {
|
||||
// a $340 stack read as "$140" at the exact moment cash changed
|
||||
// hands.
|
||||
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
||||
// Set once they have taken their chips and left, so the panel and
|
||||
// the export can say so rather than showing a player still sitting
|
||||
// there with a stack.
|
||||
cashedOut: !!gone && !seat,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
@@ -838,6 +889,136 @@ const HoldemState = {
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
// ---- The people standing behind the chairs ----
|
||||
//
|
||||
// A full home game always has a couple of them, and the list lives on a napkin or
|
||||
// in the dealer's head — which is where the argument comes from an hour later about
|
||||
// who said they were next. Kept in order of arrival, because that is the only
|
||||
// ordering anybody has ever agreed to.
|
||||
|
||||
addToWaitingList(state, name) {
|
||||
const trimmed = String(name || "").trim().slice(0, 24);
|
||||
if (!trimmed) return null;
|
||||
if (!Array.isArray(state.waitingList)) state.waitingList = [];
|
||||
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
|
||||
state.waitingList.push(entry);
|
||||
return entry;
|
||||
},
|
||||
|
||||
removeFromWaitingList(state, id) {
|
||||
if (!Array.isArray(state.waitingList)) return null;
|
||||
const index = state.waitingList.findIndex(row => row.id === id);
|
||||
if (index < 0) return null;
|
||||
return state.waitingList.splice(index, 1)[0];
|
||||
},
|
||||
|
||||
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
|
||||
// the same question the deal prompt answers when it counts the players.
|
||||
openSeats(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => !this.seatInPlay(player))
|
||||
.map(({ index }) => index);
|
||||
},
|
||||
|
||||
// Sit the next person down. Their name goes on the chair and nothing else does:
|
||||
// they haven't bought in yet, and guessing a stack for them would put chips on the
|
||||
// table that nobody has paid for — which is the one thing the bank check exists to
|
||||
// catch.
|
||||
seatFromWaitingList(state, id, index) {
|
||||
const seat = state.seats[index];
|
||||
if (!seat) return { ok: false, reason: "There's no such seat." };
|
||||
if (this.seatInPlay(seat)) {
|
||||
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
|
||||
}
|
||||
const entry = this.removeFromWaitingList(state, id);
|
||||
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
|
||||
|
||||
seat.name = entry.name;
|
||||
// Not dealt in and holding nothing: the next thing that happens is the dealer
|
||||
// taking their money, and Add chips is what says so.
|
||||
seat.inHand = false;
|
||||
return { ok: true, name: entry.name, index };
|
||||
},
|
||||
|
||||
// ---- Somebody leaving with their chips ----
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool
|
||||
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
|
||||
// hand, and then the bank check — which adds up cash taken against chips on the
|
||||
// table — declared the departing player's whole stack missing: "someone has paid
|
||||
// for chips they haven't been given", for the rest of the night. Wiping their
|
||||
// buy-in as well silenced it, at the price of losing their night from the standings
|
||||
// and the file the dealer hands over.
|
||||
//
|
||||
// Cashing out is two facts, and both have to survive the empty chair: the box took
|
||||
// their cash, and the box paid their chips back out.
|
||||
|
||||
// Can this seat be cashed out right now?
|
||||
canCashOut(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return { ok: false, reason: "There's nobody in that seat." };
|
||||
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
|
||||
if (inPot > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
// Paying someone out with money still in the middle would either take
|
||||
// chips out of a pot other people can win, or hand them chips twice.
|
||||
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// What the dealer counts out, and what the seat's night comes to.
|
||||
cashOutPreview(state, index) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return null;
|
||||
// Betting owns the answer to "what is this seat holding" — including the rule
|
||||
// that a seat which has never been given a stack is holding the table's
|
||||
// starting one. Asking it rather than working it out again here: the first
|
||||
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
|
||||
// to pay a player nothing.
|
||||
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
|
||||
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips,
|
||||
boughtIn,
|
||||
// Their result for the night, in the direction a banker says it: positive
|
||||
// means the box is paying out more than it took from them.
|
||||
net: Money.round(chips - boughtIn),
|
||||
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
|
||||
};
|
||||
},
|
||||
|
||||
// Hand them their chips and free the chair, keeping both halves on the record.
|
||||
cashOut(state, index) {
|
||||
const allowed = this.canCashOut(state, index);
|
||||
if (!allowed.ok) return allowed;
|
||||
|
||||
const preview = this.cashOutPreview(state, index);
|
||||
const leavingId = state.seats[index].id;
|
||||
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
|
||||
state.cashedOut.push({
|
||||
id: leavingId,
|
||||
name: preview.name,
|
||||
buyIns: preview.boughtIn,
|
||||
chips: preview.chips,
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
// The chair opens for the next player. A fresh id, because the person who had
|
||||
// it has gone and their night is now filed under the old one — reusing it would
|
||||
// merge two people's results the moment somebody new sits down.
|
||||
const seat = this.makePlayer();
|
||||
state.seats[index] = seat;
|
||||
// Anyone owed a hand by this seat is no longer owed one.
|
||||
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
|
||||
return { ok: true, ...preview };
|
||||
},
|
||||
|
||||
// ---- The house's cut ----
|
||||
//
|
||||
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
||||
@@ -899,12 +1080,82 @@ const HoldemState = {
|
||||
};
|
||||
},
|
||||
|
||||
// Is anybody actually in this chair? A seat with a name on it, chips in front of
|
||||
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
|
||||
// matters the moment the table's starting stack starts standing in for a seat that
|
||||
// has never been given one — every empty chair would otherwise look like somebody
|
||||
// holding two hundred dollars.
|
||||
seatInPlay(player) {
|
||||
if (!player) return false;
|
||||
return !!(player.name || "").trim() ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
player.inHand === true;
|
||||
},
|
||||
|
||||
// What a seat is holding, the way the felt and the betting see it — which includes
|
||||
// the table's starting stack standing in for a seat that has never been given one.
|
||||
//
|
||||
// This is the number the money side of the tool was missing. "Starting stack" is
|
||||
// offered in the setup panel as the ordinary way to open a table, and everything a
|
||||
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
|
||||
// check and the standings read `seat.stack` directly instead, which is null for
|
||||
// exactly those seats: measured on a three-handed table with a $200 starting stack
|
||||
// and nobody's own stack set, the felt played for $600 while the bank check said
|
||||
// "no chips in play, balanced" and the standings listed nobody at all.
|
||||
chipsHeldBy(state, player) {
|
||||
if (!this.seatInPlay(player)) return 0;
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// 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
|
||||
// stack has never been given chips of its own — the dealer set "everyone starts
|
||||
// with 200" and sat people down, which is the ordinary way to open a table and the
|
||||
// reason this whole gap existed. A seat with its own stack and no buy-in is a
|
||||
// rebuy somebody forgot to write down, and only the dealer knows what it was.
|
||||
seatsOwingCash(state) {
|
||||
return state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
|
||||
!((Number(player.buyIns) || 0) > 0))
|
||||
.map(({ player, index }) => ({
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
chips: this.chipsHeldBy(state, player),
|
||||
// True when they are holding the table's starting stack rather than one
|
||||
// the dealer typed for them.
|
||||
fromStartingStack: player.stack === null || player.stack === undefined ||
|
||||
String(player.stack).trim() === "",
|
||||
}));
|
||||
},
|
||||
|
||||
// Write down the cash for the seats that are holding the table's starting stack.
|
||||
// Only those: what somebody else's unrecorded rebuy was is a question only the
|
||||
// dealer can answer, and guessing it would be the tool inventing money.
|
||||
recordStartingBuyIns(state) {
|
||||
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
|
||||
return {
|
||||
seats: owed.length,
|
||||
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
||||
// Cash the box took from people who have since left counts as taken: it is in
|
||||
// the box, and forgetting it with the seat is what used to make a cash-out read
|
||||
// as a shortfall all night.
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
|
||||
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
this.chipsHeldBy(state, p) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
@@ -922,12 +1173,17 @@ const HoldemState = {
|
||||
// for a better reason: the box is supposed to have them.
|
||||
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
|
||||
// Chips handed back over the rail. Off the table like the other two, and the
|
||||
// one the dealer can point at when somebody asks where the money went.
|
||||
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
raked,
|
||||
paidOut,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -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,175 @@
|
||||
// Chips the pot was priced with that nobody actually paid.
|
||||
//
|
||||
// There are two ways a contribution gets into this tool. Tapping the action moves
|
||||
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
|
||||
// hand total at the end of the street. The other is the "without live betting" panel —
|
||||
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
|
||||
// hand they are watching rather than driving, and nothing ever leaves a stack.
|
||||
//
|
||||
// The pot wizard prices both the same way and awarding it credits the winner, so in
|
||||
// that second mode the table simply grew.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
|
||||
|
||||
const basePotTable = (base = 50, stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
state.basePotAmount = base;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].stack = stack;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
const pricedPot = (state) => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
describe("what nobody paid", () => {
|
||||
test("comes off the stacks that owe it", () => {
|
||||
const state = basePotTable();
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 150);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
|
||||
});
|
||||
|
||||
test("and lands where the rest of the tool looks for chips in the middle", () => {
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
|
||||
assert.equal(HoldemState.unresolvedContributions(state), 150);
|
||||
});
|
||||
|
||||
test("chips counted out on a seat are money too", () => {
|
||||
const state = basePotTable(0);
|
||||
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 20);
|
||||
assert.equal(state.seats[0].stack, 180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what has already been paid", () => {
|
||||
test("a live bet is left exactly alone", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 0, "the blinds are already off their stacks");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("nor is a swept street taken a second time", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
assert.equal(Betting.collectAssumedContributions(state).total, 0);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("a base pot on top of a live bet only collects the difference", () => {
|
||||
// The base pot says "assume everyone is in for fifty". Somebody who has already
|
||||
// put twenty in owes thirty of it, not fifty.
|
||||
const state = basePotTable(50);
|
||||
state.seats[0].committed = 20;
|
||||
state.seats[0].stack = 180;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
const ann = collected.taken.find(row => row.name === "Ann");
|
||||
assert.equal(ann.amount, 30);
|
||||
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it will not do", () => {
|
||||
test("take more than a player is holding", () => {
|
||||
const state = basePotTable(50);
|
||||
state.seats[1].stack = 20;
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
|
||||
assert.equal(state.seats[1].amountOverride, 20);
|
||||
});
|
||||
|
||||
test("touch an empty chair", () => {
|
||||
const state = basePotTable();
|
||||
const before = { ...state.seats[3] };
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[3].stack, before.stack);
|
||||
assert.equal(state.seats[3].amountOverride, before.amountOverride);
|
||||
});
|
||||
|
||||
test("or a seat that isn't in the hand", () => {
|
||||
// The base pot stands in for "everyone put the same in", and somebody sitting
|
||||
// out did not.
|
||||
const state = basePotTable();
|
||||
state.seats[2].inHand = false;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
|
||||
assert.equal(state.seats[2].stack, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the whole hand", () => {
|
||||
test("conserves the chips on the table, which it did not before", () => {
|
||||
const state = basePotTable();
|
||||
const total = chipsOnTable(state);
|
||||
const pot = pricedPot(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and the bank check stays balanced all the way through", () => {
|
||||
const state = basePotTable();
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a live-betting hand is unchanged by any of this", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
const total = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
const pot = HoldemState.unresolvedContributions(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("collecting twice takes nothing the second time", () => {
|
||||
// The pot wizard can be opened, cancelled and opened again.
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
const stacks = state.seats.map(p => p.stack);
|
||||
const again = Betting.collectAssumedContributions(state);
|
||||
assert.equal(again.total, 0);
|
||||
assert.deepEqual(state.seats.map(p => p.stack), stacks);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Somebody leaving with their chips.
|
||||
//
|
||||
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||||
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||||
// check — cash taken against chips on the table — declared the departing player's whole
|
||||
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||||
// price of losing their night from the standings and the file the dealer hands over.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("counting them out", () => {
|
||||
test("the preview is what the dealer's hands follow", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
const preview = HoldemState.cashOutPreview(state, 0);
|
||||
assert.equal(preview.name, "Ann");
|
||||
assert.equal(preview.chips, 350, "what they are holding");
|
||||
assert.equal(preview.boughtIn, 200);
|
||||
assert.equal(preview.net, 150, "up for the night");
|
||||
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||||
});
|
||||
|
||||
test("a player who is down reads as down, not as a smaller stack", () => {
|
||||
const state = table();
|
||||
state.seats[1].stack = 60;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||||
});
|
||||
|
||||
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||||
const state = table();
|
||||
state.defaultStack = 200;
|
||||
state.seats[2].stack = null;
|
||||
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("paying somebody with money still in the middle", () => {
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||||
const refusal = HoldemState.canCashOut(state, bb);
|
||||
assert.equal(refusal.ok, false);
|
||||
assert.match(refusal.reason, /in the pot/);
|
||||
// And the refusal is the whole story: nothing moved.
|
||||
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||||
assert.equal((state.cashedOut || []).length, 0);
|
||||
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||||
});
|
||||
|
||||
test("an empty chair, which has nobody to pay", () => {
|
||||
const state = table();
|
||||
const refusal = HoldemState.canCashOut(state, 99);
|
||||
assert.equal(refusal.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chair afterwards", () => {
|
||||
test("is empty and ready for the next player", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
const seat = state.seats[0];
|
||||
assert.equal(seat.name, "");
|
||||
assert.equal(seat.stack, null);
|
||||
assert.equal(seat.buyIns, 0);
|
||||
assert.equal(seat.inHand, false);
|
||||
});
|
||||
|
||||
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||||
const state = table();
|
||||
const wasId = state.seats[0].id;
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.notEqual(state.seats[0].id, wasId);
|
||||
});
|
||||
|
||||
test("and isn't owed the blind the person before them was posting", () => {
|
||||
const state = table();
|
||||
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||||
HoldemState.cashOut(state, 0);
|
||||
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check", () => {
|
||||
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||||
assert.equal(bank.chipsInPlay, 450);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // so the table starts out balanced
|
||||
// What a dealer had to do before: empty the chair themselves.
|
||||
state.seats[0].name = "";
|
||||
state.seats[0].stack = null;
|
||||
state.seats[0].inHand = false;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||||
});
|
||||
|
||||
test("chips that really are missing still show as missing", () => {
|
||||
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50; // balanced to start with
|
||||
HoldemState.cashOut(state, 0);
|
||||
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, -50);
|
||||
});
|
||||
|
||||
test("a rake and a cash-out are counted apart", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.rakeTaken = 12;
|
||||
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||||
HoldemState.cashOut(state, 0);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.raked, 12);
|
||||
assert.equal(bank.paidOut, 350);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of their night", () => {
|
||||
test("they stay in the standings with what they bought and what they left with", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.cashOut(state, 0);
|
||||
|
||||
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||||
assert.ok(ann, "still on the list");
|
||||
assert.equal(ann.boughtIn, 200);
|
||||
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||||
assert.equal(ann.cashPosition, 150);
|
||||
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||||
});
|
||||
|
||||
test("somebody still sitting there is not marked", () => {
|
||||
const state = table();
|
||||
HoldemState.cashOut(state, 0);
|
||||
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||||
assert.equal(ben.cashedOut, false);
|
||||
});
|
||||
|
||||
test("two people leaving are both kept", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.equal(state.cashedOut.length, 2);
|
||||
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table saved before any of this existed has nobody cashed out", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.cashedOut;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.cashedOut, []);
|
||||
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.cashedOut = [
|
||||
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||||
{ name: "no id" },
|
||||
null,
|
||||
];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.cashedOut.length, 1);
|
||||
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||||
assert.equal(migrated.cashedOut[0].chips, 350);
|
||||
assert.equal(migrated.cashedOut[0].at, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table carries on", () => {
|
||||
test("the next hand deals round the empty chair", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
|
||||
test("and the money still adds up once it has", () => {
|
||||
const state = table();
|
||||
state.seats[0].stack = 350;
|
||||
state.seats[1].stack = 50;
|
||||
HoldemState.cashOut(state, 0);
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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.
|
||||
const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]);
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking a hand back.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
|
||||
// the first two cards exposed — and the tool had no way to do it. The only control that
|
||||
// ended a live hand was Next Hand, which says out loud that it takes the money off the
|
||||
// table without paying anybody. That is right for an abandoned pot and wrong for a
|
||||
// misdeal, where the blinds and the calls belong to the people who put them in.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const dealt = (extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the money", () => {
|
||||
test("goes back to the people who put it in", () => {
|
||||
const state = dealt();
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("including calls and raises, not just the blinds", () => {
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
|
||||
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 95, "blinds and both wagers");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
});
|
||||
|
||||
test("and across streets, once chips have been swept into hand totals", () => {
|
||||
const state = dealt();
|
||||
[3, 4, 5, 0, 1, 2].forEach(seat => {
|
||||
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
});
|
||||
Betting.closeStreet(state);
|
||||
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
|
||||
});
|
||||
|
||||
test("the table has exactly the chips it started with", () => {
|
||||
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
const total = chipsOnTable(fresh);
|
||||
const state = dealt();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
});
|
||||
|
||||
test("nothing is thrown away — the discard pile doesn't move", () => {
|
||||
const state = dealt();
|
||||
state.discarded = 8;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
|
||||
});
|
||||
|
||||
test("it says what went back and to whom", () => {
|
||||
const state = dealt();
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.returned.length, 2, "the two blinds");
|
||||
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
|
||||
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the table afterwards", () => {
|
||||
test("is waiting to deal the same hand again", () => {
|
||||
const state = dealt();
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.bettingOpen, false, "the dealer says when");
|
||||
assert.equal(state.street, "preflop");
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
assert.equal(state.currentBet, 0);
|
||||
});
|
||||
|
||||
test("keeps the button where it was — a misdeal is not the next hand", () => {
|
||||
const state = dealt({ dealerSeatIndex: 3 });
|
||||
const handNumber = state.handNumber;
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.dealerSeatIndex, 3);
|
||||
assert.equal(state.handNumber, handNumber, "no hand has been played");
|
||||
assert.equal((state.handHistory || []).length, 0, "and none is logged");
|
||||
});
|
||||
|
||||
test("keeps the straddle, which was announced and still stands", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||
state.straddleCount = 1;
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3]);
|
||||
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(state.straddleCount, 1, "still on for the redeal");
|
||||
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
|
||||
});
|
||||
|
||||
test("clears the cards and the folds, because none of it happened", () => {
|
||||
const state = dealt();
|
||||
state.seats[3].cards = ["As", "Kd"];
|
||||
Betting.fold(state, 3);
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.seats[3].cards, ["", ""]);
|
||||
assert.equal(state.seats[3].folded, false);
|
||||
assert.ok(state.seats.every(p => !p.hasActed));
|
||||
});
|
||||
|
||||
test("deals again cleanly, with the same blinds on the same players", () => {
|
||||
const state = dealt();
|
||||
const first = state.seats.map(p => Betting.committed(p));
|
||||
Betting.takeHandBack(state);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank still balances", () => {
|
||||
test("through a misdeal and the redeal after it", () => {
|
||||
const state = dealt();
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 60);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.cashIn, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking back a hand nobody had money in", () => {
|
||||
test("is harmless: no blinds, nothing moves", () => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
|
||||
Betting.beginHand(state);
|
||||
const taken = Betting.takeHandBack(state);
|
||||
assert.equal(taken.total, 0);
|
||||
assert.deepEqual(taken.returned, []);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// One question, one answer.
|
||||
//
|
||||
// The round before this found the bank check and the standings reading `seat.stack`
|
||||
// where the felt reads the table's starting stack — two parts of the tool disagreeing
|
||||
// about how much money is on the table. This is the sweep that followed: everywhere
|
||||
// else the same two questions get asked, asked the same way.
|
||||
//
|
||||
// "What is this seat holding?" -> HoldemState.chipsHeldBy
|
||||
// "Is anybody in this chair?" -> HoldemState.seatInPlay
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, SettleUp } = require("./load");
|
||||
|
||||
const openedOnTheDefault = (stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.defaultStack = stack;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what a seat is holding", () => {
|
||||
test("is the same number the felt plays with", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats.forEach((player, i) => {
|
||||
if (i > 2) return;
|
||||
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
|
||||
});
|
||||
});
|
||||
|
||||
test("and is nothing at all for an empty chair", () => {
|
||||
const state = openedOnTheDefault();
|
||||
// Betting's own answer for an empty chair is the table default, because it is
|
||||
// only ever asked about seats that are in a hand. The money side has to ask
|
||||
// about all ten chairs, so it checks whether anybody is in them first.
|
||||
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
|
||||
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
|
||||
});
|
||||
|
||||
test("counts a stack the dealer typed over the table's default", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[0].stack = 75;
|
||||
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("is anybody in this chair", () => {
|
||||
const empty = () => HoldemState.createDefault().seats[0];
|
||||
|
||||
test("an untouched chair is empty", () => {
|
||||
assert.equal(HoldemState.seatInPlay(empty()), false);
|
||||
assert.equal(HoldemState.seatInPlay(null), false);
|
||||
});
|
||||
|
||||
test("a name, chips, cash, a bet or a dealt hand all count", () => {
|
||||
[
|
||||
["a name", p => { p.name = "Ann"; }],
|
||||
["chips", p => { p.stack = 50; }],
|
||||
["cash taken", p => { p.buyIns = 100; }],
|
||||
["a bet in front of them", p => { p.committed = 5; }],
|
||||
["money already swept in", p => { p.amountOverride = 20; }],
|
||||
["a hand dealt to them", p => { p.inHand = true; }],
|
||||
].forEach(([what, mark]) => {
|
||||
const seat = empty();
|
||||
mark(seat);
|
||||
assert.equal(HoldemState.seatInPlay(seat), true, what);
|
||||
});
|
||||
});
|
||||
|
||||
test("whitespace is not a name", () => {
|
||||
const seat = empty();
|
||||
seat.name = " ";
|
||||
assert.equal(HoldemState.seatInPlay(seat), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the parts that used to disagree", () => {
|
||||
test("the open seats and the seat count see the same empty chairs", () => {
|
||||
const state = openedOnTheDefault();
|
||||
// Three players, six chairs.
|
||||
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
|
||||
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
|
||||
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
|
||||
});
|
||||
|
||||
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
|
||||
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
|
||||
// none of those would have been handed to the next person in the queue, on top
|
||||
// of whoever was already playing it.
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[4].inHand = true;
|
||||
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
|
||||
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("the bank, the standings and the settle-up all price the night the same way", () => {
|
||||
const state = openedOnTheDefault();
|
||||
state.seats[0].stack = 320; // Ann is up
|
||||
state.seats[1].stack = 80; // Ben is down
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
const chipsFromStandings = standings
|
||||
.filter(row => !row.cashedOut)
|
||||
.reduce((sum, row) => sum + row.chipsInFront, 0);
|
||||
|
||||
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
|
||||
assert.equal(bank.balanced, true);
|
||||
|
||||
// And the payments the dealer is told to make add up to the same money.
|
||||
const plan = SettleUp.plan(standings);
|
||||
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
|
||||
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
|
||||
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
|
||||
});
|
||||
|
||||
test("a player still holding the table's starting stack is in all three", () => {
|
||||
const state = openedOnTheDefault();
|
||||
const standings = HoldemState.sessionStandings(state);
|
||||
assert.equal(standings.length, 3);
|
||||
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
|
||||
});
|
||||
});
|
||||
@@ -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,166 @@
|
||||
// The chips the felt plays with are the chips the box is checked against.
|
||||
//
|
||||
// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
|
||||
// "seats without their own stack start here" — and everything a dealer looks at reads
|
||||
// it: the puck, the action bar, what a player is allowed to bet. The money side did
|
||||
// not. It read `seat.stack` directly, which is null for exactly those seats, so a
|
||||
// three-handed table playing for $600 reported no chips in play, a balanced bank and a
|
||||
// standings list with nobody on it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting } = require("./load");
|
||||
|
||||
const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.defaultStack = stack;
|
||||
names.forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what the table is playing for", () => {
|
||||
test("is what the bank counts", () => {
|
||||
const state = openedWithStartingStack();
|
||||
// What the felt says each of them has.
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||
});
|
||||
|
||||
test("and the box being empty is a problem it now reports", () => {
|
||||
const bank = HoldemState.bankReconciliation(openedWithStartingStack());
|
||||
assert.equal(bank.cashIn, 0);
|
||||
assert.equal(bank.difference, 600);
|
||||
assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
|
||||
});
|
||||
|
||||
test("empty chairs are not people holding two hundred dollars", () => {
|
||||
// The starting stack stands in for a seat that hasn't been given one, and every
|
||||
// empty chair is such a seat. Counting them would invent $600 of chips at an
|
||||
// empty table.
|
||||
const state = openedWithStartingStack();
|
||||
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
|
||||
|
||||
const empty = HoldemState.createDefault();
|
||||
empty.defaultStack = 200;
|
||||
assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
|
||||
assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
|
||||
});
|
||||
|
||||
test("once the cash is written down it balances", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a table where everyone has their own stack is unchanged", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
["Ann", "Ben"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].stack = 150;
|
||||
state.seats[i].buyIns = 150;
|
||||
});
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.chipsInPlay, 300);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who is on the settle-up list", () => {
|
||||
test("everyone holding chips, including the ones holding the table's own stack", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
|
||||
});
|
||||
|
||||
test("with nothing bought in yet, which is the thing to notice", () => {
|
||||
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||
assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
|
||||
assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming the seats the cash is missing for", () => {
|
||||
test("says who, and which kind of missing it is", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
|
||||
|
||||
const owing = HoldemState.seatsOwingCash(state);
|
||||
assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
|
||||
assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
|
||||
});
|
||||
|
||||
test("a seat whose buy-in is written down isn't on the list", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[0].buyIns = 200;
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
|
||||
});
|
||||
|
||||
test("and a table where nothing is owed has nobody on it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
assert.deepEqual(HoldemState.seatsOwingCash(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing the starting stacks down", () => {
|
||||
test("records exactly what those seats are holding", () => {
|
||||
const state = openedWithStartingStack();
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 3);
|
||||
assert.equal(done.total, 600);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and leaves somebody else's forgotten rebuy alone", () => {
|
||||
// Only the dealer knows what that was. Guessing would be the tool inventing
|
||||
// money, which is the fault this whole round is about.
|
||||
const state = openedWithStartingStack();
|
||||
state.seats[2].stack = 320;
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
|
||||
});
|
||||
|
||||
test("does nothing on a table that doesn't need it", () => {
|
||||
const state = openedWithStartingStack();
|
||||
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||
const done = HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(done.seats, 0);
|
||||
assert.equal(done.total, 0);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("through a hand", () => {
|
||||
test("the money adds up from the deal to the payout", () => {
|
||||
const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
|
||||
HoldemState.recordStartingBuyIns(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
|
||||
|
||||
[3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
const pot = Betting.potSoFar(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, true);
|
||||
assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
|
||||
assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
|
||||
});
|
||||
});
|
||||
@@ -719,6 +719,64 @@ const LayoutAudit = {
|
||||
return { name: "the felt shows one dealer button", offenders };
|
||||
},
|
||||
|
||||
// Anything that says it is hidden and is drawn anyway.
|
||||
//
|
||||
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
|
||||
// rule in the cascade, so any class that sets a display beats it — and in this tool
|
||||
// every button class does. The result is an element the code believes is gone,
|
||||
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
|
||||
// file's own touch check had been calling it "could not be probed" rather than
|
||||
// saying the plainer thing, which is that it should not be there at all.
|
||||
hiddenButPainted() {
|
||||
const offenders = [...document.querySelectorAll("[hidden]")]
|
||||
.filter(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const style = getComputedStyle(el);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
})
|
||||
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
|
||||
return { name: "hidden means hidden", 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.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
const box = name.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return;
|
||||
// Padding is room the text has already been kept out of, so it is not
|
||||
// somewhere the text can be covered.
|
||||
const style = getComputedStyle(name);
|
||||
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
||||
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
||||
|
||||
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;
|
||||
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
||||
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
if (covered > 1 && vertical > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { name: "seat names aren't painted under their own badges", offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
@@ -735,8 +793,98 @@ 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.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
@@ -747,6 +895,8 @@ const LayoutAudit = {
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.seatLabelsClear(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// The people standing behind the chairs.
|
||||
//
|
||||
// A full home game always has a couple of them, and the list lives on a napkin or in
|
||||
// the dealer's head — which is where the argument comes from an hour later about who
|
||||
// said they were next. Order of arrival, because it is the only ordering anybody has
|
||||
// ever agreed to.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
const table = (seated = 2, seats = 4) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].stack = 200;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("keeping the list", () => {
|
||||
test("names go on in the order they arrive", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.addToWaitingList(state, "Sam");
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||
});
|
||||
|
||||
test("whitespace is trimmed and nothing empty gets on", () => {
|
||||
const state = table();
|
||||
assert.ok(HoldemState.addToWaitingList(state, " Priya "));
|
||||
assert.equal(HoldemState.addToWaitingList(state, " "), null);
|
||||
assert.equal(HoldemState.addToWaitingList(state, ""), null);
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
|
||||
});
|
||||
|
||||
test("two people with the same name are two people", () => {
|
||||
// Home games have two Mikes. The list is keyed by id, not by name.
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Mike");
|
||||
HoldemState.addToWaitingList(state, "Mike");
|
||||
assert.equal(state.waitingList.length, 2);
|
||||
assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
|
||||
|
||||
HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||
assert.equal(state.waitingList.length, 1, "only one of them leaves");
|
||||
});
|
||||
|
||||
test("taking somebody off gives them back, so the toast can name them", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||
assert.equal(gone.name, "Priya");
|
||||
assert.deepEqual(state.waitingList, []);
|
||||
assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which chairs are open", () => {
|
||||
test("the ones with nobody in them, in seat order", () => {
|
||||
assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
|
||||
assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
|
||||
});
|
||||
|
||||
test("a chair with chips or a buy-in on it is not open", () => {
|
||||
// Somebody who has stepped away from the table still owns their seat.
|
||||
const state = table(2, 4);
|
||||
state.seats[2].stack = 120;
|
||||
state.seats[3].buyIns = 100;
|
||||
assert.deepEqual(HoldemState.openSeats(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitting somebody down", () => {
|
||||
test("their name goes on the chair and they come off the list", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.addToWaitingList(state, "Sam");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(state.seats[2].name, "Priya");
|
||||
assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
|
||||
});
|
||||
|
||||
test("with nothing in front of them, because they haven't bought in yet", () => {
|
||||
// Guessing a stack would put chips on the table nobody has paid for, which is
|
||||
// the one thing the bank check exists to catch.
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||
|
||||
assert.equal(state.seats[2].stack, null);
|
||||
assert.equal(state.seats[2].buyIns, 0);
|
||||
assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("never into a chair somebody is already in", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /Ann is already/);
|
||||
assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
|
||||
});
|
||||
|
||||
test("nor from a name that has already been seated", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const id = state.waitingList[0].id;
|
||||
HoldemState.seatFromWaitingList(state, id, 2);
|
||||
const again = HoldemState.seatFromWaitingList(state, id, 3);
|
||||
assert.equal(again.ok, false);
|
||||
assert.equal(state.seats[3].name, "", "the second chair is untouched");
|
||||
});
|
||||
|
||||
test("a seat that doesn't exist is refused rather than invented", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
|
||||
assert.equal(state.waitingList.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat opening up", () => {
|
||||
test("cashing somebody out is what makes room for the next one", () => {
|
||||
const state = table(4, 4);
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
|
||||
|
||||
HoldemState.cashOut(state, 1);
|
||||
assert.deepEqual(HoldemState.openSeats(state), [1]);
|
||||
|
||||
const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
|
||||
assert.equal(seated.ok, true);
|
||||
assert.equal(state.seats[1].name, "Priya");
|
||||
// Ben's night is still on the record, and the money still adds up.
|
||||
assert.equal(state.cashedOut.length, 1);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a saved table remembers", () => {
|
||||
test("the list survives a reload", () => {
|
||||
const state = table();
|
||||
HoldemState.addToWaitingList(state, "Priya");
|
||||
const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
|
||||
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
|
||||
});
|
||||
|
||||
test("a table saved before the list existed has nobody waiting", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
delete parsed.waitingList;
|
||||
assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
|
||||
});
|
||||
|
||||
test("junk in a saved list is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(table()));
|
||||
parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||
assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user