Compare commits
1
Commits
2eca9de09f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd300ac154 |
+44
-3
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -68,7 +68,48 @@ function buildStamp(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stamp = buildStamp();
|
const stamp = buildStamp();
|
||||||
const page = readFileSync(join(root, 'src/web/index.html'), 'utf8').replaceAll('__BUILD__', stamp);
|
|
||||||
|
/**
|
||||||
|
* Cache-bust every module.
|
||||||
|
*
|
||||||
|
* A returning visitor gets a fresh index.html and STALE JavaScript, because a static host caches
|
||||||
|
* .js and the filenames never change. That is not a cosmetic staleness: it mixes new HTML with old
|
||||||
|
* code, and the first mismatch is fatal. It happened on the first real deploy — index.html had
|
||||||
|
* dropped an element that the cached main.js still asked for, so the page threw
|
||||||
|
* `missing element: target` and the game never started.
|
||||||
|
*
|
||||||
|
* Appending the build tag to every relative import makes each deploy a new set of URLs, so a
|
||||||
|
* browser cannot serve half of one build and half of another.
|
||||||
|
*/
|
||||||
|
const tag = encodeURIComponent(stamp.split(' · ')[1] ?? stamp);
|
||||||
|
|
||||||
|
function bustImports(dir: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
count += bustImports(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.name.endsWith('.js')) continue;
|
||||||
|
const src = readFileSync(full, 'utf8');
|
||||||
|
const next = src.replace(
|
||||||
|
/^(\s*(?:import|export)[^'"\n]*?from\s+['"])(\.[^'"]+\.js)(['"])/gm,
|
||||||
|
(_m, head: string, spec: string, tail: string) => `${head}${spec}?v=${tag}${tail}`,
|
||||||
|
);
|
||||||
|
if (next !== src) {
|
||||||
|
writeFileSync(full, next);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const busted = bustImports(dist);
|
||||||
|
|
||||||
|
const page = readFileSync(join(root, 'src/web/index.html'), 'utf8')
|
||||||
|
.replaceAll('__BUILD__', stamp)
|
||||||
|
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
|
||||||
writeFileSync(join(dist, 'index.html'), page);
|
writeFileSync(join(dist, 'index.html'), page);
|
||||||
|
|
||||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||||
@@ -88,4 +129,4 @@ writeFileSync(
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`built -> ${dist}\n ${stamp}`);
|
console.log(`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}`);
|
||||||
|
|||||||
@@ -341,6 +341,15 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
|||||||
existing.standing.length === 0;
|
existing.standing.length === 0;
|
||||||
if (existing && !isMovableSign) return false;
|
if (existing && !isMovableSign) return false;
|
||||||
|
|
||||||
|
// §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
|
||||||
|
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
||||||
|
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
||||||
|
// limits · Whistle Post · limits · straight · limits
|
||||||
|
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
|
||||||
|
if (coord.row === area.runningRow) {
|
||||||
|
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
|
||||||
|
}
|
||||||
|
|
||||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||||
for (const p of ports) {
|
for (const p of ports) {
|
||||||
if (!hasPort(card, p)) continue;
|
if (!hasPort(card, p)) continue;
|
||||||
|
|||||||
+14
-3
@@ -87,10 +87,17 @@ export type Narration = {
|
|||||||
export type NarrateContext = {
|
export type NarrateContext = {
|
||||||
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
|
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
|
||||||
cardName?: (id: string) => string;
|
cardName?: (id: string) => string;
|
||||||
|
/**
|
||||||
|
* Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read
|
||||||
|
* "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the
|
||||||
|
* game, phrased in internal identifiers.
|
||||||
|
*/
|
||||||
|
trainName?: (trayId: TrayId) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||||
|
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||||
|
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
// -- clock
|
// -- clock
|
||||||
@@ -290,14 +297,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
case 'clearanceRequested':
|
case 'clearanceRequested':
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
|
text:
|
||||||
|
`SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
|
||||||
|
`that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
|
||||||
|
`into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
|
||||||
|
`waits where it is, losing time but safe.`,
|
||||||
};
|
};
|
||||||
case 'clearanceGiven':
|
case 'clearanceGiven':
|
||||||
return {
|
return {
|
||||||
tone: e.allow ? 'bad' : 'plain',
|
tone: e.allow ? 'bad' : 'plain',
|
||||||
text: e.allow
|
text: e.allow
|
||||||
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
|
? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision`
|
||||||
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
|
: `Clearance DENIED — ${train(e.trainId)} holds where it is`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- passengers
|
// -- passengers
|
||||||
|
|||||||
+5
-2
@@ -35,7 +35,7 @@ export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip }
|
|||||||
export { cardName, describeIntent, snapshot } from './view.ts';
|
export { cardName, describeIntent, snapshot } from './view.ts';
|
||||||
import type { Frame } from './view.ts';
|
import type { Frame } from './view.ts';
|
||||||
import type { Decision } from './view.ts';
|
import type { Decision } from './view.ts';
|
||||||
import { cardName, describeDecision, snapshot } from './view.ts';
|
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Frame shape — only what the viewer draws
|
// Frame shape — only what the viewer draws
|
||||||
@@ -61,7 +61,10 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
};
|
};
|
||||||
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
|
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
|
||||||
const frames: Frame[] = [];
|
const frames: Frame[] = [];
|
||||||
const narrateCtx = { cardName: (id: string) => cardName(s, id) };
|
const narrateCtx = {
|
||||||
|
cardName: (id: string) => cardName(s, id),
|
||||||
|
trainName: (id: string) => trainName(s, id),
|
||||||
|
};
|
||||||
|
|
||||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||||
let didSomething = false;
|
let didSomething = false;
|
||||||
|
|||||||
+102
-7
@@ -17,6 +17,7 @@ import {
|
|||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
modifierProfile,
|
modifierProfile,
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
trainProfile,
|
trainProfile,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameState } from '../engine/state.ts';
|
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
|
||||||
import type { TrackGeometry } from '../engine/content.ts';
|
import type { TrackGeometry } from '../engine/content.ts';
|
||||||
import { variantsFor } from '../engine/track.ts';
|
import { variantsFor } from '../engine/track.ts';
|
||||||
import type { Impediment } from './narrate.ts';
|
import type { Impediment } from './narrate.ts';
|
||||||
@@ -46,6 +47,14 @@ export type CellView = {
|
|||||||
tray: string | null;
|
tray: string | null;
|
||||||
cars: string[];
|
cars: string[];
|
||||||
facility: FacilityView | null;
|
facility: FacilityView | null;
|
||||||
|
/**
|
||||||
|
* What this card DOES, now that it is on the board.
|
||||||
|
*
|
||||||
|
* A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
|
||||||
|
* "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
|
||||||
|
* the moment it starts mattering.
|
||||||
|
*/
|
||||||
|
what: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FacilityView = {
|
export type FacilityView = {
|
||||||
@@ -168,7 +177,7 @@ function facilityView(
|
|||||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||||
return {
|
return {
|
||||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
|
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
|
||||||
commodity: facilityCarType(f) ?? '?',
|
commodity: facilityCarType(f) ?? '?',
|
||||||
flow: f.kind === 'passenger'
|
flow: f.kind === 'passenger'
|
||||||
? 'passengers on and off'
|
? 'passengers on and off'
|
||||||
@@ -285,8 +294,16 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
return `Red Flags on ${i.trayId}`;
|
return `Red Flags on ${i.trayId}`;
|
||||||
case 'maneuver.flyingSwitch':
|
case 'maneuver.flyingSwitch':
|
||||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||||
case 'mainline.clearance':
|
case 'mainline.clearance': {
|
||||||
return i.allow ? 'grant clearance' : 'refuse clearance';
|
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
||||||
|
// that granting it risks a rear-ender, or that refusing merely costs time.
|
||||||
|
const pending = s.clock.pendingDecision;
|
||||||
|
const who = pending ? trainName(s, pending.train) : 'the train';
|
||||||
|
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
|
||||||
|
return i.allow
|
||||||
|
? `ALLOW — ${who} follows ${ahead} into the same Subdivision (risks a collision, −5 Revenue)`
|
||||||
|
: `HOLD — ${who} waits where it is: safe, but it loses the Stage`;
|
||||||
|
}
|
||||||
case 'draw.fromDepartment': {
|
case 'draw.fromDepartment': {
|
||||||
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
|
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
|
||||||
// the choice between a visible card and a blind draw is unmakeable without it.
|
// the choice between a visible card and a blind draw is unmakeable without it.
|
||||||
@@ -351,8 +368,11 @@ export function snapshot(
|
|||||||
let label: string;
|
let label: string;
|
||||||
if (g.kind === 'office') label = officeProfile(area.tier).name;
|
if (g.kind === 'office') label = officeProfile(area.tier).name;
|
||||||
else if (g.kind === 'limits') label = 'Limits';
|
else if (g.kind === 'limits') label = 'Limits';
|
||||||
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
|
// Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
|
||||||
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
|
// guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
|
||||||
|
// as the fallback put `refinery` and `viscosityBreakers` on the board.
|
||||||
|
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
|
||||||
|
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
|
||||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||||
else label = geometryLabel(g.geometry);
|
else label = geometryLabel(g.geometry);
|
||||||
|
|
||||||
@@ -365,6 +385,7 @@ export function snapshot(
|
|||||||
kind,
|
kind,
|
||||||
label,
|
label,
|
||||||
running: row === area.runningRow,
|
running: row === area.runningRow,
|
||||||
|
what: cellDescription(card, officeProfile(area.tier).name),
|
||||||
enhancements: card.enhancements.map(prettyKey),
|
enhancements: card.enhancements.map(prettyKey),
|
||||||
tray: trayAt.get(key) ?? null,
|
tray: trayAt.get(key) ?? null,
|
||||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||||
@@ -512,11 +533,16 @@ export function cardDescription(s: GameState, id: string): string {
|
|||||||
}
|
}
|
||||||
case 'office': {
|
case 'office': {
|
||||||
const p = officeProfile(k.tier);
|
const p = officeProfile(k.tier);
|
||||||
|
// Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
|
||||||
|
// Office is a Depot. Without saying so, the card looks playable and simply never is.
|
||||||
|
const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
|
||||||
|
const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
|
||||||
return (
|
return (
|
||||||
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
|
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
|
||||||
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
|
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
|
||||||
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
|
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
|
||||||
(p.isControlPoint ? ' · a Control Point' : '')
|
(p.isControlPoint ? ' · a Control Point' : '') +
|
||||||
|
prereq
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'freightFacility': {
|
case 'freightFacility': {
|
||||||
@@ -544,6 +570,75 @@ export function cardDescription(s: GameState, id: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The train riding a Crew Tray, for anything that has to talk about it. */
|
||||||
|
export function trainName(s: GameState, trayId: string): string {
|
||||||
|
const tray = s.trays.get(trayId);
|
||||||
|
if (!tray) return trayId;
|
||||||
|
if (tray.trainNumber === null) return 'the local crew';
|
||||||
|
return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a card on the board does, in one short line. */
|
||||||
|
function cellDescription(card: TrackCard, officeName: string): string {
|
||||||
|
const g = card.geometry;
|
||||||
|
switch (g.kind) {
|
||||||
|
case 'limits':
|
||||||
|
return 'the edge of your control area — lay track HERE to extend the Running Track';
|
||||||
|
case 'office': {
|
||||||
|
const p = officeProfile(
|
||||||
|
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
|
||||||
|
(p.isPassengerFacility
|
||||||
|
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
|
||||||
|
: 'not a Passenger Facility — no porters, no passenger boxes')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'modifier': {
|
||||||
|
const m = modifierProfile(g.modifier);
|
||||||
|
const adds: string[] = [];
|
||||||
|
if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
|
||||||
|
if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
|
||||||
|
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
|
||||||
|
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||||
|
return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
|
||||||
|
}
|
||||||
|
case 'spaceUse':
|
||||||
|
return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
|
||||||
|
case 'facility': {
|
||||||
|
const f = card.facility;
|
||||||
|
if (!f) return 'a facility';
|
||||||
|
if (f.kind === 'passenger') return 'passengers board and detrain here';
|
||||||
|
const p = industryProfile(g.facility as never);
|
||||||
|
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
|
||||||
|
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load`;
|
||||||
|
}
|
||||||
|
case 'track':
|
||||||
|
switch (g.geometry) {
|
||||||
|
case 'straight':
|
||||||
|
return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
|
||||||
|
case 'curved':
|
||||||
|
case 'sharpCurved': {
|
||||||
|
const stem = g.hand === 'left' ? 'west' : 'east';
|
||||||
|
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||||
|
return `curve — joins the ${stem} end to a leg running south${cost}`;
|
||||||
|
}
|
||||||
|
case 'turnout': {
|
||||||
|
const t = g.turnout;
|
||||||
|
if (!t) return 'turnout';
|
||||||
|
const dir = (p: string): string =>
|
||||||
|
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||||
|
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
|
||||||
|
return (
|
||||||
|
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
|
||||||
|
`a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
|
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
|
||||||
function facilityLabel(key: string): string {
|
function facilityLabel(key: string): string {
|
||||||
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
|
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
|
||||||
|
|||||||
+31
-2
@@ -32,7 +32,14 @@ import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
|||||||
import { narrate } from '../sim/narrate.ts';
|
import { narrate } from '../sim/narrate.ts';
|
||||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||||
// which would pull node:fs into a browser bundle.
|
// which would pull node:fs into a browser bundle.
|
||||||
import { cardName, describeIntent, geometryLabel, snapshot, variantLabel } from '../sim/view.ts';
|
import {
|
||||||
|
cardName,
|
||||||
|
describeIntent,
|
||||||
|
geometryLabel,
|
||||||
|
snapshot,
|
||||||
|
trainName,
|
||||||
|
variantLabel,
|
||||||
|
} from '../sim/view.ts';
|
||||||
import type { TrackGeometry } from '../engine/content.ts';
|
import type { TrackGeometry } from '../engine/content.ts';
|
||||||
import { variantsFor } from '../engine/track.ts';
|
import { variantsFor } from '../engine/track.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
@@ -258,6 +265,25 @@ export function submit(game: Game, intent: Intent): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||||
|
*
|
||||||
|
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
|
||||||
|
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
|
||||||
|
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
|
||||||
|
*/
|
||||||
|
export function handPlayable(game: Game): boolean[] {
|
||||||
|
const hand = game.state.decks.hands.get(0) ?? [];
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) return hand.map(() => false);
|
||||||
|
const playable = new Set(
|
||||||
|
legalActions(game.state, actor)
|
||||||
|
.filter((i) => i.type === 'card.play')
|
||||||
|
.map((i) => (i as Extract<Intent, { type: 'card.play' }>).cardId),
|
||||||
|
);
|
||||||
|
return hand.map((id) => playable.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
/** The board as the replay draws it, so the live game and the replay agree. */
|
/** The board as the replay draws it, so the live game and the replay agree. */
|
||||||
export function view(game: Game): Frame {
|
export function view(game: Game): Frame {
|
||||||
return snapshot(game.state, [], null);
|
return snapshot(game.state, [], null);
|
||||||
@@ -267,7 +293,10 @@ function record(game: Game, events: GameEvent[]): void {
|
|||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
||||||
if (e.type === 'actorChanged') continue;
|
if (e.type === 'actorChanged') continue;
|
||||||
const n = narrate(e, { cardName: (id) => cardName(game.state, id) });
|
const n = narrate(e, {
|
||||||
|
cardName: (id) => cardName(game.state, id),
|
||||||
|
trainName: (id) => trainName(game.state, id),
|
||||||
|
});
|
||||||
game.log.push({ text: n.text, tone: n.tone });
|
game.log.push({ text: n.text, tone: n.tone });
|
||||||
}
|
}
|
||||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,8 @@ header b{font-size:16px}
|
|||||||
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
||||||
.handcard:first-child{border-top:0}
|
.handcard:first-child{border-top:0}
|
||||||
.handcard .what{font-size:11px;line-height:1.35}
|
.handcard .what{font-size:11px;line-height:1.35}
|
||||||
|
.handcard.unplayable{opacity:.55}
|
||||||
|
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
|
||||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||||
@@ -50,6 +52,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
|
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
|
||||||
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
|
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
|
||||||
.cell .nm{font-size:12px;font-weight:600}
|
.cell .nm{font-size:12px;font-weight:600}
|
||||||
|
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
|
||||||
|
.cell{cursor:help}
|
||||||
.coord{font-size:10px}
|
.coord{font-size:10px}
|
||||||
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||||
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
||||||
@@ -107,7 +111,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<main>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||||
<section><h2>Your Office Area</h2><div id="grid"></div></section>
|
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
|
||||||
<section><h2>History</h2><div id="log"></div></section>
|
<section><h2>History</h2><div id="log"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -8,6 +8,7 @@
|
|||||||
import type { Game } from './game.ts';
|
import type { Game } from './game.ts';
|
||||||
import {
|
import {
|
||||||
actionMenu,
|
actionMenu,
|
||||||
|
handPlayable,
|
||||||
currentActor,
|
currentActor,
|
||||||
fromSave,
|
fromSave,
|
||||||
newGame,
|
newGame,
|
||||||
@@ -130,12 +131,15 @@ function render(): void {
|
|||||||
: '<div></div>';
|
: '<div></div>';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The full explanation goes in `title` so hovering any card says what it does, with a short
|
||||||
|
// version printed on the card itself — a name alone tells a player nothing once it is played.
|
||||||
cells +=
|
cells +=
|
||||||
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
|
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
|
||||||
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
|
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
|
||||||
`${spots ? ` data-at="${r},${c}"` : ''}>` +
|
`${spots ? ` data-at="${r},${c}"` : ''} title="${esc(cell.label)} — ${esc(cell.what)}">` +
|
||||||
`<div class="nm">${esc(cell.label)}</div>` +
|
`<div class="nm">${esc(cell.label)}</div>` +
|
||||||
`<div class="dim coord">(${r},${c})</div>` +
|
`<div class="dim coord">(${r},${c})</div>` +
|
||||||
|
`<div class="dim what">${esc(short(cell.what))}</div>` +
|
||||||
(cell.enhancements.length
|
(cell.enhancements.length
|
||||||
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
|
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
|
||||||
: '') +
|
: '') +
|
||||||
@@ -194,16 +198,18 @@ function render(): void {
|
|||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
||||||
const cardRow = (name: string, why: string): string =>
|
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
||||||
`<div class="handcard"><b>${esc(name)}</b>` +
|
`<div class="handcard${playable === false ? ' unplayable' : ''}"><b>${esc(name)}</b>` +
|
||||||
|
(playable === false ? ' <span class="tag">not playable yet</span>' : '') +
|
||||||
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
|
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
|
|
||||||
|
const canPlay = handPlayable(game);
|
||||||
$('hand').innerHTML = f.hand.length
|
$('hand').innerHTML = f.hand.length
|
||||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '')).join('')
|
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||||
: '<span class="dim">empty</span>';
|
: '<span class="dim">empty</span>';
|
||||||
$('depts').innerHTML = f.departments.length
|
$('depts').innerHTML = f.departments.length
|
||||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '')).join('')
|
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||||
: '<span class="dim">none</span>';
|
: '<span class="dim">none</span>';
|
||||||
|
|
||||||
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
||||||
@@ -235,6 +241,12 @@ function render(): void {
|
|||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First clause only — the cards are small, and the full text is on the tooltip. */
|
||||||
|
function short(what: string): string {
|
||||||
|
const head = what.split(' · ')[0] ?? what;
|
||||||
|
return head.length > 64 ? head.slice(0, 61) + '…' : head;
|
||||||
|
}
|
||||||
|
|
||||||
function boxes(items: string[], cap: number): string {
|
function boxes(items: string[], cap: number): string {
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
||||||
|
|||||||
@@ -416,6 +416,46 @@ describe('card coverage', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
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', () => {
|
it('keeps the Limits at the ends, never stranded mid-track', () => {
|
||||||
// Found by watching a replay: a district grew to
|
// 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) …
|
// (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 assert from 'node:assert/strict';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
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 { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||||
import {
|
import {
|
||||||
actionGroups,
|
actionGroups,
|
||||||
actionMenu,
|
actionMenu,
|
||||||
|
handPlayable,
|
||||||
currentActor,
|
currentActor,
|
||||||
fromSave,
|
fromSave,
|
||||||
newGame,
|
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', () => {
|
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
|
// 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.
|
// 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`);
|
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', () => {
|
it('states the objective and whether you are keeping up', () => {
|
||||||
const f = view(newGame(430));
|
const f = view(newGame(430));
|
||||||
assert.equal(f.objective.target, 20);
|
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.
|
// 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;
|
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
|
||||||
for (const m of src.matchAll(imports)) {
|
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.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
|
||||||
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${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(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
|
||||||
@@ -387,9 +512,18 @@ describe('the static build', () => {
|
|||||||
return node;
|
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>;
|
const g = globalThis as Record<string, unknown>;
|
||||||
g['document'] = {
|
g['document'] = {
|
||||||
getElementById: (id: string) => {
|
getElementById: (id: string) => {
|
||||||
|
if (!served.has(id)) return null;
|
||||||
if (!els.has(id)) els.set(id, make());
|
if (!els.has(id)) els.set(id, make());
|
||||||
return els.get(id);
|
return els.get(id);
|
||||||
},
|
},
|
||||||
@@ -437,9 +571,71 @@ describe('the static build', () => {
|
|||||||
assert.match(html, /ghost/, 'no empty square was offered as a destination');
|
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', () => {
|
it('serves a page that loads the game as a module', () => {
|
||||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
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');
|
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
|
||||||
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
||||||
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user