Make the dealer tool work with no signal, and stop it shouting
Three findings this round, all measured on the running page.
**1. The tool needed the internet to exist.**
The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.
There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.
Three decisions worth writing down:
* The precache list is read off the document rather than kept in the worker.
Every stylesheet and script is already in the DOM; a hand-written file list
stays right until somebody adds a script tag, and then the tool is quietly
offline-broken until a dealer finds out at a table.
* 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 scripts that expect to agree with each other.
* Stale-while-revalidate for assets, not cache-first — see finding 3.
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 scope the worker wasn't told about goes
straight to the network.
Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".
**2. A screen reader read out the whole action bar after every tap.**
The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:
"PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
Fold Call $10 Raise"
Nothing in it is wrong; it is 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. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.
**3. Every tap rebuilt ninety DOM nodes.**
renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:
renderSeats 7.3ms -> 4.0ms
renderAll 12.4ms -> 9.5ms
The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.
Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.
608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -580,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>
|
||||
|
||||
@@ -607,8 +617,31 @@
|
||||
|
||||
@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);
|
||||
})());
|
||||
});
|
||||
@@ -90,6 +90,21 @@ input[type="number"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Read aloud, never drawn. `display: none` and `visibility: hidden` both take an
|
||||
element out of the accessibility tree along with the layout, which would silence the
|
||||
live region entirely — so it stays in the tree at 1px, clipped. */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Hold the scrollbar's width whether or not there is one to show. A panel opening far
|
||||
enough to make the page scrollable used to narrow the viewport by the scrollbar's
|
||||
width, which re-centres the felt and nudges every line of text left. No effect on
|
||||
|
||||
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 |
@@ -142,6 +142,52 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
this.syncTankTimer();
|
||||
this.announce();
|
||||
},
|
||||
|
||||
// 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 (!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() {
|
||||
|
||||
@@ -863,4 +863,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 });
|
||||
});
|
||||
|
||||
@@ -386,11 +386,53 @@ 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 = "";
|
||||
const count = this.state.seats.length;
|
||||
if (layer.childElementCount !== count) this.buildSeatNodes(layer, count);
|
||||
this.updateSeatDensity();
|
||||
const template = document.getElementById("seatTemplate");
|
||||
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
|
||||
@@ -400,11 +442,11 @@ const Render = {
|
||||
!this.state.seats[dealerIndex].inHand;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
const node = layer.children[index];
|
||||
if (!node) return;
|
||||
const pos = this.seatPosition(index, this.state.seatCount);
|
||||
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
|
||||
@@ -416,7 +458,8 @@ 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;
|
||||
@@ -440,13 +483,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");
|
||||
@@ -471,11 +507,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();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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 nothing at all", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(barOn(state).announcement(), "");
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user