Draw the board as track, split the site into three pages, and fix curve geometry
Adds SVG board rendering shared by the game and the replay, a splash page with a shareable replay directory, hover tooltips for reference detail, and makes curves two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
+99
-8
@@ -24,6 +24,9 @@ import {
|
||||
view,
|
||||
} from '../src/web/game.ts';
|
||||
|
||||
const root = join(import.meta.dirname, '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
/** Play a whole game by always taking the first offered action. */
|
||||
function playThrough(seed: number, maxTurns = 20_000) {
|
||||
const game = newGame(seed);
|
||||
@@ -490,9 +493,54 @@ describe('the page explains itself', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replays are saves', () => {
|
||||
it('rebuilds the exact position a save describes', () => {
|
||||
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
|
||||
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
|
||||
// replay shows something the player never saw.
|
||||
const game = newGame(430);
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
const restored = fromSave(toSave(game));
|
||||
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
||||
assert.equal(restored.state.clock.day, game.state.clock.day);
|
||||
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
||||
assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
|
||||
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
|
||||
});
|
||||
|
||||
it('stays small enough to email', () => {
|
||||
const game = newGame(202);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
|
||||
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
|
||||
});
|
||||
|
||||
it('publishes every save in public/replays with an index', () => {
|
||||
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
|
||||
const manifestPath = join(dist, 'replays/manifest.json');
|
||||
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
|
||||
for (const entry of manifest) {
|
||||
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
|
||||
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
|
||||
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
|
||||
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the static build', () => {
|
||||
const root = join(import.meta.dirname, '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
it('builds, and needs nothing but a static host', () => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
@@ -543,8 +591,27 @@ describe('the static build', () => {
|
||||
// would drop the handlers the page assigns — the test would then report "no button" for a
|
||||
// page that works perfectly.
|
||||
let cache = new Map<string, Record<string, unknown>[]>();
|
||||
const found = new Map<string, Record<string, unknown>>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
// The board is SVG now, and highlighting works by finding a card group and adding a class.
|
||||
// The stub has to model that much or it cannot see whether highlighting happened at all.
|
||||
querySelector(sel: string) {
|
||||
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
|
||||
if (!m) return null;
|
||||
const key = `${m[1]}:${m[2]}`;
|
||||
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
|
||||
if (!found.has(key)) {
|
||||
const classes = new Set<string>();
|
||||
found.set(key, {
|
||||
onclick: null,
|
||||
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
|
||||
classes,
|
||||
});
|
||||
}
|
||||
return found.get(key);
|
||||
},
|
||||
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 0),
|
||||
querySelectorAll(sel: string) {
|
||||
const hit = cache.get(sel);
|
||||
if (hit) return hit;
|
||||
@@ -581,17 +648,22 @@ describe('the static build', () => {
|
||||
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
|
||||
// would pass here and throw on load in a browser — the page would simply never start.
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
|
||||
(m) => m[1]!,
|
||||
),
|
||||
);
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
|
||||
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['location'] = { search: '?seed=555' };
|
||||
const store = new Map<string, string>();
|
||||
@@ -631,9 +703,11 @@ describe('the static build', () => {
|
||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
||||
|
||||
const html = String(grid['innerHTML']);
|
||||
assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
|
||||
assert.match(html, /data-at="/, 'highlighted squares carry no click target');
|
||||
assert.match(html, /ghost/, 'no empty square was offered as a destination');
|
||||
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
|
||||
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
||||
const lit = (grid['highlighted'] as () => unknown[])();
|
||||
const ghosts = /data-ghost="/.test(html);
|
||||
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
||||
});
|
||||
|
||||
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
||||
@@ -689,7 +763,7 @@ describe('the static build', () => {
|
||||
// Getting this wrong does not degrade the page, it stops the game starting at all.
|
||||
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
|
||||
// `again` is created by the game-over screen before it is looked up.
|
||||
present.add('again');
|
||||
@@ -698,8 +772,25 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serves all three pages, each stamped and cache-busted', () => {
|
||||
// The splash is the front door now; the game and the replay directory are separate pages.
|
||||
for (const [name, entry] of [
|
||||
['index.html', 'splash'],
|
||||
['play.html', 'main'],
|
||||
['replays.html', 'replays'],
|
||||
] as const) {
|
||||
const html = readFileSync(join(dist, name), 'utf8');
|
||||
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
|
||||
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
|
||||
}
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
|
||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
||||
});
|
||||
|
||||
it('serves a page that loads the game as a module', () => {
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
|
||||
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
||||
|
||||
Reference in New Issue
Block a user