Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass

This commit is contained in:
Jesse
2026-08-04 11:26:18 -04:00
parent ec792dfc15
commit c3c5c59a45
16 changed files with 1249 additions and 129 deletions
+137
View File
@@ -10,6 +10,143 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
the board.
Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed
its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so
the two disagreed the moment a legal square lay outside the played cards, which is every square that
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
y=99 on a canvas 103 tall, i.e. off it entirely.
`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail
of it. It was also imported by the replay and never called there.
A test walks six seeds and asserts every square the menu offers has a target drawn inside the
canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact
coordinates above.
### The railroad, heard
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the
conductor when a train is built. **Off by default** — everything here is synthesised rather than
recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should
not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers
require before audio may start.
**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static
folder that fetches nothing — there is a test asserting no page reaches an external host — so audio
would have to be committed to the repo, and a plausible whistle is not something to invent. A real
steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of
the steam, which three detuned partials and a band of filtered noise get most of the way to. The
bell is inharmonic partials struck twice, which is what separates a bell from a beep.
**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built
into the browser, needs no asset and works offline, so it says the words; where the platform has no
voice installed a two-note conductor's call takes its place rather than nothing happening.
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page
decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell
is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the
previous one ending and there is no previous one.
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55
whistles and 5 bells, and 5 Days produced 5 bells.
### Regions, drawn from what the crossing already cost
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The
`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region
a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants.
The map draws regions again without reinstating the mechanic, because the printed cards say how:
they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is
the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out
of what the engine already knows:
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
| crossing | drawn as | |
| --- | --- | --- |
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** |
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
at the exit, reading as a train that raced across and then waited — which is what the first version
did, and what the test now forbids.
`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything,
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
would change the answer and make the train jump backwards.
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew
moves there by Moves, not Stages, so it occupies a card outright.
### The Division map shows the whole route
The map drew one box per node, which collapsed each player's entire district into a single "Office"
tile — so the track a train actually runs along was invisible on the only view that shows where
trains are.
An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than
a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary
track a crossing train never touches. Sidings, industries and the load pipeline stay in the district
view, which is where they can be read.
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a
Division Point. A crew working BELOW the Running Track has no position on the through route, so it
is reported against the district — "2 switching below" — rather than drawn somewhere it is not.
Projecting a siding onto the through line would be a lie the map cannot support.
**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone,
two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop —
trains enter at one Division Point and leave at the other — so the shape is deliberately left open,
with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it
into a ring would promise a connection the rules do not have.
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3
and 4 players no two cells may overlap and none may fall outside the canvas.
### The district folds itself away
Auto-focus. The district is worth its vertical space during the phases that change it — Local
Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division
map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that
disappears entirely reads as broken.
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players
at the same table may reasonably want it set differently.
### The replay was storing the same things over and over
Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game.
The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells`
payload — `cells` is 62% of the file, and inside it the cost is elsewhere:
what 32% of cells long prose, identical on every turnout in the district
facility 24% of cells the SAME object already serialised in the frame's `facilities`
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
So all three are interned. A card's identity and its description are written once for the whole
recording and referenced by integer, and a cell points at its facility instead of carrying a second
copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and
any Enhancement laid on the card.
`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board
renderers use — a second copy inside the page's inline script could drift from the packing and the
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
exact function over every frame, resolving nulls the way the page does.
### Rolling stock was leaving the game
Four things were tried against bot revenue. **One of them was worth more than everything else in
+38 -2
View File
@@ -93,6 +93,39 @@ target is settled and freight carries its intended share.
## Not yet built
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
placeholder, not the finished sound. Sound therefore defaults to OFF.
- **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so
it is whatever system voice the player happens to have — a robot, not a conductor. A real
clip is the single biggest improvement available here.
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing,
a train pulling away. Needs licences that permit redistribution (CC0 or similar), files small
enough to commit, and a check that the "fetches nothing external" test still passes — assets
must be served from the site's own folder, never hot-linked.
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
a quieter game rather than a broken one.
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
deriving position from what the crossing already cost. The engine still models a crossing as a
countdown of Stages, so two things printed on the cards remain unimplemented:
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
has five named Start positions, and playing Brakeman is supposed to move your entry point
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
why the derived drawing looks right — but the mechanism is not the printed one, so a card
whose starts do not correspond to its speed would be drawn wrong.
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
with speeds and named entries, with crossing time DERIVED from the region walk.
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
source Start-position art for the ten card types before it can begin.
- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY
state, so in a multiplayer game two players may reasonably want it set differently and it must
never become part of game state. It currently resets on reload. Worth a settings object in
localStorage — auto-focus mode to start with, and whatever else earns a preference — kept
strictly separate from the save, which is the seed plus the intents and has to stay portable.
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
@@ -117,8 +150,11 @@ target is settled and freight carries its intended share.
## Smaller things
- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but
re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state.
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
runs the page's own unpacking function.
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
+1 -1
View File
@@ -357,7 +357,7 @@ function enterMainline(
tray.direction,
node.gradeUp ?? 'east',
);
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
}
+18 -1
View File
@@ -228,7 +228,24 @@ export type CrewTray = {
* A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
* regions the placeholder used are gone — the cells printed on the cards are decoration.
*/
export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction };
/**
* A train crossing a Mainline card.
*
* `stagesTotal` is what the crossing cost when the train ENTERED, and it exists so the Division map
* can say where on the card a train is. §2.1 divides a Mainline card into two regions and §8.2 has
* a train move one region per Stage; the engine models crossing as a countdown of Stages instead,
* so position has to be derived — and it cannot be recomputed later, because a modifier played onto
* the card mid-crossing would change the answer and make the train appear to jump backwards.
*
* Read by nothing that decides anything: no legality check, no movement, no bot. It records what
* already happened so the picture can be drawn.
*/
export type Transit = {
tray: TrayId;
stagesRemaining: number;
stagesTotal: number;
direction: Direction;
};
export type DivisionNode =
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
+283 -82
View File
@@ -29,82 +29,274 @@ export type BoardTrain = { label: string; consist: string[] };
* separated by thin seams, capacity legible because the lines can be counted.
*/
export function divisionSvg(nodes: DivisionView[]): string {
const COL = 190;
const TOP = 34;
const LANE = 30;
const width = Math.max(1, nodes.length) * COL;
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
const height = TOP + maxCap * LANE + 54;
/**
* THE WHOLE DIVISION, west to east, as one continuous route.
*
* It used to draw one box per node, which collapsed every player's district into a single
* "Office" tile — so the thing a train actually runs along, the Running Track between the Limits,
* was invisible on the only map that shows where trains are. Now an Office expands into its
* Running Track, Limits to Limits, and the route reads end to end:
*
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
*
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
* into a ring would promise a connection the rules do not have.
*
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
* anything outside its own body.
*/
const CW = { dp: 118, ml: 152, run: 78 };
const CH = 58;
const GAP = 6;
const PAD = 26;
const SIDE_GAP = 34;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
// -- flatten the route into drawable cells, remembering which belong to whose district --------
type Cell = {
kind: 'dp' | 'ml' | 'run';
label: string;
sub: string;
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
cap: number | null;
tip: string;
owner: number | null;
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number;
w: number;
x: number;
y: number;
};
const cells: Cell[] = [];
const sides: number[][] = [];
let side: number[] = [];
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
side.push(cells.length);
cells.push({ ...c, x: 0, y: 0 });
};
for (const n of nodes) {
if (n.kind === 'office') {
const cap = n.capacity;
const ad = n.trains.flat();
for (const rc of n.running ?? []) {
const isOffice = rc.kind === 'office';
push({
kind: 'run',
label: rc.label,
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
// A train standing at the Office occupies an A/D track, which is where it is.
trains: isOffice ? [...rc.trains, ...ad] : rc.trains,
cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
owner: n.owner ?? null,
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one.
regions: 0,
w: CW.run,
});
}
// A crew below the Running Track has no position ON it, so it is reported against the
// district rather than drawn somewhere it is not.
const below = n.switching ?? [];
if (below.length > 0) {
const last = cells[cells.length - 1];
if (last) last.sub = `${below.length} switching below`;
}
sides.push(side);
side = [];
continue;
}
const dp = n.kind === 'dp';
push({
kind: dp ? 'dp' : 'ml',
label: n.label,
sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`,
trains: n.trains.flat(),
cap: n.capacity,
tip: dp
? 'A Division Point — trains enter and leave the Division here, and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
owner: null,
// A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0),
w: dp ? CW.dp : CW.ml,
});
}
if (side.length > 0) sides.push(side);
// Each player's side carries their district and the Mainline card leading into it; whatever is
// left over (the last Mainline and the East DP) joins the final side.
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
const lanes: number[][] = [];
for (let i = 0; i < seats; i++) lanes.push([]);
sides.forEach((grp, i) => {
const target = Math.min(i, seats - 1);
for (const idx of grp) lanes[target]!.push(idx);
});
// -- lay the sides out around the table -------------------------------------------------------
// top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a
// horseshoe open to the west, and a square broken at the same place.
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
const runLen = (idxs: number[]): number =>
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
lanes.forEach((idxs, i) => {
const d = dir[i]!;
if (d === 'top' || d === 'bottom') {
const y = d === 'top' ? PAD : boardH - PAD - CH - 22;
const order = d === 'bottom' ? [...idxs].reverse() : idxs;
let x = PAD;
for (const idx of order) {
const c = cells[idx]!;
c.x = x;
c.y = y;
x += c.w + GAP;
}
} else {
const x = d === 'right' ? boardW - PAD - CW.run : PAD;
const order = d === 'left' ? [...idxs].reverse() : idxs;
let y = PAD + CH + SIDE_GAP;
for (const idx of order) {
const c = cells[idx]!;
c.x = x;
c.y = y;
c.w = CW.run;
y += CH + GAP;
}
}
});
// -- draw -------------------------------------------------------------------------------------
const rail = (x1: number, y: number, x2: number): string => {
let out =
let o =
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
for (let i = 0; i <= n; i++) {
const tx = x1 + ((x2 - x1) * i) / n;
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
o += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
}
return out;
return o;
};
const chip = (x: number, y: number, t: BoardTrain): string => {
const w = 30 + Math.min(t.consist.length, 4) * 9;
return (
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
);
// The same rail turned through ninety degrees, for the sides of the table.
const railV = (x: number, y1: number, y2: number): string => {
let o =
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
for (let i = 0; i <= n; i++) {
const ty = y1 + ((y2 - y1) * i) / n;
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
}
return o;
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
nodes.forEach((node, i) => {
const x0 = i * COL;
const x1 = x0 + COL;
const cap = node.capacity;
const lanes = cap ?? 3;
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
if (node.gradeUp) {
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
// The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
// the Mainline card two districts share, and it is the thing a table needs to see.
for (let i = 0; i + 1 < cells.length; i++) {
const a = cells[i]!;
const b = cells[i + 1]!;
const sameRow = Math.abs(a.y - b.y) < 1;
const sameCol = Math.abs(a.x - b.x) < 1;
if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x);
else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x);
else if (sameCol) {
// Stacked down one side of the table: still one straight run of track, not a turn.
const top = Math.min(a.y + CH, b.y + CH);
const bot = Math.max(a.y, b.y);
out += railV(a.x + a.w / 2, top, bot);
} else {
// A turn between sides: an elbow, so the route is visibly continuous around the table.
const ax = a.x + a.w / 2;
const bx = b.x + b.w / 2;
const ay = a.y + CH;
const by = b.y;
out += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
}
}
cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
// REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage.
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
for (let r = 0; r < c.regions; r++) {
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
}
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
// and labelled, rather than pretending to a number they do not have.
for (let k = 0; k < lanes; k++) {
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
}
const here = node.trains.flat();
here.forEach((t, k) => {
const lane = Math.min(k, lanes - 1);
const across = here.length > lanes ? (k - lane) * 34 : 0;
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
c.trains.forEach((t, k) => {
const w = 30 + Math.min(t.consist.length, 4) * 8;
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
const tx = slotX + spread;
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
}">` +
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
});
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
if (node.modifiers.length > 0) {
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
}
out += '</g>';
});
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
// what stops a seated layout being read as a loop.
const first = cells[0];
const last = cells[cells.length - 1];
if (first) {
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</text>`;
}
if (last && last !== first) {
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">trains leave</text>`;
}
out += '</svg>';
return out;
}
/**
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
*
* The through rail sits at a constant height on every card — the alignment the printed cards use —
* so abutting cards produce one unbroken line and a gap is visibly a gap.
*/
export function officeSvg(cells: CellView[], runningRow: number): string {
export function officeSvg(
cells: CellView[],
runningRow: number,
ghosts: { row: number; col: number }[] = [],
): string {
const W = 132;
const H = 96;
const PAD = 3;
@@ -113,9 +305,22 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
const rows = cells.map((c) => c.row);
const cols = cells.map((c) => c.col);
/**
* ONE COORDINATE SYSTEM, AND ONE CANVAS.
*
* Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in
* afterwards. It computed its own origin over cells PLUS spots while this function sized itself
* over cells alone, so the two disagreed the moment a legal square lay outside the played cards —
* and every legal square that extends the district does. Measured on seed 555: three placements
* offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The
* list said three, the board showed two, and the third could not be clicked.
*
* Sizing over cells AND ghosts together is what makes that class of bug impossible.
*/
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts];
if (all.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
const rows = all.map((c) => c.row);
const cols = all.map((c) => c.col);
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
@@ -254,44 +459,21 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
out += '</g>';
}
// A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame.
for (const g of ghosts) {
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
`</g>`;
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
return out;
}
/**
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
* placeholder — the commonest placement of all is onto a blank square, and without this the
* highlight would have nothing to attach to.
*/
export function ghostSvg(
spots: { row: number; col: number }[],
cells: CellView[],
runningRow: number,
): string {
const W = 132;
const H = 96;
const PAD = 3;
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
if (all.length === 0) return '';
const r1 = Math.max(...all.map((c) => c.row));
const c0 = Math.min(...all.map((c) => c.col));
let out = '';
for (const s of spots) {
const x = (s.col - c0) * (W + PAD);
const y = (r1 - s.row) * (H + PAD);
out +=
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
`</g>`;
}
void runningRow;
return out;
}
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
export const BOARD_CSS = `
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
@@ -308,6 +490,25 @@ export const BOARD_CSS = `
.bs-sec{fill:#161b21;stroke:#39424e}
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
/* THE DIVISION, drawn as the route rather than as a row of boxes.
Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains
come from and go to, a Mainline card is shared ground between two districts, and a Running Track
card belongs to one player's Office Area. */
.bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1}
.bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
.bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6}
.bs-drun rect{fill:#1b2534;stroke:#4d6fa8}
.bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2}
/* The elbow where the route turns from one player's side of the table to the next. Drawn heavier
than a plain join because that turn is the boundary two districts share. */
.bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
/* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point
and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
train is measured against, not something to look at instead of the train. */
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
+87 -9
View File
@@ -36,7 +36,7 @@ export { cardName, describeIntent, snapshot } from './view.ts';
import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
@@ -147,19 +147,94 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
*
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
* the field.
* the field, and rebuilds each cell from the dictionaries below.
*
* MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within
* it the cost was not where the TODO said. Carrying `links` forward — the item this started as —
* would have saved 5% of cells. What actually costs:
*
* what 32% of cells long prose, and the SAME prose on every turnout in the district
* facility 24% of cells the identical object is already in the frame's `facilities`
* identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
*
* So each of the three is interned instead. A card's identity and its description are written once
* for the whole recording and referenced by integer, and a cell points at its facility rather than
* carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars
* standing there, the crew, and any Enhancement laid on it.
*/
function compress(frames: Frame[]): unknown[] {
type Packed = {
/** Static per square, written once: row, col, kind, label, running, links. */
cards: unknown[];
/** Distinct `what` descriptions, written once. */
whats: string[];
frames: unknown[];
};
export function compress(frames: Frame[]): Packed {
const cards: unknown[] = [];
const cardAt = new Map<string, number>();
const whats: string[] = [];
const whatAt = new Map<string, number>();
const packed = frames.map((f) => {
const cells = f.cells.map((c) => {
const idKey = JSON.stringify([c.row, c.col, c.kind, c.label, c.running, c.links]);
let ci = cardAt.get(idKey);
if (ci === undefined) {
ci = cards.length;
cards.push([c.row, c.col, c.kind, c.label, c.running, c.links]);
cardAt.set(idKey, ci);
}
let wi = whatAt.get(c.what);
if (wi === undefined) {
wi = whats.length;
whats.push(c.what);
whatAt.set(c.what, wi);
}
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
// cell and the frame's `facilities`, so this always resolves.
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
});
return { ...f, cells } as unknown as Frame;
});
const keys = ['cells', 'facilities', 'division'] as const;
let prev: Record<string, string> = {};
return frames.map((f, i) => {
const out: Record<string, unknown> = { ...f };
const out = packed.map((f, i) => {
const o: Record<string, unknown> = { ...f };
for (const k of keys) {
const json = JSON.stringify(f[k]);
if (i > 0 && prev[k] === json) out[k] = null;
if (i > 0 && prev[k] === json) o[k] = null;
prev[k] = json;
}
return out;
return o;
});
return { cards, whats, frames: out };
}
/**
* Rebuild whole CellViews from the compact wire rows.
*
* Exported and then emitted into the page by `toString()`, exactly as the two board renderers are:
* a second copy of this in the page's inline script could drift from the packing above, and the
* failure would be a board that draws the wrong cards rather than an error.
*/
export function rehydrateCells(
packed: unknown[],
cards: unknown[],
whats: string[],
facs: unknown[],
): unknown[] {
return packed.map((row) => {
const p = row as [number, number, string[], string | null, string[], number];
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
return {
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
facility: p[5] < 0 ? null : facs[p[5]],
};
});
}
@@ -322,7 +397,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
const FRAMES = ${JSON.stringify(compress(rec.frames))};
const PACKED = ${JSON.stringify(compress(rec.frames))};
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
@@ -330,6 +406,8 @@ function carry(i, key) {
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
return [];
}
const rehydrateCells = ${rehydrateCells.toString()};
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@@ -376,7 +454,7 @@ function render() {
$('fno').textContent = i;
$('scrub').value = i;
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = divisionSvg(DIV);
// The same office renderer the playable app uses, so replay and game draw one board.
+122 -5
View File
@@ -17,6 +17,7 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
@@ -86,7 +87,35 @@ export type FacilityView = {
jammed: boolean;
};
export type TrainChip = { label: string; consist: string[] };
export type TrainChip = {
label: string;
consist: string[];
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
* Moves rather than by Stages, so it occupies a card outright.
*/
region?: number;
direction?: string;
};
/**
* One card of a player's Running Track, as the Division sees it.
*
* The through route between the Limits IS the Running Track — everything hanging beneath it is
* secondary, and a train crossing the Division never touches it. So the Division map expands an
* Office into this row and leaves sidings, industries and the load pipeline to the district view.
*/
export type RunningCardView = {
row: number;
col: number;
kind: string;
label: string;
/** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */
links: string[];
/** Trains standing ON this card of the Running Track. */
trains: TrainChip[];
};
export type DivisionView = {
kind: string;
label: string;
@@ -104,6 +133,19 @@ export type DivisionView = {
* on that grade has no visible meaning.
*/
gradeUp: string | null;
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
regions?: number;
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[];
/** Office nodes only: whose district this is. */
owner?: number;
/**
* Office nodes only: crews working BELOW the Running Track.
*
* A crew down a siding has no position on the through route — projecting one would be a lie — so
* it is reported against the district as a whole and drawn exactly where it is in the zoomed view.
*/
switching?: TrainChip[];
};
export type Frame = {
@@ -431,12 +473,43 @@ export function snapshot(
// with how long each still has to run.
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
/**
* WHERE ON THE CARD, from what the crossing already cost.
*
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
* point as the thing that varies, exactly as the cards do:
*
* entry = REGIONS - stagesTotal position = entry + elapsed
*
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
* what "Start positions further along the card" means on the printed art. A 30 card is two
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
* card's distance is fixed and the train is simply slow across it.
*/
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
// two regions, so it notionally starts before the card and spends the extra Stage getting
// to the first region. Clamping only the final position keeps that Stage at the START,
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
const elapsed = t.stagesTotal - t.stagesRemaining;
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
};
return {
kind: 'ml',
label: name,
regions: REGIONS_PER_MAINLINE_CARD,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
return {
...chip,
label: `${chip.label} (${t.stagesRemaining})`,
region: place(t),
direction: t.direction,
};
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
modifiers: [
@@ -446,13 +519,57 @@ export function snapshot(
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
const oa = areaOf(s, n.owner);
// Where every crew in this district actually is: on a Running Track card, or below it.
const onRunning = new Map<string, TrainChip[]>();
const below: TrainChip[] = [];
for (const [id, tray] of s.trays) {
const pos = tray.position;
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
const c = trainChip(s, id);
if (pos.coord.row === oa.runningRow) {
const k = `${pos.coord.row},${pos.coord.col}`;
onRunning.set(k, [...(onRunning.get(k) ?? []), c]);
} else {
below.push(c);
}
}
// Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the
// signs are the ends of the through route rather than obstacles on it.
const running: RunningCardView[] = [];
for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) {
const card = oa.grid.get(`${oa.runningRow},${col}`);
if (!card) continue;
const g = card.geometry;
let label: string;
if (g.kind === 'office') label = officeProfile(oa.tier).name;
else if (g.kind === 'limits') label = 'Limits';
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 label = geometryLabel(g.geometry);
running.push({
row: oa.runningRow,
col,
kind: g.kind,
label,
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
trains: onRunning.get(`${oa.runningRow},${col}`) ?? [],
});
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
label: officeProfile(oa.tier).name,
trains: [oa.adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(oa.tier).adTracks,
modifiers: [],
gradeUp: null,
owner: n.owner,
running,
switching: below,
};
});
+18 -1
View File
@@ -79,6 +79,14 @@ export type Game = {
* against the current rules.
*/
mustPlayCard: boolean;
/**
* Sounds the last batch of events earned, for the page to play and clear.
*
* The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view
* decides what that sounds like. Detection lives here because this is where events are seen; it
* would be guesswork from a rendered frame.
*/
cues: string[];
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -114,7 +122,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -336,6 +344,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
game.log.push({ text, tone: mine ? 'act' : n.tone });
if (e.type === 'trainMadeUp') game.cues.push('train');
if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary gets the bell only: sounding both there would
// collide, and the bell is the bigger event.
if (e.day > 1 && e.stage === 1) game.cues.push('day');
else if (e.day > 1 || e.stage > 1) game.cues.push('stage');
}
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
+82 -14
View File
@@ -5,8 +5,9 @@
* is answered by the engine or by the shared view helpers.
*/
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { play } from './sound.ts';
import type { Game } from './game.ts';
import {
actionMenu,
@@ -26,6 +27,28 @@ let game: Game;
let selected: string | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
* AUTO-FOCUS on the district.
*
* It is only worth the vertical space during the phases that change it — Local Operations, where
* cards and track are placed and the crew switches, and Cargo, where loads move. During New Train,
* Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters.
*
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
* they change it again. Display only — in a multiplayer game two players may reasonably want it
* set differently, so this must never become part of game state.
*/
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
/**
* Sound, OFF until asked for.
*
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
* One click in the title bar turns it on, and that click is also the gesture browsers require
* before any audio may start.
*/
let soundOn = false;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
@@ -149,7 +172,16 @@ function render(): void {
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
const grid = $('grid');
grid.innerHTML = officeSvg(f.cells, f.runningRow);
// The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the
// same pass so they share its origin and its canvas: a target outside the canvas is a legal move
// the player cannot click.
const ghostCoords = [...spotsAt.keys()]
.filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k))
.map((k) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
@@ -160,18 +192,11 @@ function render(): void {
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
// A legal EMPTY square has no card to outline, so draw a target for it.
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
if (ghosts.length > 0) {
const coords = ghosts.map(([k]) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
for (const [key, list] of ghosts) {
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
for (const [key, list] of spotsAt) {
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
function pick(key: string, list: { label: string; index: number }[]): void {
@@ -261,10 +286,38 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
renderDistrict(f);
renderActions(menu);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
const cues = game.cues.splice(0, game.cues.length);
if (soundOn) for (const c of cues) play(c);
save();
}
function renderDistrict(f: ReturnType<typeof view>): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
const crew = f.cells.filter((c) => c.tray).length;
$('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
const btn = $('districttoggle');
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode;
btn.onclick = () => {
// auto -> the opposite of what it is doing now -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
render();
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
@@ -439,4 +492,19 @@ installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
};
soundBtn.onclick = () => {
soundOn = !soundOn;
paint();
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
// doubles as the user gesture the browser needs before any audio may start.
if (soundOn) play('stage');
};
paint();
}
start();
+17 -1
View File
@@ -100,6 +100,14 @@ button:hover{background:#39424e;border-color:#4d6fa8}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
button.act{display:inline-block}
/* The district collapses when nothing in it is changing. A summary line stays, because a panel
that vanishes entirely reads as broken rather than as folded away. */
button.ghost{background:transparent;border:1px solid var(--line);color:var(--dim);font-size:10px;
padding:1px 7px;margin-left:8px;text-transform:none;letter-spacing:0;vertical-align:middle}
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#district.folded #grid{display:none}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
#districtsummary{display:none}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
@@ -169,6 +177,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
@@ -186,7 +195,14 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></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 id="district">
<h2>Your Office Area
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<button id="districttoggle" class="ghost" title="Auto-focus opens the district during the phases that change it">auto</button>
</h2>
<div id="districtsummary" class="dim"></div>
<div id="grid"></div>
</section>
<section><h2>History</h2><div id="log"></div></section>
</div>
+136
View File
@@ -0,0 +1,136 @@
/**
* The railroad, heard.
*
* SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static
* folder that fetches nothing — there is a test asserting no page reaches an external host — so an
* audio file would have to be committed to the repo, and a plausible-sounding whistle is not
* something to invent. The Web Audio API can build these from oscillators at no download cost, and
* what it produces is honestly synthetic rather than a fake recording.
*
* A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers
* slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials
* and a band of noise is most of the way there.
*
* "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
* into the browser, needs no asset and works offline, so it says the words — and where the platform
* has no voice installed, a two-note call takes its place rather than nothing happening.
*/
let ctx: AudioContext | null = null;
/**
* Browsers refuse to start audio until the player has interacted with the page, so the context is
* created on first use — by which time they have clicked something — and resumed if it was
* suspended in the meantime.
*/
function audio(): AudioContext | null {
try {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
ctx ??= new Ctor();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
}
}
/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */
function tone(
c: AudioContext,
at: number,
freq: number,
dur: number,
gain: number,
type: OscillatorType = 'sine',
bendTo?: number,
): void {
const osc = c.createOscillator();
const amp = c.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, at);
if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur);
amp.gain.setValueAtTime(0, at);
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4));
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
osc.connect(amp).connect(c.destination);
osc.start(at);
osc.stop(at + dur + 0.02);
}
/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */
function breath(c: AudioContext, at: number, dur: number, gain: number): void {
const frames = Math.floor(c.sampleRate * dur);
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const band = c.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.8;
const amp = c.createGain();
amp.gain.setValueAtTime(0, at);
amp.gain.linearRampToValueAtTime(gain, at + 0.05);
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(band).connect(amp).connect(c.destination);
src.start(at);
src.stop(at + dur);
}
/** End of a Stage. A three-chime whistle, one long call. */
export function whistle(): void {
const c = audio();
if (!c) return;
const t = c.currentTime + 0.02;
const d = 0.9;
// A minor-third-ish cluster, deliberately not in tune with itself — that beating IS the sound.
for (const [f, g] of [[392, 0.16], [466, 0.13], [587, 0.10], [784, 0.05]] as const) {
tone(c, t, f, d, g, 'sawtooth', f * 0.985);
}
breath(c, t, d, 0.05);
}
/** End of a Day. The grade-crossing bell: two strikes, metallic and inharmonic. */
export function clang(): void {
const c = audio();
if (!c) return;
const t = c.currentTime + 0.02;
for (const at of [t, t + 0.22]) {
// Inharmonic partials are what separate a bell from a beep.
for (const [f, g] of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]] as const) {
tone(c, at, f, 0.4, g, 'square');
}
}
}
/** A train is added to the board. */
export function allAboard(): void {
try {
const s = window.speechSynthesis;
if (s && typeof SpeechSynthesisUtterance === 'function') {
const u = new SpeechSynthesisUtterance('All aboard!');
u.rate = 0.95;
u.pitch = 0.85;
u.volume = 0.9;
s.speak(u);
return;
}
} catch {
// fall through to the call below
}
// No voice on this platform: a two-note conductor's call rather than silence.
const c = audio();
if (!c) return;
const t = c.currentTime + 0.02;
tone(c, t, 523, 0.22, 0.14, 'triangle');
tone(c, t + 0.2, 784, 0.34, 0.14, 'triangle');
}
/** Cue names the model emits; the view decides what they sound like. */
export function play(cue: string): void {
if (cue === 'stage') whistle();
else if (cue === 'day') clang();
else if (cue === 'train') allAboard();
}
+4 -4
View File
@@ -299,7 +299,7 @@ describe('collisions are automatic (Gap 2)', () => {
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
@@ -322,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => {
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
mlx.card = 'plains';
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.trays.set('x', {
id: 'x',
@@ -371,7 +371,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
// A second train at the Western Division Point wanting to follow it.
@@ -414,7 +414,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('waiting', {
+3 -3
View File
@@ -194,7 +194,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
@@ -261,7 +261,7 @@ describe('ABS Signals amend the collision rule', () => {
// pass, so the following train would never be held and there would be nothing for ABS to do.
ml.card = 'plains';
ml.absSignals = true;
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
@@ -301,7 +301,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
ml.card = 'plains';
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('mine', {
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
+63 -2
View File
@@ -24,6 +24,7 @@ import {
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
@@ -146,7 +147,7 @@ describe('placing a Mainline modifier', () => {
it('will not modify a card with a train on it', () => {
const s = game();
const node = pinned(s, 1, 'heavyGrade');
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
@@ -207,7 +208,7 @@ describe('Red Flags protect a stopped train', () => {
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
function rearEnder(s: GameState) {
const node = pinned(s, 1, 'plains');
node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
@@ -534,3 +535,63 @@ describe("a train is made up to its card's consist (§8.2)", () => {
);
});
});
describe('regions on a Mainline card (§2.1, §8.2)', () => {
/** Put one train mid-crossing and ask the view where the map should draw it. */
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => {
const s = createGame({
id: 'reg',
seed: 4,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node && node.kind === 'mainline');
const tray = [...s.trays.keys()][0]!;
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' });
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
assert.ok(ml, 'no Mainline node in the view');
const t = ml!.trains.flat()[0]!;
return { region: t.region as number, regions: ml!.regions as number };
};
it('divides every Mainline card into two regions', () => {
// §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves
// trains still on the road when the next Day begins, locking Crew Trays permanently.
assert.equal(regionFor(2, 2).regions, 2);
});
it('moves one region per Stage for an ordinary train on a 30 card', () => {
// §8.2 exactly. This is the case the printed rule was written for.
assert.equal(regionFor(2, 2).region, 0);
assert.equal(regionFor(2, 1).region, 1);
});
it('starts a fast train further along the card rather than moving it twice', () => {
// The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map
// shows as entering at the second region. That is the same fact as "Brakemen moves your entry
// point further along", which is how the modifier cards are drawn.
assert.equal(regionFor(1, 1).region, 1);
});
it('holds a slow train at the START, not at the exit', () => {
// Three Stages will not fit three steps into two regions. The card's distance is fixed, so the
// extra Stage is spent getting going — clamping the entry instead would park it at the exit and
// read as a train that raced across and then waited.
assert.equal(regionFor(3, 3).region, 0);
assert.equal(regionFor(3, 2).region, 0);
assert.equal(regionFor(3, 1).region, 1);
});
it('never leaves the card it is on', () => {
for (let total = 1; total <= 4; total++) {
for (let left = total; left >= 1; left--) {
const r = regionFor(total, left).region;
assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`);
}
}
});
});
+53 -3
View File
@@ -14,7 +14,7 @@ import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
const config: GameConfig = {
@@ -189,8 +189,16 @@ describe('replay HTML', () => {
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
});
it('embeds the frame data', () => {
assert.match(html, /const FRAMES = \[/);
it('embeds the frame data and the dictionaries it is packed against', () => {
// The frames are packed now: a card's identity and its description are written once into
// CARDS/WHATS and referenced by integer. A page with FRAMES but no dictionaries would draw an
// empty board rather than fail, so all three are checked.
assert.match(html, /const PACKED = \{/);
assert.match(html, /FRAMES = PACKED\.frames/);
assert.match(html, /CARDS = PACKED\.cards/);
assert.match(html, /WHATS = PACKED\.whats/);
// And the unpacking function must be emitted, not just referenced.
assert.match(html, /const rehydrateCells = function/);
});
it('offers the controls that make it usable', () => {
@@ -300,3 +308,45 @@ describe('replay HTML', () => {
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
});
});
describe('the replay wire format is lossless (regression)', () => {
it('rebuilds every cell exactly, through the same function the page runs', () => {
// The frames are packed before they reach the page: a card's identity and its description are
// written once and referenced by integer, and a cell points at its facility instead of carrying
// a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact.
//
// `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks
// the real thing rather than a second implementation of it.
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);
let checked = 0;
let carried: unknown[] = [];
let carriedFacs: unknown[] = [];
for (let i = 0; i < rec.frames.length; i++) {
const wire = packed.frames[i] as Record<string, unknown>;
// null means "unchanged since the last frame that carried it", exactly as the page resolves it
if (wire['cells'] !== null) carried = wire['cells'] as unknown[];
if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[];
const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs);
assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`);
checked++;
}
assert.ok(checked > 100, `only ${checked} frames checked`);
});
it('writes each distinct card and description once, not once per frame', () => {
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);
const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0);
assert.ok(
packed.cards.length < cellsSeen / 20,
`${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`,
);
assert.ok(
packed.whats.length < cellsSeen / 20,
`${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`,
);
});
});
+187 -1
View File
@@ -12,7 +12,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import { officeSvg } from '../src/sim/board-svg.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
actionGroups,
actionMenu,
@@ -696,8 +698,19 @@ describe('the static build', () => {
// page that works perfectly.
let cache = new Map<string, Record<string, unknown>[]>();
const found = new Map<string, Record<string, unknown>>();
// Every element carries a classList. The page folds the district panel by toggling a class on
// it, and a stub without one throws on load — which is a page that never starts, not a
// cosmetic gap.
const ownClasses = new Set<string>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
contains: (c: string) => ownClasses.has(c),
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
},
classes: ownClasses,
// 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) {
@@ -936,3 +949,176 @@ describe('the static build', () => {
}
});
});
describe('the Division map shows the whole route', () => {
const divisionFor = (players: number): string => {
const s = createEngineGame({
id: `div-${players}`,
seed: 7,
config: {
mode: players === 1 ? 'solitaire' : 'competitive',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
});
return divisionSvg(snapshot(s, [], null).division);
};
it('expands each Office into its Running Track, Limits to Limits', () => {
// The map used to collapse a whole district into one "Office" box, so the track a train
// actually runs along was invisible on the only view that shows where trains are.
const s = createEngineGame({
id: 'div-run',
seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const f = snapshot(s, [], null);
const office = f.division.find((n) => n.kind === 'office');
assert.ok(office, 'no Office node in the Division');
assert.ok(office!.running && office!.running.length >= 3, 'the Office carries no Running Track');
// The through route runs BETWEEN the Limits, so both ends must be Limits cards (§2.1).
assert.equal(office!.running![0]!.kind, 'limits', 'the district does not start at a Limits sign');
assert.equal(office!.running![office!.running!.length - 1]!.kind, 'limits', 'the district does not end at a Limits sign');
// And the columns must run west to east without a hole.
const cols = office!.running!.map((c) => c.col);
for (let i = 1; i < cols.length; i++) {
assert.ok(cols[i]! > cols[i - 1]!, 'the Running Track is not ordered west to east');
}
});
it('seats 1 to 4 players without overlapping or spilling off the canvas', () => {
// A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual
// feedback loop, so this is the only thing standing between a change and an unreadable board.
for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players);
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
assert.ok(vb, `${players}p produced no viewBox`);
const W = Number(vb![1]);
const H = Number(vb![2]);
const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! }));
assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`);
for (let i = 0; i < rects.length; i++) {
const a = rects[i]!;
assert.ok(
a.x >= 0 && a.y >= 0 && a.x + a.w <= W && a.y + a.h <= H,
`${players}p: a cell falls outside the canvas`,
);
for (let j = i + 1; j < rects.length; j++) {
const b = rects[j]!;
const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
assert.ok(!hit, `${players}p: two cells overlap`);
}
}
}
});
it('draws the Division as a line with two ends, never as a loop', () => {
// Seating players around a table invites exactly one misreading: that the route joins up. It
// does not — trains enter at one Division Point and leave at the other.
for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players);
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
assert.match(svg, /trains enter/, `${players}p does not label where trains enter`);
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`);
}
});
});
describe('every square the menu offers can actually be clicked (regression)', () => {
it('draws a target for it, inside the canvas', () => {
// REGRESSION. Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG after
// the fact. It sized its origin over cells PLUS spots while `officeSvg` sized itself over cells
// alone, so the two disagreed the moment a legal square lay outside the played cards — which is
// every square that would EXTEND the district, i.e. most of them.
//
// Seed 555 offered three placements for a straight; the empty square was drawn at y=99 on a
// canvas 103 tall. The list said three, the board showed two, and the third could not be
// clicked at all. Ghosts are drawn by `officeSvg` in the same pass now, so there is one origin
// and one canvas.
let checked = 0;
for (const seed of [555, 42, 909, 7, 88, 1234]) {
const game = newGame(seed);
const draw = actionMenu(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
if (draw) submit(game, draw);
const menu = actionMenu(game);
const f = view(game);
for (const item of menu.placeable.flatMap((g) => g.items)) {
const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()];
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
const svg = officeSvg(f.cells, f.runningRow, ghosts);
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
assert.ok(vb, 'the board produced no viewBox');
const W = Number(vb![1]);
const H = Number(vb![2]);
for (const c of uniq) {
checked++;
const key = `${c.row},${c.col}`;
const isCard = f.cells.some((x) => x.row === c.row && x.col === c.col);
const target = isCard ? `data-cell="${key}"` : `data-ghost="${key}"`;
assert.ok(svg.includes(target), `${item.subject}: (${key}) is offered but has no target on the board`);
// `data-tip` sits between the id and the transform on a real card, so match loosely.
const m = new RegExp(
'data-(?:cell|ghost)="' + key + '"[^>]*transform="translate\\((-?[\\d.]+),(-?[\\d.]+)\\)"',
).exec(svg);
assert.ok(m, `${item.subject}: (${key}) has no position`);
const x = Number(m![1]);
const y = Number(m![2]);
assert.ok(
x >= 0 && y >= 0 && x + 132 <= W + 0.01 && y + 96 <= H + 0.01,
`${item.subject}: (${key}) is drawn off-canvas at (${x}, ${y}) on ${W}x${H} — a legal move that cannot be clicked`,
);
}
}
}
assert.ok(checked > 50, `only ${checked} squares checked`);
});
});
describe('the sounds fire on the events they name', () => {
it('one cue per Stage boundary, the bell replacing the whistle at a Day', () => {
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
// count is checked against the clock rather than trusted.
const game = newGame(555);
const cues: Record<string, number> = {};
let stageBoundaries = 0;
let dayBoundaries = 0;
for (let i = 0; i < 4000; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
const before = { d: game.state.clock.day, s: game.state.clock.stage };
submit(game, options[groups[0]!.actions[0]!.index]!);
for (const c of game.cues) cues[c] = (cues[c] ?? 0) + 1;
game.cues.length = 0;
if (game.state.clock.day !== before.d) dayBoundaries++;
if (game.state.clock.stage !== before.s || game.state.clock.day !== before.d) stageBoundaries++;
}
assert.ok(stageBoundaries > 20, `only ${stageBoundaries} Stages elapsed — the game did not run`);
assert.equal(cues['day'] ?? 0, dayBoundaries, 'the Day bell does not match the Days played');
assert.equal(
(cues['stage'] ?? 0) + (cues['day'] ?? 0),
stageBoundaries,
'Stage boundaries and Stage-or-Day cues disagree — some Stage ended silently, or one sounded twice',
);
assert.ok((cues['train'] ?? 0) > 0, 'no train was ever announced');
});
it('says nothing at all before the first Stage has ended', () => {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening.
const game = newGame(555);
assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had');
});
});