Fix caching, Limits placement, and explain the board better
This commit is contained in:
@@ -416,6 +416,46 @@ describe('card coverage', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
||||
it('refuses to build beyond the Limits sign', () => {
|
||||
// §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point.
|
||||
// Offering the square PAST it produced, from one straight laid at (0,2):
|
||||
// limits · Whistle Post · limits · straight · limits
|
||||
// — track on the far side of the sign, and a second sign planted beyond that.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.turn.option = 'draw';
|
||||
|
||||
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
|
||||
const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 };
|
||||
for (const placement of [beyondEast, beyondWest]) {
|
||||
assert.notEqual(
|
||||
check(s, 0, { type: 'track.lay', geometry: 'straight', hand: 'none', placement, variant: 0 }),
|
||||
null,
|
||||
`(${placement.row},${placement.col}) is outside the Limits and must not be placeable`,
|
||||
);
|
||||
}
|
||||
|
||||
// The sign itself IS legal — that is how the Running Track grows.
|
||||
assert.equal(
|
||||
check(s, 0, {
|
||||
type: 'track.lay', geometry: 'straight', hand: 'none',
|
||||
placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0,
|
||||
}),
|
||||
null,
|
||||
'laying on the Limits sign must be how the track extends',
|
||||
);
|
||||
|
||||
// The district hangs below the Running Track and is not bounded by the Limits at all.
|
||||
assert.equal(
|
||||
check(s, 0, {
|
||||
type: 'track.lay', geometry: 'straight', hand: 'none',
|
||||
placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
|
||||
}),
|
||||
null,
|
||||
'Secondary Track below the Office must stay legal',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the Limits at the ends, never stranded mid-track', () => {
|
||||
// Found by watching a replay: a district grew to
|
||||
// (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
|
||||
|
||||
+199
-3
@@ -9,12 +9,13 @@ 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 { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||
import {
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
handPlayable,
|
||||
currentActor,
|
||||
fromSave,
|
||||
newGame,
|
||||
@@ -238,6 +239,47 @@ describe('the page explains itself', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('says what every card on the BOARD does, in readable words', () => {
|
||||
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
|
||||
// and the explanation that was visible while it sat in hand disappears exactly when it starts
|
||||
// mattering. Play a long way in so every card kind reaches the grid.
|
||||
const game = newGame(111);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
const pick =
|
||||
options.find((o) => o.type === 'card.play' && o.placement) ??
|
||||
options.find((o) => o.type === 'track.lay') ??
|
||||
options[0]!;
|
||||
if (!submit(game, pick)) break;
|
||||
}
|
||||
|
||||
const cells = view(game).cells;
|
||||
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
|
||||
for (const c of cells) {
|
||||
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
|
||||
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
|
||||
assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
|
||||
assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells out which way a turnout will and will not let a train run', () => {
|
||||
// §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
|
||||
// "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
|
||||
const game = newGame(3);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||
assert.match(cell.what, /stem east/);
|
||||
assert.match(cell.what, /diverges south/);
|
||||
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -251,6 +293,89 @@ describe('the page explains itself', () => {
|
||||
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
|
||||
});
|
||||
|
||||
it('marks cards that cannot be played yet, and says why', () => {
|
||||
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
|
||||
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
|
||||
// action that did nothing.
|
||||
const game = newGame(111);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
|
||||
|
||||
const f = view(game);
|
||||
const playable = handPlayable(game);
|
||||
assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');
|
||||
|
||||
const upgrades = f.hand
|
||||
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
|
||||
.filter((c) => /upgrade/.test(c.name));
|
||||
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
|
||||
for (const u of upgrades) {
|
||||
// Only Depot is reachable from a Whistle Post.
|
||||
if (/Depot/.test(u.name)) continue;
|
||||
assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
|
||||
assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with the buttons about what is playable', () => {
|
||||
// The flag is derived from legalActions, so it must never disagree with the offered actions.
|
||||
const game = newGame(7);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
const flags = handPlayable(game);
|
||||
const offered = new Set(
|
||||
actionGroups(game)
|
||||
.options.filter((o) => o.type === 'card.play')
|
||||
.map((o) => (o as { cardId: string }).cardId),
|
||||
);
|
||||
hand.forEach((id, k) => {
|
||||
assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
|
||||
});
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('never puts an internal tray id in front of a player', () => {
|
||||
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
|
||||
// sharpest decision in the game, phrased in internal identifiers.
|
||||
const game = newGame(430);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
for (const line of game.log) {
|
||||
assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells out what each clearance ruling costs', () => {
|
||||
const game = newGame(5);
|
||||
const s = game.state;
|
||||
s.trays.set('tray2', {
|
||||
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('tray3', {
|
||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
||||
|
||||
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
|
||||
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
|
||||
for (const label of [allow, hold]) {
|
||||
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
|
||||
assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
|
||||
}
|
||||
assert.match(allow, /collision/, 'granting clearance does not mention the risk');
|
||||
assert.match(hold, /safe|loses/, 'holding does not mention the cost');
|
||||
});
|
||||
|
||||
it('states the objective and whether you are keeping up', () => {
|
||||
const f = view(newGame(430));
|
||||
assert.equal(f.objective.target, 20);
|
||||
@@ -332,7 +457,7 @@ describe('the static build', () => {
|
||||
// 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]!;
|
||||
const spec = m[1]!.split('?')[0]!;
|
||||
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}`);
|
||||
@@ -387,9 +512,18 @@ describe('the static build', () => {
|
||||
return node;
|
||||
};
|
||||
|
||||
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
|
||||
// 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(
|
||||
(m) => m[1]!,
|
||||
),
|
||||
);
|
||||
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);
|
||||
},
|
||||
@@ -437,9 +571,71 @@ describe('the static build', () => {
|
||||
assert.match(html, /ghost/, 'no empty square was offered as a destination');
|
||||
});
|
||||
|
||||
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
||||
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
|
||||
// dropped an element the old script still asked for, so the page threw `missing element:
|
||||
// target` and never started. Filenames never change, so without a version query a static host
|
||||
// will happily mix two builds.
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
|
||||
assert.match(entry, /\?v=/, 'the entry script is not cache-busted');
|
||||
|
||||
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);
|
||||
|
||||
let checked = 0;
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
||||
assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
|
||||
});
|
||||
|
||||
it('resolves every busted import to a real file', () => {
|
||||
// Appending a query must not break resolution: the path before `?` still has to exist.
|
||||
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);
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
||||
const target = resolve(dirname(f), m[1]!.split('?')[0]!);
|
||||
assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('asks only for elements the page actually has', () => {
|
||||
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
|
||||
// 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 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');
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
||||
}
|
||||
});
|
||||
|
||||
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.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']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
||||
|
||||
Reference in New Issue
Block a user