various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)

This commit is contained in:
Jesse
2026-07-31 22:03:13 -04:00
parent 160190da3f
commit 2eca9de09f
16 changed files with 2399 additions and 401 deletions
+448
View File
@@ -0,0 +1,448 @@
/**
* The playable browser build.
*
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
* at the new position — so "it compiles" is never mistaken for "it plays".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import {
actionGroups,
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from '../src/web/game.ts';
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
const game = newGame(seed);
let turns = 0;
for (; turns < maxTurns; turns++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
const first = groups[0]!.actions[0]!;
if (!submit(game, options[first.index]!)) break;
}
return { game, turns };
}
describe('the browser game plays', () => {
it('reaches the end of a game through the same calls the page makes', () => {
const { game, turns } = playThrough(77);
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
assert.equal(game.state.status, 'finished');
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
});
it('never offers an action the engine then refuses', () => {
// The action list comes from legalActions, which delegates to check — so every button on the
// page must be accepted. A rejection here means the UI and the rules disagree.
const game = newGame(21);
for (let i = 0; i < 300; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
const pick = groups[groups.length - 1]!.actions[0]!;
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
}
});
it('offers every legal action somewhere, dropping none', () => {
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
// legal move becomes unreachable in a way that is very hard to notice.
const game = newGame(5);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (options.length === 0) break;
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
const kinds = new Set(options.map((o) => o.type));
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
submit(game, options[[...shown][0]!]!);
}
});
it('restores a saved game to the same position', () => {
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
const game = newGame(909);
for (let i = 0; i < 80; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
submit(game, options[groups[0]!.actions[0]!.index]!);
}
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.equal(restored.state.status, game.state.status);
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
});
it('is deterministic — the same seed deals the same game', () => {
const a = playThrough(4242).game;
const b = playThrough(4242).game;
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
assert.deepEqual(view(a).cells, view(b).cells);
});
});
describe('the action menu presents choices the way they are made', () => {
it('offers a card ONCE, with its locations underneath', () => {
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
// tell which square each referred to. Subject first, location second.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const items = menu.placeable.flatMap((g) => g.items);
assert.ok(items.length > 0, 'nothing placeable was offered');
const keys = items.map((i) => i.subjectKey);
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
for (const item of items) {
const labels = item.spots.map((sp) => sp.label);
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
}
});
it('loses no legal action in the reshuffle', () => {
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
const game = newGame(88);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const reachable = new Set([
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
]);
const kinds = new Set(menu.options.map((o) => o.type));
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
submit(game, menu.options[[...reachable][0]!]!);
}
});
it('names the card in every face-up slot', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
assert.ok(draw, 'no draw group');
for (const a of draw!.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
}
assert.ok(
draw!.actions.some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
it('never offers a train card a place on the board', () => {
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
// rotations each — all identical, because the placement was accepted and then ignored.
const game = newGame(555);
for (let i = 0; i < 200; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
if (o.type !== 'card.play') continue;
const kind = game.state.cards.get(o.cardId)?.kind.kind;
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
assert.equal(o.placement, undefined, 'a train card was offered a board square');
}
}
submit(game, options[0]!);
}
});
it('reports what is left of the personal track supply', () => {
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
// board alone could never answer "how many straights have I got left".
const game = newGame(31);
const supply = view(game).trackSupply;
assert.ok(supply.length > 0, 'no track supply reported');
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const items = actionMenu(game).placeable.flatMap((g) => g.items);
assert.ok(items.length > 0);
for (const it of items) {
for (const sp of it.spots) {
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
}
}
});
it('highlights squares OUTSIDE the current district', () => {
// The commonest placement is just beyond the existing cards — that is what extending means. A
// grid sized only to the cards already down would highlight nothing exactly when it matters.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const cells = view(game).cells;
const minCol = Math.min(...cells.map((c) => c.col));
const maxCol = Math.max(...cells.map((c) => c.col));
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
assert.ok(
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
'no legal spot lies outside the current cards — the bounds test would be vacuous',
);
});
});
describe('the page explains itself', () => {
it('says what every card in hand and every face-up slot does', () => {
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
// this text already existed in the content tables without reaching the screen.
for (const seed of [555, 430, 7, 99]) {
const f = view(newGame(seed));
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
f.hand.forEach((name, i) => {
const what = f.handWhat[i]!;
assert.ok(what.length > 0, `${name} has no description`);
// camelCase leaking to the screen is the recurring failure here.
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
});
f.departments.forEach((name, i) => {
if (name === '—') return;
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
});
}
});
it('describes every card kind in the deck, not just the easy ones', () => {
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
// show as a blank line under a card name only for the seeds that happen to deal it.
const game = newGame(1);
const seen = new Set<string>();
for (const [id, card] of game.state.cards) {
const what = cardDescription(game.state, id);
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
seen.add(card.kind.kind);
}
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
});
it('states the objective and whether you are keeping up', () => {
const f = view(newGame(430));
assert.equal(f.objective.target, 20);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 20/);
assert.match(f.objective.note, /Days? left/);
});
it('explains what each Local Operations option spends', () => {
// The most consequential decision of the Stage, previously labelled "choose switch".
const game = newGame(555);
const choices = actionGroups(game)
.options.filter((o) => o.type === 'localOps.choose')
.map((o) => describeIntent(game.state, o));
assert.ok(choices.length > 0);
for (const c of choices) {
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
}
});
it('says which way a piece will point, never "rotation 2"', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const track = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((i) => /straight|curve|turnout/.test(i.subject));
assert.ok(track.length > 0, 'no track pieces offered');
for (const item of track) {
for (const sp of item.spots) {
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
}
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
const perSquare = new Map<string, number>();
for (const sp of item.spots) {
const k = `${sp.coord.row},${sp.coord.col}`;
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
}
if ([...perSquare.values()].some((n) => n > 1)) {
assert.ok(
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
`${item.subject} offers two orientations on one square without naming them`,
);
}
}
});
});
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' });
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
});
it('emits no import that a browser cannot resolve', () => {
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
// those to .js, and nothing may reach for a bare module or a node: builtin.
const files: string[] = [];
const walk = (dir: string): void => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(join(dir, e.name));
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
}
};
walk(dist);
assert.ok(files.length > 5, 'suspiciously few emitted files');
for (const f of files) {
const src = readFileSync(f, 'utf8');
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
// called A, which is the kind of false alarm that trains you to ignore a failing test.
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
for (const m of src.matchAll(imports)) {
const spec = m[1]!;
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
}
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
}
});
it('renders clickable highlights on the board when a card is picked', async () => {
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
// nothing about whether the page draws them.
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
let html = '';
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
// 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 node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
querySelectorAll(sel: string) {
const hit = cache.get(sel);
if (hit) return hit;
const out: Record<string, unknown>[] = [];
const re = sel.startsWith('[')
? /data-at="([^"]*)"/g
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const data: Record<string, string> = {};
if (sel.startsWith('[')) data['at'] = m[1]!;
else {
const attr = /data-(\w+)="([^"]*)"/g;
let a: RegExpExecArray | null;
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
}
out.push({ dataset: data, onclick: null });
}
cache.set(sel, out);
return out;
},
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => {
html = v;
cache = new Map();
},
});
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
};
g['location'] = { search: '?seed=555' };
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),
};
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
// than compiling them.
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
const actions = els.get('actions')!;
const grid = els.get('grid')!;
// Take the draw option, then pick the first placeable subject.
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const nodes = fn.call(el, sel);
const target = nodes[0];
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
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');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js">/);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
}
});
});