Be there when the signal, the screen and the storage are not

Three failures that only ever happen at a table, none of which show up on a
desk with a good connection and a plugged-in laptop.

The offline promise depended on how the URL was spelled. Razor Pages routing
is case-insensitive, so /Casino/TexasHoldem serves the identical page; a
service worker's scope is matched as a plain string prefix, so a worker scoped
to /casino/ does not control it. The registration succeeded, the worker
activated, nothing was logged, and the cache stayed completely empty --
because the precache list was handed to navigator.serviceWorker.ready, which
resolves with the registration controlling the page and so never resolves at
all outside its scope. A dealer who opened that URL to load the tool for the
night had loaded nothing.

One spelling wins now, via a 301 scoped to /casino rather than applied
site-wide: every file under wwwroot/casino and wwwroot/images/casino is
already lowercase, so lowercasing there cannot miss a real file on a
case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and /chessHub would break. The handshake is fixed rather than left to be
saved by the redirect -- it takes the worker off the registration itself.

The manifest icons were never cached, because the shell list is read off the
DOM and the DOM never mentions them. They are read out of the manifest now,
cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and
the link sweep picks up apple-touch-icon, which is the only one iOS reads.

The screen slept mid-hand. A wake lock is held while betting is open and
dropped the moment it is not: through a whole street of renders it takes
exactly one, two syncs in a tick take exactly one, a hand that ends inside the
request leaves nothing held, and the browser taking it back leaves us able to
take it again. Announced once with a Turn off button rather than a permanent
header indicator; a setting after that.

And localStorage is evictable, which the Save & reset panel now says out loud.
persist() is asked for on Post blinds & deal, because every browser wants that
request to come from something the dealer did.

