v0.5.2 — the splash's multiplayer door opens, and knows whether it should
The "Play multiplayer" door on index.html had sat disabled, labelled
"Coming soon", since before the server existed — Phases 2 through 4 built
a working lobby and nothing ever linked to it. Loading the site landed on
the same solitaire splash whether a real multiplayer server was behind it
or not, with no visible way in. Found packaging Phase 6 for StartOS.
The door is now a live link to ./play.html?lobby, and main.ts's start()
routes ?lobby straight to the lobby screen — the same showScreen('lobby');
runLobby(beginRemote) the in-game Multiplayer button already used —
instead of dealing a solitaire game first.
GET /api/health is new, and exists to be failed. The same dist/ ships both
served by src/server/ and uploaded as flat files by deploy-web.ts, and the
bundle is identical either way (D4), so the page cannot know from its own
build which it is; every other route 404s an unknown path exactly as a
static host does, so nothing distinguished them. The splash probes it on
load and closes the door when nothing names itself in reply.
The door starts open and only ever closes, deliberately: a wrong "no
server" is the bug above again — invisible, and it strands a player who
does have one — while a wrong "there is one" costs a click and a lobby
that says it cannot connect. The reply must name itself rather than merely
return 200, or a host answering every path with its index page would pass.
Verified: tsc clean; 659 tests pass (656 + 3); /api/health exercised live
against a running server — 200 with the right body, unauthenticated, while
an unknown path and a wrong method both still 404, which is what makes the
probe discriminate at all.
The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
This commit is contained in:
@@ -2515,6 +2515,149 @@ describe('the static build', () => {
|
||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
||||
});
|
||||
|
||||
it('opens the multiplayer door from the splash, straight into the lobby', () => {
|
||||
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
|
||||
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
|
||||
// real server in front of them saw the same dead card as everyone else.
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/play\.html\?lobby"/, 'the splash does not link to the lobby');
|
||||
assert.doesNotMatch(splash, /Coming soon/, 'the multiplayer door still says it is unbuilt');
|
||||
// The probe addresses all three by id; renaming one silently un-wires it, which is precisely
|
||||
// the class of break that put "Coming soon" on a working feature for two releases.
|
||||
for (const id of ['door-multiplayer', 'door-multiplayer-go', 'door-multiplayer-blurb']) {
|
||||
assert.ok(splash.includes(`id="${id}"`), `the splash is missing #${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The probe decides whether the door above is real, so both of its answers are worth a test —
|
||||
* and the OPEN one especially: a false "no server here" is invisible to the player and is the
|
||||
* exact failure this release exists to remove.
|
||||
*/
|
||||
it('closes the multiplayer door only when nothing answers the health probe', async () => {
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
|
||||
);
|
||||
const loadSplash = async (fetchImpl: () => Promise<unknown>): Promise<Record<string, unknown>> => {
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (): Record<string, unknown> => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
textContent: '', innerHTML: '', hidden: false, href: './play.html?lobby',
|
||||
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), has: (c: string) => classes.has(c) },
|
||||
removeAttribute: (k: string) => { if (k === 'href') delete (els.get('door-multiplayer') ?? {})['href']; },
|
||||
addEventListener: () => {}, appendChild: () => {},
|
||||
};
|
||||
};
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make());
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make(), addEventListener: () => {},
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['fetch'] = fetchImpl;
|
||||
await import(`file://${join(dist, 'web/splash.js')}?t=${Date.now()}${Math.random()}`);
|
||||
// The probe settles a microtask or two after load; nothing in the page waits on it.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
return els.get('door-multiplayer')!;
|
||||
};
|
||||
|
||||
const answered = await loadSplash(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, service: 'station-master' }) }),
|
||||
);
|
||||
assert.equal(
|
||||
(answered['classList'] as { has: (c: string) => boolean }).has('disabled'), false,
|
||||
'the door closed even though a Station Master server answered',
|
||||
);
|
||||
|
||||
const silent = await loadSplash(() => Promise.reject(new Error('nothing there')));
|
||||
assert.equal(
|
||||
(silent['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
|
||||
'the door stayed open with no server behind it',
|
||||
);
|
||||
assert.equal(silent['href'], undefined, 'the closed door is still clickable');
|
||||
|
||||
// A host that answers EVERY path 200 — with its own index page, or with some unrelated
|
||||
// service's JSON — sails straight past an `ok` check, so the body has to name itself. Both
|
||||
// shapes below reach the door by a DIFFERENT route through the probe than the rejection
|
||||
// above does, which is the whole reason they are here: an earlier version of this test
|
||||
// asserted only the rejection path and passed with the naming check deleted outright.
|
||||
const unnamed = await loadSplash(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ service: 'something-else', ok: true }) }),
|
||||
);
|
||||
assert.equal(
|
||||
(unnamed['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
|
||||
'a 200 from some other service was taken for a Station Master server',
|
||||
);
|
||||
|
||||
const unparseable = await loadSplash(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.reject(new Error('not json')) }),
|
||||
);
|
||||
assert.equal(
|
||||
(unparseable['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
|
||||
'a 200 that is not JSON at all was taken for a server',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* `?lobby` is what the door above hands to `main.ts`. Asserted on the BUILT bundle rather than
|
||||
* the source, because the one thing that broke here was a browser-vs-Node API difference
|
||||
* (`params.has` against the stub below), which only a load of the real output catches.
|
||||
*/
|
||||
it('routes ?lobby straight to the lobby screen instead of dealing a solitaire game', async () => {
|
||||
const shown: string[] = [];
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
|
||||
);
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (id: string): Record<string, unknown> => ({
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
|
||||
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
|
||||
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
|
||||
querySelector: () => null, querySelectorAll: () => [],
|
||||
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
|
||||
});
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make('?'), addEventListener: () => {},
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['location'] = { search: '?lobby' };
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
g['URLSearchParams'] = class {
|
||||
search: string;
|
||||
constructor(search: string) { this.search = search; }
|
||||
get(k: string): string | null {
|
||||
// A bare flag with no `=value` is still present — `?lobby` is exactly that shape.
|
||||
if (new RegExp(`[?&]${k}(?=$|[&=])`).test(this.search)) {
|
||||
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
// The lobby screen opens an EventSource as soon as it is shown; nothing here drives a game.
|
||||
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
|
||||
|
||||
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby screen stayed hidden under ?lobby');
|
||||
assert.equal(els.get('gameui')!['hidden'], true, 'it dealt a solitaire game instead of opening the lobby');
|
||||
});
|
||||
|
||||
it('serves a page that loads the game as a module', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
||||
|
||||
Reference in New Issue
Block a user