Compare commits

...
2 Commits
Author SHA1 Message Date
Jesse 51710498f5 v0.5.6 — seats counted from 1, a name you can read, and a line that stops repeating
Three things off the first proper look at a live table.

The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right inside —
it indexes seating, the seats array and every route, and none of that
changes — but nobody sitting at a table calls their chair "seat 0". There
were four of these rather than one: the lobby list, the topline's Seat N
for a remote session, the presence banner's fallback name, and the admin
summary's. All go through a single seatLabel now, and a test fails the
build if any "Seat ${...}" interpolates a raw seat again, since the
conversion has to happen in exactly one place or the two conventions drift
apart. Verified by mutation — putting the raw seat back fails the suite.

seatLabel lives in sim/view.ts, not web/game.ts. Putting it in game.ts was
the first attempt and test/session.test.ts caught it: the page may not
import values from that module, because they are the local engine by
another name and importing one reopens the Phase 1 boundary. The test was
right and the placement was wrong.

.bs-name.bs-turn carried font-weight:700 over a base of 600. At 11px a
monospace face has to be synthesised the rest of the way and the extra ink
lands as blur, so the one name you most need to read was the one you could
not. The bump is gone; amber against #e6e9ee was always doing the work, and
blue "(you)" and amber "their move" stay clearly distinct without it.

The west-to-east chain under the Division map now shows only during Day 1
Stage 1. It answers who is where and why, which is a question you have once
— at the start, when the chain has just been rolled and the names are new.
By Stage 2 the map has been answering it for a while and the line is
something to read past.

675 tests pass (673 + 2).
2026-08-21 20:53:17 -04:00
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.

Three things lined up. start() enters a remembered session WITHOUT checking
it still exists — that is what makes reconnection seamless, and it is why
the lobby was skipped. The v0.5.4 update had refused to resume that game,
its save being recorded under v0.5.3 and the engine-version check being
exact (D7). And createRemoteSession had no onerror at all, so EventSource
retried the resulting 404 forever in silence while frame stayed null and
nothing rendered. The only escape was clearing site data, and nothing on
screen said so.

v0.5.3's Manage Game -> End had just widened the same dead end: it closes
every watcher's stream, so a player whose game an administrator ended would
sit frozen on a stale board indefinitely, for exactly the same reason.

GET /api/session?token= is new: a cheap yes/no on whether a token still
names a live game. EventSource fires error identically for a transient blip
— the expected shape of a game idle for minutes (§9) — and for a 404 it
will retry forever, and exposes no status code either way, so the client
asks rather than guessing. Only a definite 404 closes the stream and
reports the game gone; a flaky network still self-heals.

The page then forgets the stored session, says why (ended by an
administrator, or the service was updated, which does not carry games
across), and drops into the lobby. Forgetting the token is what stops the
next load repeating it. It also stops rendering nothing while it waits —
"… connecting to the game" sits in the presence banner until the first push
arrives, because a page showing nothing is indistinguishable from a broken
one, which is what this looked like.

Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every
game in progress, and v0.5.4's changes were rendering only. The refusal is
right, but the test is exact equality against the PACKAGE version, which
moves for reasons unrelated to the rules. Three options costed; the
recommendation is to replay the save and refuse only if an intent actually
rejects — the real question rather than a proxy for it, and a full replay
measures ~100 ms.