885 tests pass, 18 of them new. Five fault injections into the wake lock are
each caught. One of the new tests was green for the wrong reason first:
globalThis.navigator = fake does nothing, because Node has defined navigator
as a getter since v21.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-09 04:31:45 -06:00
co-authored by Claude Opus 5
parent 75ec028c3e
commit e2bb935b43
10 changed files with 767 additions and 19 deletions
@@ -97,6 +97,14 @@
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -295,6 +303,14 @@
The night lives in this browser and nowhere else. Save it to a file to carry it
to another device, or to have something to come back to if this one dies.
</p>
@* That sentence has always been true, and always been missing its
second half: a browser is allowed to throw site data away when the
device runs short, and Safari does it on a timer. So the panel now
says which of the two situations this device is actually in, and
offers the one thing that changes it. See requestDurable() in
state.js for why the ask is a button rather than a page-load call. *@
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
@@ -705,6 +721,8 @@
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
+33
View File
@@ -53,6 +53,39 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
// on it and nothing warns you when it breaks.
//
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
// capital-C spelling registered the worker, activated it, logged nothing — and then left
// the cache completely empty, because the page was never controlled and the precache
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
// that looked loaded and had nothing stored for the night the signal went.
//
// So one spelling wins and the other is redirected to it, permanently, before anything
// else in the pipeline gets a look.
//
// Deliberately scoped to /casino rather than applied site-wide. Every file under
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
// not hold for the rest of the site, and one that /chessHub in particular would break.
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value;
if (path is not null
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
&& path.Any(char.IsUpper))
{
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
return;
}
await next();
});
// .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
@@ -1140,6 +1140,11 @@ const ActionBar = {
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
// The first hand is the moment this stops being a setup screen and starts
// being a night somebody could lose. It is also a tap, which is what every
// browser wants the "keep my data" request to come from — see
// requestDurable() in state.js. Harmless and instant after the first time.
if (this.hooks.keepStorage) this.hooks.keepStorage();
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
+133 -19
View File
@@ -364,6 +364,7 @@ document.addEventListener("DOMContentLoaded", () => {
save: hooks.save,
rerender: hooks.rerender,
resolvePot: openPotWizard,
keepStorage: () => askToKeepStorage(),
// 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
@@ -1001,6 +1002,74 @@ document.addEventListener("DOMContentLoaded", () => {
{ duration: 12000 });
}
// ---- Keeping the screen on ----
//
// The lock itself is taken and dropped in renderAll, because that is the one place
// every start and end of a hand already passes through. All that lives here is the
// dealer's say in it: the toggle, and the way out offered by the toast the first
// time it takes hold.
WakeLock.hooks = {
turnOff: () => {
state.keepScreenAwake = false;
hooks.save(state);
Render.renderAll();
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
},
};
WakeLock.start(() => state);
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
state.keepScreenAwake = e.target.checked;
hooks.save(state);
// renderAll does the acquiring and releasing, so turning this off mid-hand
// drops the lock in the same tick rather than at the end of the hand.
Render.renderAll();
});
// ---- Asking the browser to keep the night ----
//
// Reported rather than announced. A dealer opening Save & reset is already thinking
// about losing the table, and that is the only moment this is worth a sentence.
const showStorageDurability = async () => {
const hint = document.getElementById("storageDurabilityHint");
const button = document.getElementById("keepStorageBtn");
if (!hint || !button) return;
if (!HoldemState.supportsDurableStorage()) {
// Nothing to say and nothing to offer. A browser this old still keeps the
// table exactly as well as it did yesterday.
hint.hidden = true;
button.hidden = true;
return;
}
const durable = await HoldemState.isDurable();
hint.hidden = false;
hint.textContent = durable
? "This browser has promised to keep it: the table survives the phone running short on space."
: "This browser may clear the table on its own if the phone runs short on space.";
button.hidden = durable;
};
// Fires on the first deal of every session, which costs nothing after the first
// time it is granted — persisted() is checked before persist() is asked for.
const askToKeepStorage = async () => {
await HoldemState.requestDurable();
showStorageDurability();
};
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
const granted = await HoldemState.requestDurable();
showStorageDurability();
// Said either way, because a button that sometimes does nothing visible is a
// button a dealer presses four more times.
Toast.show(granted
? "This browser will keep the table now."
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
});
showStorageDurability();
// ---- Working with no signal ----
//
// The night already survives anything — it lives in localStorage — but the page
@@ -1014,33 +1083,78 @@ document.addEventListener("DOMContentLoaded", () => {
// 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 = () => {
const MANIFEST_URL = "/casino/holdem.webmanifest";
// The icons are the one part of the shell the document doesn't mention: the browser
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
// at them and the sweep above misses all four. An installed app that was launched
// with no signal then had no icon to show for itself.
//
// Read out of the manifest rather than typed out here, for the same reason the rest
// of the list is read out of the document — a copy of a list is a list that is one
// edit away from being wrong, and this particular wrongness is invisible until
// somebody is standing in a back room with no signal.
const manifestIcons = async () => {
try {
// The cache before the network, which is backwards from how the rest of this
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
// every single launch — noise on the one screen where a real error needs to
// be visible, in service of re-reading a list of four icon paths that has not
// changed since the manifest was written and is already cached anyway.
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|| await fetch(MANIFEST_URL);
if (!res || !res.ok) return [];
const manifest = await res.json();
return (manifest.icons || [])
.map(icon => icon && icon.src)
.filter(Boolean)
.map(src => new URL(src, location.origin).href);
} catch {
return [];
}
};
const registerWorker = async () => {
if (!("serviceWorker" in navigator)) return;
const shell = [
location.pathname,
"/casino/holdem.webmanifest",
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
MANIFEST_URL,
...await manifestIcons(),
// Stylesheets, plus the icons the document points at itself — iOS reads
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
// an installed iPhone launched with no signal needs this one specifically.
...[...document.querySelectorAll(
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][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.
try {
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
// Whichever worker this registration has, at whatever stage it has reached.
//
// This used to fall back on navigator.serviceWorker.ready, which resolves
// with the registration *controlling this page* — and so never resolves at
// all on a page outside the worker's scope. That is not hypothetical: it is
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
// and the symptom was the worst kind. The registration succeeded, the worker
// activated, nothing was logged, and the cache stayed completely empty — a
// dealer who had "loaded the tool for tonight" had loaded nothing.
const worker = reg.installing || reg.waiting || reg.active;
if (!worker) return;
const send = () => worker.postMessage({ type: "precache", urls: shell });
if (worker.state === "activated") send();
else worker.addEventListener("statechange", () => {
if (worker.state === "activated") send();
});
} 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
@@ -40,6 +40,11 @@ const Render = {
this.renderStandings();
this.renderHandHistory();
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
// Last, and from here rather than from the dozen places a hand can start or
// end. Every one of those already ends in a renderAll, and a wake lock that is
// taken in some of them and not others is worse than none: the dealer learns
// the screen stays on, and then one night it doesn't.
if (typeof WakeLock !== "undefined") WakeLock.sync(this.state);
},
renderSetupFields() {
@@ -48,6 +53,7 @@ const Render = {
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
@@ -66,6 +66,11 @@ const HoldemState = {
// it has always been: no board on the felt, and the pot wizard asks who
// won. On, it reads the showdown and answers that question itself.
trackBoard: false,
// Whether to stop the screen sleeping while a hand is live. On, because a
// dealer who has to wake their phone between the flop and the turn is the
// single most common way this tool gets in the way of the game it is meant
// to be running. Only ever held while betting is open — see wakeLock.js.
keepScreenAwake: true,
currentBet: 0,
lastRaiseSize: 0,
actingSeatIndex: null,
@@ -229,6 +234,12 @@ const HoldemState = {
parsed.trackBoard = parsed.trackBoard === true;
parsed.board = this.normaliseBoard(parsed.board);
// Note the direction: `!== false` rather than `=== true`. A table saved
// before this setting existed has no opinion about it, and the useful guess
// for a dealer who has never been asked is the helpful one. Only somebody
// who has actually turned it off gets it off.
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
@@ -318,6 +329,48 @@ const HoldemState = {
//
// So the table can be written out as a file and read back in. Same shape that goes
// into storage, wrapped in enough to recognise it later.
// ---- Asking the browser to actually keep it -------------------------------
//
// "The night lives in this browser and nowhere else" is truer than it sounds.
// localStorage is *evictable*: when a device runs short, the browser clears
// site data to make room, and it starts with the sites it thinks nobody cares
// about. Safari goes further and deletes script-created storage after seven days
// without a visit — so a table saved at last month's game can simply not be there
// at this one, with nothing having gone wrong and nobody having deleted anything.
//
// `persist()` is the way to say this data is not that. Granted, the storage can
// only be cleared by the person whose browser it is. Chromium and Safari decide
// silently on how much the site has been used and whether it's installed; Firefox
// asks. All three want the request to come from something the dealer did, which is
// why this fires on the first hand dealt rather than on page load.
supportsDurableStorage() {
return typeof navigator !== "undefined"
&& !!navigator.storage
&& typeof navigator.storage.persist === "function";
},
async isDurable() {
if (!this.supportsDurableStorage()) return false;
try {
return await navigator.storage.persisted();
} catch {
return false;
}
},
// Returns whether the browser said yes. A no is not a failure worth reporting at a
// table — the tool behaves identically either way, and the only honest difference
// is how confident the Save & reset panel is allowed to sound.
async requestDurable() {
if (!this.supportsDurableStorage()) return false;
try {
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
},
BACKUP_KIND: "casino.holdem.table",
BACKUP_VERSION: 1,
@@ -0,0 +1,126 @@
// Keeps the screen on while a hand is live.
//
// The dealer's phone spends the night face-up on the felt with nine people round it,
// and the one thing it reliably does between the flop and the turn is dim, then lock.
// Every hand, all night. A dozen rounds of work have gone into making the next step
// readable at a glance, and none of it survives a black screen and a passcode with
// four people waiting to know whether the bet is called.
//
// Held only while betting is actually open. A phone left on the table through a break,
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
// like any other phone — which is the difference between a tool that respects a battery
// and one a dealer stops trusting with their evening.
const WakeLock = {
// The browser's promise to stay awake. Null whenever we aren't holding one, which
// includes every moment the browser has taken it back on us — see `start()`.
sentinel: null,
// What the last sync decided we want. Kept separate from `sentinel` because
// requesting one is asynchronous: a hand can be dealt and folded out inside the
// await, and the answer to "should we still be holding this" has to be the current
// one rather than the one that was true when the request went out.
wanted: false,
// One request at a time. `sync()` runs off renderAll, which fires many times a
// second during a busy street; without this, two overlapping requests both get
// past the "already holding one?" check and the second sentinel leaks — the screen
// then stays awake after the hand ends, with nothing left holding a reference to
// turn it off.
pending: false,
// Whether the dealer has been told this is happening. Once per page load, not once
// per hand: the same toast at the top of all forty hands of a session would be the
// single most annoying thing the tool does.
told: false,
// Set by main.js so the toast's "Turn off" can actually turn it off.
hooks: null,
supported() {
return typeof navigator !== "undefined" && "wakeLock" in navigator;
},
// The whole decision, with no browser in it, so it can be tested as what it is:
// a rule about when a poker table needs the screen.
shouldHold(state) {
if (!state) return false;
if (state.keepScreenAwake === false) return false;
return state.bettingOpen === true;
},
// Called after anything that could start or end a hand. Safe to call constantly.
sync(state) {
this.wanted = this.supported() && this.shouldHold(state);
if (this.wanted) this.acquire();
else this.release();
},
async acquire() {
if (this.sentinel || this.pending) return;
this.pending = true;
try {
const sentinel = await navigator.wakeLock.request("screen");
// The hand can have ended while that was in flight. Letting this one go
// immediately is the only correct answer; holding it "just until the next
// sync" is how a phone stays lit on an empty table.
if (!this.wanted) {
try { await sentinel.release(); } catch { /* already gone */ }
return;
}
this.sentinel = sentinel;
// The browser releases this itself when the tab is hidden, and tells us so.
// Without this listener our `sentinel` would be a stale object and the
// re-acquire in `start()` would decline to do anything.
sentinel.addEventListener("release", () => {
if (this.sentinel === sentinel) this.sentinel = null;
});
this.tell();
} catch {
// Battery saver, a hidden tab, a browser that simply says no. All of these
// are the browser doing its job, and none of them is worth an error at a
// poker table — the tool works exactly as it did before, the screen just
// dims the way the phone's own settings say it should.
} finally {
this.pending = false;
}
},
async release() {
const sentinel = this.sentinel;
this.sentinel = null;
if (!sentinel) return;
try { await sentinel.release(); } catch { /* already released */ }
},
// Said once, at the moment it first becomes true, with the way out attached.
//
// The alternative was a permanent indicator in the header, and the header is the
// one part of this page that has been fought over for crowding more than any other.
// A dealer needs to know their phone is behaving unusually exactly once; after that
// it is a setting, and settings live in the sidebar.
tell() {
if (this.told) return;
this.told = true;
if (typeof Toast === "undefined") return;
Toast.show("Keeping the screen on while a hand is live.", {
actionLabel: "Turn off",
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
});
},
// A screen wake lock is not something you take once. The browser drops it whenever
// the document stops being visible — switching apps to check a rule, the phone
// locking on its own before the first hand was dealt, a notification pulled down —
// and it never comes back on its own. A tool that requests it at the start of the
// hand and assumes it still has it is a tool that works until the first interruption.
start(getState) {
if (!this.supported()) return;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
this.sync(getState());
});
},
};
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
+111
View File
@@ -621,6 +621,117 @@ still names whoever didn't turn over; closing it leaves **Showdown — resolve p
bar; and pressing it again resumes at the player the dealer stopped on, because the list
is computed from the cards on the table rather than remembered from the last attempt.
## The failures that only happen at the table
Everything above this line is about a tool being easy to read and quick to press. This
section is about it being *there* — the three ways it could let a dealer down on the
night that no amount of layout work would have caught, because none of them show up on a
developer's desk with a good connection and a plugged-in laptop.
### One spelling of the URL, because the offline promise depends on it
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
page. A service worker's scope is not: the spec matches it as a plain string prefix, so a
worker scoped to `/casino/` does not control a page at `/Casino/TexasHoldem`. Measured on
the capital-C spelling, with the network cut:
| | registration | controlling | cached | offline |
|---|---|---|---|---|
| `/casino/texasholdem` | created | yes | 25 entries | loads |
| `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` |
The empty cache is the part that matters, and it took a second look to understand. The
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
registration **controlling this page** — so on a page outside the scope it never resolves,
the message is never sent, and not one file is stored. A dealer who had opened that URL to
"load the tool for tonight" had loaded nothing, and had no way to find out until the
signal went.
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
than applied site-wide, because every file under `wwwroot/casino` and
`wwwroot/images/casino` is already lowercase and so lowercasing a path there cannot miss a
real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and `/chessHub` in particular would break.
The precache handshake was fixed too, rather than left to be saved by the redirect. It
now takes the worker off the registration itself — `installing || waiting || active` —
and waits for `activated` on that. A tool whose offline support silently depends on being
in scope is one URL away from having none.
### And what the document never mentions
The shell list is read off the DOM on purpose, so it can't drift from the markup. The
icons are the one part of the shell the DOM doesn't mention: the browser reads them out of
the manifest, so no tag in the document points at them and the sweep missed all four. An
installed app launched with no signal had no icon to show for itself.
They're read out of the manifest now — not typed into a list here, for exactly the same
reason. iOS is the exception: it reads none of the manifest's icons and only
`<link rel="apple-touch-icon">`, which *is* in the document, so the sweep picks up the
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
Reading the manifest offline is deliberately cache-first, which is backwards from how the
rest of the worker thinks. A plain `fetch` for it failed on every offline launch and
logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be
visible, in exchange for re-reading four paths that were already cached.
### A lock is easy to take and easy to leave holding
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
screen to say why.
So the rule is narrow — held only while betting is actually open — and the tests are
mostly about letting go:
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
fires many times a second while a street is being bet.
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
past the "already holding one?" check and the second sentinel is never stored, so
nothing can release it.
- **A hand that ends inside the request leaves nothing held.** A fold can end the hand
between asking for a lock and being handed one, and the sentinel that arrives afterwards
belongs to a hand that no longer exists.
- **The browser taking it back leaves us able to take it again.** This is not an edge
case: the browser releases the lock itself every time the document stops being visible,
and never gives it back. A tool that takes one at the start of the hand and assumes it
still has it works until the dealer checks a rule on their phone.
#### A fake that wasn't installed, and four tests that agreed
The first run of these had eight failures and, worse, one pass: "a browser without the API
is left alone entirely" was green from the start.
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
tests failed honestly and the unsupported-browser test passed for precisely the wrong
reason. `Object.defineProperty` installs it properly.
Then five faults were injected and all five were caught: dropping the `pending` guard,
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
between hands, and reading a missing `keepScreenAwake` as off instead of on.
### Asking the browser to keep the night
"The night lives in this browser and nowhere else" was always true and always missing its
second half. `localStorage` is evictable: a browser is free to clear site data when the
device runs short, and Safari deletes script-created storage after seven days without a
visit. A table saved at last month's game can simply not be there at this one, with
nothing having gone wrong and nobody having deleted anything.
`navigator.storage.persist()` is the way to say this data is not that, and every browser
wants the request to come from something the dealer did — so it fires on **Post blinds &
deal**, the moment this stops being a setup screen and starts being a night somebody could
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
asks. A no is not an error worth reporting at a table, so the only thing that changes is
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+6
View File
@@ -38,6 +38,11 @@ const ActionBar = load("actionBar");
// is plain state work and testable here. Only render() and open() touch the DOM.
const CardPicker = load("cardPicker");
// The wake lock's rule about when a table needs the screen is plain state work too.
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -93,6 +98,7 @@ module.exports = {
HoldemState,
ActionBar,
CardPicker,
WakeLock,
makeTable,
chipsOnTable,
contributions,
+276
View File
@@ -0,0 +1,276 @@
// When the tool asks the phone to stay awake, and — the part that actually matters —
// when it lets go again.
//
// A dealer's phone lies face-up on the felt all night. Left alone it dims and locks
// between the flop and the turn, every hand, and a dozen rounds of making the next step
// readable at a glance count for nothing behind a lock screen.
//
// The risk runs the other way too, and it is the one worth testing. A wake lock that is
// taken and not dropped is a phone that burns its battery through a break, a re-buy, and
// the twenty minutes everyone spends arguing about pizza — and the dealer has no way to
// tell that is what is happening. So the rule is narrow: held only while betting is
// actually open, and only while the dealer wants it.
//
// The browser side of this is a stand-in below rather than a real navigator, which lets
// the tests ask the questions a real table asks: what happens when the hand ends inside
// the request, what happens when the phone takes the lock back on its own, and what
// happens when the browser simply says no.
const test = require("node:test");
const { describe, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert");
const { WakeLock, HoldemState, makeTable } = require("./load");
// A stand-in for navigator.wakeLock that records what was asked of it. `deny` makes
// every request reject, the way a phone in battery saver does.
const fakeWakeLock = ({ deny = false } = {}) => {
const api = {
requests: 0,
released: 0,
live: [],
deny,
wakeLock: {
request: async () => {
api.requests += 1;
if (api.deny) throw new Error("denied");
const listeners = [];
const sentinel = {
released: false,
addEventListener: (name, fn) => { if (name === "release") listeners.push(fn); },
release: async () => {
if (sentinel.released) return;
sentinel.released = true;
api.released += 1;
listeners.forEach(fn => fn());
},
// What the browser does on its own when the tab stops being visible.
dropFromOutside: () => {
sentinel.released = true;
listeners.forEach(fn => fn());
},
};
api.live.push(sentinel);
return sentinel;
},
},
};
return api;
};
// Settles the promises `sync()` deliberately doesn't return — it is called from
// renderAll, which is synchronous, so the acquiring happens off to one side.
const settle = () => new Promise(resolve => setImmediate(resolve));
// Node has had its own `globalThis.navigator` since v21, defined as a getter — so a
// plain assignment to it does nothing at all and throws nothing either. Every test in
// here that thought it had installed a fake would have been quietly measuring Node's
// real navigator, which has no wakeLock, and passing for the wrong reason.
const setNavigator = value => {
Object.defineProperty(globalThis, "navigator", {
value, configurable: true, writable: true,
});
};
let realNavigator;
beforeEach(() => {
realNavigator = globalThis.navigator;
WakeLock.sentinel = null;
WakeLock.wanted = false;
WakeLock.pending = false;
WakeLock.told = true; // The toast is DOM; it is checked in the browser.
WakeLock.hooks = null;
});
afterEach(() => {
setNavigator(realNavigator);
});
const useFake = api => {
setNavigator(api);
return api;
};
// A table mid-hand, which is the only state that wants the screen.
const dealtIn = () => {
const state = makeTable({ seatCount: 4, stack: 200 });
state.bettingOpen = true;
return state;
};
describe("the rule about when a table needs the screen", () => {
test("a hand in progress does", () => {
assert.equal(WakeLock.shouldHold(dealtIn()), true);
});
test("a table between hands does not", () => {
const state = dealtIn();
state.bettingOpen = false;
assert.equal(WakeLock.shouldHold(state), false);
});
test("a dealer who turned it off does not, mid-hand or otherwise", () => {
const state = dealtIn();
state.keepScreenAwake = false;
assert.equal(WakeLock.shouldHold(state), false);
});
test("no table at all does not", () => {
assert.equal(WakeLock.shouldHold(null), false);
assert.equal(WakeLock.shouldHold(undefined), false);
});
test("a table that has never heard of the setting still does", () => {
// Every saved table from before this existed. The migration fills it in, but the
// rule itself has to read a missing value as yes or the first hand after an
// update runs without it.
const state = dealtIn();
delete state.keepScreenAwake;
assert.equal(WakeLock.shouldHold(state), true);
});
});
describe("taking the lock and giving it back", () => {
test("a hand starting takes one", async () => {
const api = useFake(fakeWakeLock());
WakeLock.sync(dealtIn());
await settle();
assert.equal(api.requests, 1);
assert.ok(WakeLock.sentinel);
});
test("the hand ending gives it back", async () => {
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
state.bettingOpen = false;
WakeLock.sync(state);
await settle();
assert.equal(api.released, 1);
assert.equal(WakeLock.sentinel, null);
});
test("turning the setting off mid-hand gives it back", async () => {
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
state.keepScreenAwake = false;
WakeLock.sync(state);
await settle();
assert.equal(api.released, 1);
});
test("a whole street of renders takes exactly one", async () => {
// sync() runs off renderAll, which fires many times a second while a street is
// being bet. Every one of those must be a no-op, or the tool leaks a sentinel
// per render and the screen stays lit long after the hand is over.
const api = useFake(fakeWakeLock());
const state = dealtIn();
for (let i = 0; i < 20; i += 1) {
WakeLock.sync(state);
await settle();
}
assert.equal(api.requests, 1);
assert.equal(api.released, 0);
});
test("two syncs in the same tick still take exactly one", async () => {
// The `pending` guard. Without it both calls get past the "already holding one?"
// check, and the second sentinel is never stored — so nothing can release it and
// the screen stays awake for the rest of the night.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
WakeLock.sync(state);
await settle();
await settle();
assert.equal(api.requests, 1);
});
});
describe("the awkward moments", () => {
test("a hand that ends while the request is in flight leaves nothing held", async () => {
// A fold can end the hand between asking for the lock and being given it. The
// sentinel that arrives afterwards belongs to a hand that no longer exists.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
state.bettingOpen = false;
WakeLock.sync(state);
await settle();
await settle();
assert.equal(WakeLock.sentinel, null, "nothing should still be held");
assert.equal(api.released, 1, "the one that arrived late should have been let go");
});
test("the browser taking it back leaves us able to take it again", async () => {
// What happens every time the dealer switches apps to check a rule. The browser
// releases the lock itself and never gives it back; if we go on believing we
// hold one, the screen sleeps for the rest of the night.
const api = useFake(fakeWakeLock());
const state = dealtIn();
WakeLock.sync(state);
await settle();
api.live[0].dropFromOutside();
assert.equal(WakeLock.sentinel, null, "the release listener should have cleared it");
WakeLock.sync(state);
await settle();
assert.equal(api.requests, 2, "coming back to the tool should take a fresh one");
});
test("a browser that says no is not an error", async () => {
const api = useFake(fakeWakeLock({ deny: true }));
const state = dealtIn();
await assert.doesNotReject(async () => {
WakeLock.sync(state);
await settle();
});
assert.equal(api.requests, 1);
assert.equal(WakeLock.sentinel, null);
});
test("a browser without the API is left alone entirely", async () => {
useFake({});
assert.equal(WakeLock.supported(), false);
const state = dealtIn();
await assert.doesNotReject(async () => {
WakeLock.sync(state);
await settle();
});
assert.equal(WakeLock.wanted, false);
});
});
describe("what a saved table remembers about the screen", () => {
test("a new table keeps the screen on", () => {
assert.equal(HoldemState.createDefault().keepScreenAwake, true);
});
test("a table saved before the setting existed keeps the screen on", () => {
const saved = HoldemState.createDefault();
delete saved.keepScreenAwake;
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
});
test("a dealer who turned it off still has it off after a reload", () => {
const saved = HoldemState.createDefault();
saved.keepScreenAwake = false;
assert.equal(HoldemState.migrate(saved).keepScreenAwake, false);
});
test("junk in that field is read as on rather than trusted", () => {
const saved = HoldemState.createDefault();
saved.keepScreenAwake = "yes please";
assert.equal(HoldemState.migrate(saved).keepScreenAwake, true);
});
});