Verified live: /api/session answers 200 for a seated token, 404 once an
administrator ends the game, 404 for a garbage token, and /api/stream 404s
in the same state — which is the response EventSource had been retrying
silently. 673 tests pass.
2026-08-21 17:55:59 -04:00
11 changed files with 283 additions and 12 deletions
+83
View File
@@ -19,6 +19,89 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.6 — 2026-08-21
Three things off the first proper look at a live table.
### Seats are counted from 1
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right *inside* — it indexes `seating`,
the seats array and every route, and none of that changes — but nobody sitting down at a table
calls their chair "seat 0". The displayed number is now the one a player would say out loud.
There were four of these, not one: the lobby list, the topline's `Seat N` for a remote session, the
presence banner's fallback name, and the admin summary's. All go through a single `seatLabel`, and
a test fails the build if any `Seat ${…}` interpolates a raw seat again — the conversion has to
happen at exactly one place or the two conventions drift. (The StartOS package's **Games in
Progress** action had the same leak and is fixed alongside.)
`seatLabel` lives in `view.ts` rather than `web/game.ts`, because the page may not import values
from that module — they are the local engine by another name, and `test/session.test.ts` fails the
build for it. Putting it there was the first attempt; the test was right and the placement was
wrong.
### The current player's name was unreadable
`.bs-name.bs-turn` carried `font-weight:700` over a base of 600. At 11px a monospace face has to be
synthesised the rest of the way, and the extra ink lands as blur rather than as weight — so the one
name you most need to read was the one you could not. The weight bump is gone; amber against
`#e6e9ee` was always doing the work, and blue "(you)" and amber "their move" stay clearly distinct
without it.
### The seating chain says its piece once
The west-to-east line under the Division map explains who is where and why, which is a question you
have once — at the start, when the chain has just been rolled and the names are new. It now shows
only during Day 1 Stage 1. By Stage 2 the map itself has been answering it for a while, and a
permanent line restating it is a permanent line to read past.
---
## 0.5.5 — 2026-08-21
One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game
with no lobby and no controls, and the board was blank.
### A remembered session for a game the server no longer has
Three things lined up. `start()` enters a remembered multiplayer session **without checking it
still exists** — that is what makes reconnection seamless, and it is why the lobby was skipped.
The v0.5.4 update had **refused to resume** that game, because the save was recorded under v0.5.3
and the engine-version check is exact (D7). And `createRemoteSession` had **no `onerror` at all**,
so `EventSource` retried the resulting 404 forever, in silence, while `frame` stayed null and the
page rendered nothing.
The only escape was clearing site data, and nothing on screen said so.
The same dead end had just been widened by v0.5.3's **Manage Game → End**, which closes every
watcher's stream: a player whose game an administrator ended would sit frozen on a stale board
indefinitely, for the same reason.
**The fix.** `GET /api/session?token=…` is new — a cheap yes/no on whether a token still names a
live game. `EventSource` fires `error` identically for a transient blip (the expected shape of a
game idle for minutes, §9) and for a 404 it will retry forever, and exposes no status code either
way, so the client asks. Only a definite 404 closes the stream and reports the game gone; a flaky
network still self-heals as before.
The page then forgets the stored session, says why — ended by an administrator, or the service was
updated, which does not carry games across — and drops into the lobby. Forgetting the token is what
stops the next load repeating it.
It also stops rendering nothing while it waits: "… connecting to the game" sits in the presence
banner until the first push arrives, because a page showing nothing is indistinguishable from a
page that is broken, which is precisely what this looked like.
### Recorded, not fixed
`TODO.md` now carries the underlying problem: **three releases in a row destroyed every game in
progress, and v0.5.4's changes were rendering only.** The refusal is right — a move legal under old
rules may not be legal under new ones — but the test is exact equality against the *package*
version, which moves for reasons that have nothing to do with the rules. Three options are costed
there; the recommendation is to replay the save and refuse only if an intent actually rejects,
since that answers the real question rather than a proxy for it, and a full replay measures ~100 ms.
---
## 0.5.4 — 2026-08-21
Six things found by playing the StartOS build, all of them about the game telling you what it
+44
View File
@@ -29,6 +29,9 @@ Queued 2026-08-21, from playing the StartOS build:
this is not purely a UI job.
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
two are read by nothing at all. Reasoning in Multiplayer below.
6. **Stop every release destroying every game in progress** — the check is exact equality against
the package version, and most releases do not touch the rules. Reasoning in Multiplayer below;
the recommendation is to replay-and-see rather than to guess from a version number.
---
@@ -500,6 +503,47 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table").
- [ ] **EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS, AND MOST RELEASES DO NOT CHANGE THE RULES.**
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
list of intents (D5), so loading one means replaying those intents through the current engine.
A move that was legal under the old rules may be rejected under the new ones, and a
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
back makes the games loadable again. That is all correct. The problem is only that the test is
**exact equality against the package version**, which moves for reasons that have nothing to
do with the rules.
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
player. It stops being acceptable the moment other people are seated: their game is destroyed
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
Three ways out, cheapest first:
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
the save instead of `package.json`'s version, and raised only when a change can alter
whether an intent is legal. v0.5.4 would not have touched it and both games would have
survived. Cheapest and the least clever, but it is a judgement call on every release, and
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
than the one it replaces.
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
"this release breaks saves" an explicit act rather than the default.
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
if an intent actually rejects. This is the honest test and needs no judgement at all: it
answers the real question ("does this game still replay?") instead of a proxy for it. It
costs a full replay per game on boot, which is ~100 ms per finished game (measured
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
free. The work is in reporting a partial failure well: the game is intact up to the
rejected intent, and a player would probably rather resume there than lose it entirely.
**(3) is the one worth doing**, and (1)/(2) are what to reach for only if a replay ever
becomes too slow to do on boot. Decide before the next release that changes a rule, not after.
- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.5.4",
"version": "0.5.6",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+21
View File
@@ -481,6 +481,27 @@ export function startServer(opts: ServerOptions): void {
// -- The running game (token-authenticated) ------------------------------------------------
/**
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
*
* A browser remembers its session in `localStorage` and re-enters the game on the next load
* without asking, which is what makes reconnection seamless — and what leaves it stranded
* when the game is gone. `EventSource` cannot report a status code and retries a 404
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
* game legitimately disappears under a player: an engine-version bump refuses to resume it
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
*/
if (url.pathname === '/api/session' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const live = ps ? games.get(ps.gameId) : undefined;
if (!ps || !live) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
+2 -2
View File
@@ -26,7 +26,7 @@ import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, subm
import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts';
import { snapshot, seatLabel } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts';
@@ -298,7 +298,7 @@ function buildSession(
day: game.state.clock.day,
stage: game.state.clock.stage,
phase: game.state.clock.phase,
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${actor}` },
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${seatLabel(actor)}` },
};
},
};
+7 -2
View File
@@ -1145,8 +1145,13 @@ export const BOARD_CSS = `
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. */
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
seconds and which railroad is yours never does.
NO WEIGHT BUMP. This was 700 and the name came out fuzzy to the point of being unreadable: the
base is already 600, so at 11px a monospace face has to be synthesised the rest of the way, and
the extra ink lands as blur rather than as weight. Amber against #e6e9ee is the distinction; it
does not need help. */
.bs-name.bs-turn{fill:#f0b64a}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
+16
View File
@@ -275,6 +275,22 @@ export type RunningCardView = {
trains: TrainChip[];
};
/**
* A seat as a PERSON counts them, from 1.
*
* Seats are zero-based everywhere inside — `PlayerIndex`, `seating`, the seats array, every route
* — and that must not change, since it is what indexes into all of them. But nobody sitting down
* at a table calls their chair "seat 0", so the number on screen is the one they would say out
* loud. Every user-facing seat goes through here, so the two conventions cannot drift apart.
*
* It lives here rather than in `web/game.ts` because the page may not import values from that
* module — they are the local engine by another name, and `test/session.test.ts` fails the build
* for it. This is presentation, which is what `view.ts` is for.
*/
export function seatLabel(seat: number): number {
return seat + 1;
}
export type DivisionView = {
kind: string;
label: string;
+2 -1
View File
@@ -13,6 +13,7 @@
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { defaultMultiplayerConfig } from './game.ts';
import { seatLabel } from '../sim/view.ts';
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
@@ -85,7 +86,7 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
}
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`;
html += `<div class="lb-seat"><span class="dim">Seat ${seatLabel(seat)}</span><span class="who">${who}</span>${action}</div>`;
}
$('lb-seats').innerHTML = html;
+45 -4
View File
@@ -8,6 +8,7 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts';
import { seatLabel } from '../sim/view.ts';
import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
@@ -333,7 +334,11 @@ function showScreen(which: 'lobby' | 'gameui'): void {
function beginRemote(ready: LobbyReady): void {
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
showScreen('gameui');
session = createRemoteSession(ready.token, ready.seat);
// Nothing can be drawn until the first push arrives, and a page showing nothing at all is
// indistinguishable from a page that is broken — which is exactly what a dead session used to
// look like, forever.
$('presence').textContent = '… connecting to the game';
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
@@ -342,6 +347,31 @@ function beginRemote(ready: LobbyReady): void {
session.subscribe(render);
}
/**
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
*
* Two things legitimately destroy a game under a seated player, and both are by design: an
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
* short of clearing site data.
*
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
*/
function abandonRemote(): void {
localStorage.removeItem(REMOTE_KEY);
showScreen('lobby');
$('presence').textContent = '';
runLobby(beginRemote);
const note = document.getElementById('lb-create-err');
if (note) {
note.textContent =
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
'service was updated, which does not carry games in progress across. Create or join a new one.';
}
}
/**
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
@@ -352,6 +382,9 @@ function beginRemote(ready: LobbyReady): void {
function start(): void {
const params = new URLSearchParams(location.search);
// Entered without checking it still exists — deliberately. Verifying up front would mean an
// await before anything renders on the common path, where the game IS still there; instead the
// session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = loadRemote();
if (remembered) {
beginRemote(remembered);
@@ -418,7 +451,15 @@ function applyCapabilities(): void {
function renderSeatingChain(f: Frame): void {
const el = document.getElementById('seating-chain');
if (!el) return;
if (f.players.length < 2) {
/**
* ONLY WHILE THE GAME IS STILL OPENING.
*
* This answers "who is where, and why" — which is a question you have once, at the start, when
* the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been
* answering it for a while, and a permanent line restating it is a permanent line to read past.
*/
const opening = f.day === 1 && f.stage === 1;
if (f.players.length < 2 || !opening) {
el.textContent = '';
return;
}
@@ -445,7 +486,7 @@ function renderPresence(f: Frame): void {
const away = session
.presence()
.filter((p) => !p.connected)
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`);
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${seatLabel(p.seat)}`);
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
}
@@ -487,7 +528,7 @@ function render(): void {
obj.className = 'pace';
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`;
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
renderHouseRules(f.houseRules);
// -- division
+34 -1
View File
@@ -226,7 +226,16 @@ type Push = {
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
*/
export function createRemoteSession(token: string, seat: PlayerIndex): Session {
export function createRemoteSession(
token: string,
seat: PlayerIndex,
/**
* Called once when this session's game is established to be gone for good, so the page can stop
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
* forever and reports nothing, and `frame` never becomes non-null.
*/
onGone?: () => void,
): Session {
let frame: Frame | null = null;
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
@@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session {
const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`);
/**
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
*
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
* the cheap question that separates them: only a definite 404 closes the stream and reports the
* game gone, so a flaky network still self-heals.
*/
let reportedGone = false;
source.onerror = () => {
if (reportedGone) return;
void fetch(`/api/session?${qs}`)
.then((r) => {
if (r.status !== 404 || reportedGone) return;
reportedGone = true;
source.close();
onGone?.();
})
.catch(() => {
// The probe itself failed, so this says nothing about the game — leave the retry running.
});
};
source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push;
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
+28 -1
View File
@@ -14,11 +14,12 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
import { createLocalSession } from '../src/web/session.ts';
import { seatLabel } from '../src/sim/view.ts';
/**
* Drive a session by always taking the first offered action.
@@ -218,6 +219,32 @@ describe('the page stays on the near side of the boundary', () => {
});
});
describe('seats are counted from 1 wherever a person reads them', () => {
it('seatLabel shifts the zero-based index the whole engine uses', () => {
assert.deepEqual([0, 1, 2, 3].map(seatLabel), [1, 2, 3, 4]);
});
it('no user-facing "Seat N" bypasses it', () => {
// The internal convention is zero-based and must stay that way — it indexes `seating`, the
// seats array and every route. The DISPLAYED number is the one a player would say out loud, so
// the two have to be converted at exactly one place; anything interpolating a raw seat into a
// "Seat …" string has quietly reintroduced "Seat 0".
const roots = ['src/web', 'src/sim', 'src/server'];
const offenders: string[] = [];
for (const dir of roots) {
const base = join(import.meta.dirname, '..', dir);
for (const name of readdirSync(base, { recursive: true, encoding: 'utf8' })) {
if (!name.endsWith('.ts')) continue;
const text = readFileSync(join(base, name), 'utf8');
for (const m of text.matchAll(/`[^`]*Seat \$\{([^}]*)\}/g)) {
if (!m[1]!.includes('seatLabel')) offenders.push(`${dir}/${name}: ${m[0]!.slice(0, 60)}`);
}
}
}
assert.deepEqual(offenders, [], 'a seat is shown to a player without going through seatLabel');
});
});
describe('capabilities say what only a local session can do', () => {
it('offers undo, a local save and a new deal', () => {
// The page hides these rather than calling them and failing. A server can offer none of them: it