More bug fixes. Show the timetable, and stop the arrival message contradicting itself. Fixed division point in labels. And highlighted discard as targets.

This commit is contained in:
Jesse
2026-08-05 23:08:03 -04:00
parent 543584a2ca
commit f4c0f49604
13 changed files with 381 additions and 50 deletions
+64
View File
@@ -10,6 +10,70 @@ The target is 20 Revenue over 5 Days.
## Unreleased ## Unreleased
### A second playtest pass
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
`expedite: true` — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
`trainArrived` narrated *"it will highball again next Mainline Phase, so any work must happen now"*
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
itself two lines later. The event carries `expedited` now and the message says which case it is.
**`TX14 (2)` was Stages remaining to cross that Mainline card**, not the car count — a bare figure
beside a train's name, read as the consist twice by the same player because that is the obvious
guess. Now `TX14 · 2⧗`.
**"stock a coach at (0, 0)"** reads as putting a car on the track, and was reported as exactly that
confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the
Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger
facility, people on the platform. It now says so.
**"trains enter" / "trains leave"** claimed the Division ran one way. Odd trains run west and even
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
rather than a loop. Both ends now read `in and out`.
**The discard targets were "a very tiny highlight".** Choosing a card to PLAY lights big ghost squares
on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be
looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard
beside them so the three that are choices read as a choice.
### The timetable, and watching the die land
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the
answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame
all along, and only the standalone replay ever drew them.
The play page now has a **Timetable panel**: twelve Stages across, the train due out at each, the
current Stage lit, Stages already gone dimmed. Playing a train card **flashes the slot the die just
filled**, sounds a die-and-chime cue, and says it in words above the actions — *"Train 4 is scheduled
to depart at Stage 11 — see the Timetable"*. The flash marks the moment rather than the state: it is
gone by the next render, which is what makes it read as "that just happened".
No acknowledge-click. It would stop the game to say something the highlighted timetable says better,
and would be clicked through by the third time.
### The load pipeline is drawn the way freight actually flows
Reported after unloading at a warehouse: *"it went W, A, M, and then to the red box at the far right."*
**The engine was right.** §9.3 is explicit — *"The first Laborer replaces the load with an empty car
of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT
then MEN. The last one places the load on a red Unloading box."* An unload ends in red, and it did.
**The drawing was wrong.** The pipeline was laid out `green → MEN|AT|WORK → red`, left to right, with
both arrows pointing right. But loading runs Green → MEN → AT → WORK → **car**, and unloading runs the
other way entirely: **car** → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
and an unload appeared to run backwards across the whole row and land on the end it had just left.
Now green and red both sit before the sign with their arrows pointing opposite ways, and **only the
boxes an industry actually uses are drawn**: a Grocer's Warehouse has no green box and a Mine Tipple
no red one, where before every industry showed both and half of them were dead squares.
The Facilities panel had the same left-to-right assumption in its row labels. `green` and `red` are
now `waiting to load` and `cleared inbound`, each with the direction its loads travel spelled out,
and a row an industry cannot use is omitted rather than shown empty.
### A playtest report, worked through ### A playtest report, worked through
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to **Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
+1
View File
@@ -756,6 +756,7 @@ function arriveAtOffice(
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
consist: tray.consist.map((c) => ({ ...c })), consist: tray.consist.map((c) => ({ ...c })),
office: officeProfile(area.tier).name, office: officeProfile(area.tier).name,
expedited: isExpedited(tray),
}); });
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to // Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
+6 -1
View File
@@ -84,7 +84,12 @@ export type GameEvent =
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string } | { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string } | { type: 'trainHeld'; trainNumber: number; reason: string }
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string } | { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string } /**
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
* every other train stands until the next Mainline Phase and can be switched in between.
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] } | { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock } | { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
+39 -22
View File
@@ -132,7 +132,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
trains: n.trains.flat(), trains: n.trains.flat(),
cap: n.capacity, cap: n.capacity,
tip: dp tip: dp
? 'A Division Point — trains enter and leave the Division here, and queue without limit' ? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`, : `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
owner: null, owner: null,
// A Division Point is one region — the queue trains enter and leave the Division through. // A Division Point is one region — the queue trains enter and leave the Division through.
@@ -286,12 +286,16 @@ export function divisionSvg(nodes: DivisionView[]): string {
if (first) { 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}"/>` + 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>`; `<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>`; // BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
// Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said
// the Division ran one way, which it does not — the point of the buffer stops is only that it is
// a LINE and not a loop.
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">west end · in and out</text>`;
} }
if (last && last !== first) { 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}"/>` + 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>`; `<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 += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">east end · in and out</text>`;
} }
out += '</svg>'; out += '</svg>';
@@ -443,12 +447,19 @@ export function officeSvg(
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`; out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/** /**
* THE LOAD PIPELINE, on the card itself. * THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
* *
* A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of * §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way
* freight. It was drawn only in the side panel, so advancing a load changed nothing on the card * entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car
* the player was looking at. The printed cards put these squares under the track for exactly * belongs beside WORK.
* this reason. *
* This drew green, then MEN|AT|WORK, then red — putting the red box at the far right, which is
* exactly where the car is. An unload therefore appeared to run backwards across the whole row
* and then land on the end it had just come from. Reported after unloading at a warehouse:
* "it went W, A, M, and then to the red box at the far right".
*
* Only the boxes an industry actually uses are drawn, and the arrows point the way its freight
* really moves.
*/ */
const f = cell.facility; const f = cell.facility;
if (f) { if (f) {
@@ -459,25 +470,31 @@ export function officeSvg(
let bx = 7; let bx = 7;
const by = H - 42; const by = H - 42;
// Green: what is waiting to be worked. // Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
for (let i = 0; i < Math.max(1, f.greenCap); i++) { // point opposite ways. An industry that only receives has no green boxes at all.
out += box(bx, by, i < f.green.length, 'bs-green'); if (f.allowsOut) {
bx += cellW + 2; for (let i = 0; i < Math.max(1, f.greenCap); i++) {
out += box(bx, by, i < f.green.length, 'bs-green');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 9;
} }
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`; if (f.allowsIn) {
bx += 8; for (let i = 0; i < Math.max(1, f.redCap); i++) {
// MEN | AT | WORK — a load sits in one of these while it is being worked. out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
bx += 9;
}
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
// unloading. The spotted car sits past WORK, on the siding drawn below.
for (let i = 0; i < f.maw.length; i++) { for (let i = 0; i < f.maw.length; i++) {
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]); out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
bx += cellW + 2; bx += cellW + 2;
} }
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`; out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
bx += 8;
// Red: what has come off an arriving car and is waiting to be cleared.
for (let i = 0; i < Math.max(1, f.redCap); i++) {
out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
}
} }
// Standing room, drawn as the printed squares: filled means occupied. // Standing room, drawn as the printed squares: filled means occupied.
+14 -1
View File
@@ -239,9 +239,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`, text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
}; };
case 'trainArrived': case 'trainArrived':
/**
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND.
*
* This said "it will highball again next Mainline Phase, so any work must happen now" to every
* arrival. For an expedited train that is exactly backwards — Q3 departs it in the SAME Stage
* it arrived, so there is no turn in which to work it — and the log then contradicted itself
* two lines later when the train left. Reported after Train 4, the Express, did precisely that.
*/
return { return {
tone: 'plain', tone: 'plain',
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`, text: e.expedited
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — and EXPEDITES, so it leaves again this same Mainline Phase; there is no turn in which to work it`
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands until the next Mainline Phase, so any work must happen before then`,
}; };
case 'trainDiverted': case 'trainDiverted':
return { return {
@@ -551,6 +561,9 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
// board with only a line of history to say where it went is the thing that most needs a noise. // board with only a line of history to say where it went is the thing that most needs a noise.
if (e.type === 'carsCoupled') out.push('couple'); if (e.type === 'carsCoupled') out.push('couple');
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop'); if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
// The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed
// silently, so the card was gone and the answer to "when does it run?" was a line of history.
if (e.type === 'trainScheduled') out.push('schedule');
if (e.type === 'stageBegan') { if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening // A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide, // and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+34 -3
View File
@@ -109,6 +109,16 @@ export type FacilityView = {
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)". * anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/ */
base: { out: number; in: number; laborers: number }; base: { out: number; in: number; laborers: number };
/**
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
*
* §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way: car → WORK → AT →
* MEN → red. So green and red both sit beside MEN, and the car sits beside WORK. Drawing red at
* the far right — where the car is — made an unload look like it ran backwards across the whole
* row and then landed on the wrong end.
*/
allowsOut: boolean;
allowsIn: boolean;
/** The Modifier cards standing beside it, by name. */ /** The Modifier cards standing beside it, by name. */
modifiers: string[]; modifiers: string[];
/** /**
@@ -322,6 +332,8 @@ function facilityView(
porters: `${portersLeft(f)}/${f.porters}`, porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f), canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f), jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
allowsOut: f.allows.outbound,
allowsIn: f.allows.inbound,
base: baseOf(card), base: baseOf(card),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)), modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
}; };
@@ -468,8 +480,20 @@ export function describeIntent(s: GameState, i: Intent): string {
} }
case 'switch.sortConsist': case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`; return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound': case 'freightAgent.stockOutbound': {
return `stock a ${i.carType} at ${at(i.at)}`; /**
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
* that confusion: no train at the Depot, so how is a coach being stocked there? It is not a
* car on the track — it is a load taken from the Division Yard into the green Loading box,
* waiting for a train that can carry it. For a passenger facility that load is passengers on
* the platform.
*/
const f = areaOf(s, 0).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
return i.carType === 'coach' && f?.kind === 'passenger'
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
: `bring a ${i.carType} load ${where} at ${at(i.at)} — it waits there for a car to be spotted`;
}
case 'freightAgent.unjam': case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`; return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound': case 'freightAgent.clearInbound':
@@ -683,7 +707,14 @@ export function snapshot(
const chip = trainChip(s, t.tray); const chip = trainChip(s, t.tray);
return { return {
...chip, ...chip,
label: `${chip.label} (${t.stagesRemaining})`, /**
* SAY WHAT THE NUMBER IS.
*
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
* car count twice, by the same player, because that is the obvious guess. It is Stages
* left to cross this Mainline card. Naming the unit costs three characters.
*/
label: `${chip.label} · ${t.stagesRemaining}⧗`,
region: place(t), region: place(t),
direction: t.direction, direction: t.direction,
}; };
+12 -1
View File
@@ -91,6 +91,14 @@ export type Game = {
* would be guesswork from a rendered frame. * would be guesswork from a rendered frame.
*/ */
cues: string[]; cues: string[];
/**
* The timetable slot the last batch of events filled, or null.
*
* Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see.
* Carrying the slot lets the timetable flash the one that just changed — the roll becomes
* something you watch land rather than something you are told about afterwards.
*/
scheduled: number | null;
}; };
/** How each intent kind is introduced in the action list, in the order they should appear. */ /** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -125,7 +133,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] }; const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS // A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over. // first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' }); game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -603,6 +611,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
} }
game.cues.push(...cuesFor(events)); game.cues.push(...cuesFor(events));
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
// schedule more than one train, and the most recent is the one the eye should be sent to.
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
// 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.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400); if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
} }
+38 -4
View File
@@ -7,7 +7,7 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts'; import { playCue } from './sound.ts';
import type { Game } from './game.ts'; import type { Game } from './game.ts';
@@ -293,6 +293,9 @@ function render(): void {
? menu.hand.find((h) => `hand:${h.cardId}` === selected) ? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined; : undefined;
if (discarding) { if (discarding) {
// Dim the piles that are not targets, so the three that are read as a choice being offered
// rather than as four cards that happen to be there.
$('depts').classList.add('aiming');
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) { for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement; const node = el as HTMLElement;
const slot = Number(node.dataset['dept']); const slot = Number(node.dataset['dept']);
@@ -309,6 +312,10 @@ function render(): void {
} }
} }
else {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button. // -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) { if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) { for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
@@ -326,6 +333,16 @@ function render(): void {
} }
} }
/**
* THE TIMETABLE, with the slot the die just filled flashed.
*
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
* it is gone by the next render, which is what makes it read as "that just happened".
*/
const justSet = game.scheduled;
game.scheduled = null;
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f); $('blocked').innerHTML = blockedHtml(f);
// -- log // -- log
@@ -354,7 +371,7 @@ function render(): void {
lastPhase = f.phaseKey; lastPhase = f.phaseKey;
renderDistrict(f); renderDistrict(f);
renderActions(menu, f); renderActions(menu, f, justSet);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // 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. // not then play a backlog of everything that happened while it was off.
@@ -413,7 +430,11 @@ function renderDistrict(f: ReturnType<typeof view>): void {
}; };
} }
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void { function renderActions(
menu: ReturnType<typeof actionMenu>,
f: ReturnType<typeof view>,
justSet: number | null,
): void {
const el = $('actions'); const el = $('actions');
if (game.state.status !== 'active') { if (game.state.status !== 'active') {
@@ -474,6 +495,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
* the count belongs beside the buttons. It was reported only in the history panel, which is the * the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching. * one place a player is not looking while switching.
*/ */
/**
* WHAT THE DIE DID, said where the player is looking.
*
* Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the
* answer arrived only in the history panel, one line among many. The timetable now shows it and
* flashes the slot; this says it in words at the same moment.
*/
const scheduledNote =
justSet !== null && f.timetable[justSet] != null
? `<div class="scheduled" data-tip="§7 — the Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
: '';
const movesNote = const movesNote =
f.movesLeft !== null f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` + ? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
@@ -483,7 +517,7 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
// A heading over the buttons, so the panel says what it is before it says what is in it. The // A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with // list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase". // no worker able to act, the only thing offered is "End my Cargo phase".
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`; let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
/** /**
* WHAT IS LEFT IN THE ACTION LIST. * WHAT IS LEFT IN THE ACTION LIST.
+86 -11
View File
@@ -97,6 +97,43 @@ export function yardHtml(rows: { type: string; loaded: number; empty: number }[]
.join(''); .join('');
} }
/**
* THE TIMETABLE: twelve Stages of a Day, and which train is due out at each.
*
* Playing a train card rolls 1D12 for a slot, and the roll landed nowhere a player could see — the
* card was gone and the answer to "so when does it run?" was one line of history. The Frame has
* carried the twelve slots all along and only the standalone replay ever drew them.
*
* `justSet` is the slot filled by the roll being reported this frame, so it can be flashed. It marks
* the moment rather than the state: the highlight is gone by the next render, which is what makes it
* read as "that just happened" rather than "this is special".
*/
export function timetableHtml(f: Frame, justSet: number | null): string {
const slots = f.timetable
.map((t, k) => {
const stage = k + 1;
const cls = [
t !== null ? 'due' : '',
stage === f.stage ? 'now' : '',
stage < f.stage ? 'past' : '',
k === justSet ? 'fresh' : '',
]
.filter(Boolean)
.join(' ');
const tip =
t !== null
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
: `Stage ${stage} — no train due out.`;
return (
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
`<span class="tt-s">${stage}</span>` +
`<span class="tt-t">${t !== null ? `T${t}` : '·'}</span></div>`
);
})
.join('');
return `<div class="tt">${slots}</div>`;
}
export function blockedHtml(f: Frame): string { export function blockedHtml(f: Frame): string {
return f.blocked.length === 0 return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>' ? '<li class="dim">nothing blocked</li>'
@@ -147,18 +184,28 @@ export function facilitiesHtml(f: Frame): string {
*/ */
`<div class="caps">` + `<div class="caps">` +
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') + stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') + (x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') + (x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
(x.modifiers.length (x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>` ? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
: '') + : '') +
`</div>` + `</div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` + /**
`<div class="boxes"><span class="dim">MEN AT WORK</span>` + * IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
* and unloads the other way: car → WORK → AT → MEN → red. Labelling the rows by colour
* alone left a player to work out which direction their industry ran; naming the step
* says it outright, and an industry that only receives has no green row to puzzle over.
*/
(x.allowsOut
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') + x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</div>` + `</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` + (x.allowsIn
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` + ? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${ `<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).' ? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
@@ -196,14 +243,42 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.stock .ld{color:#8fd6a0} .stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8} .stock .mt{color:#9fb6d8}
.stock.none{opacity:.4} .stock.none{opacity:.4}
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the /* A PILE THAT IS WAITING TO BE PICKED, drawn like the board's ghost squares because it is the same
legal-square outline on the board: the same question is being asked, so it looks the same. */ question: you have chosen a card, now choose where it goes. It was a 2px outline on a small tile
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer; in a panel the player might not be looking at, and was reported as "a very tiny highlight". Now it
box-shadow:0 0 0 2px rgba(90,169,230,.20)} grows, brightens, says DISCARD HERE outright, and pulses once so the eye finds it. */
.handcard.target:hover{background:#2b3d55} .handcard.target{border:2px dashed #5aa9e6;background:#233246;cursor:pointer;color:#e6e9ee;
box-shadow:0 0 0 4px rgba(90,169,230,.22);transform:translateY(-2px);
animation:pick 1.1s ease-out 2}
.handcard.target:hover{background:#31485f;border-style:solid;box-shadow:0 0 0 6px rgba(90,169,230,.30)}
.handcard.target::before{content:"DISCARD HERE";display:block;font-size:9px;letter-spacing:.09em;
color:#8fc4ee;font-weight:700;margin-bottom:2px}
@keyframes pick{0%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}
50%{box-shadow:0 0 0 9px rgba(90,169,230,.10)}
100%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}}
@media(prefers-reduced-motion:reduce){.handcard.target{animation:none}}
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
are stand out from the Salvage Yard beside them. */
.cardrow.aiming .handcard:not(.target){opacity:.4}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px; .stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)} outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)} .stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
dimmed, and a slot the die has just filled flashes once. */
.tt{display:flex;gap:3px;flex-wrap:wrap}
.tt-slot{flex:1 1 0;min-width:26px;text-align:center;border:1px solid #2c333d;border-radius:4px;
padding:2px 0;background:#1a1f26;font-size:10px;cursor:help;line-height:1.25}
.tt-slot .tt-s{display:block;color:#5f6b7a;font-size:9px}
.tt-slot .tt-t{display:block;color:#5f6b7a;font-variant-numeric:tabular-nums}
.tt-slot.due{background:#222c38;border-color:#4d6fa8}
.tt-slot.due .tt-t{color:#cfe0f5;font-weight:700}
.tt-slot.past{opacity:.45}
.tt-slot.now{border-color:#b98cf0;box-shadow:0 0 0 2px rgba(150,110,230,.20)}
.tt-slot.now .tt-s{color:#b98cf0;font-weight:700}
.tt-slot.fresh{animation:ttflash 1.5s ease-out 1}
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
ul.blocked{margin:0;padding-left:18px} ul.blocked{margin:0;padding-left:18px}
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080} .sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
.fac{border-top:1px solid #2c333d;padding:7px 0} .fac{border-top:1px solid #2c333d;padding:7px 0}
+7
View File
@@ -76,6 +76,10 @@ button.cardact.discard{color:#d6b48a}
box-shadow:0 6px 22px rgba(0,0,0,.5)} box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)} #phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}} @media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
thing that just happened — and it disappears with the next render, as a moment should. */
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
.makeup-note{font-size:11px;margin:0 0 5px} .makeup-note{font-size:11px;margin:0 0 5px}
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the /* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
buttons underneath it; struck through in red once it is gone. */ buttons underneath it; struck through in red once it is gone. */
@@ -187,6 +191,9 @@ ul.blocked li{padding:2px 0}
<div id="clsyard" class="yard"></div> <div id="clsyard" class="yard"></div>
<div class="dim" id="yardnote"></div> <div class="dim" id="yardnote"></div>
</section> </section>
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section> <section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section> <section><h2>Facilities</h2><div id="facs"></div></section>
</div> </div>
+8
View File
@@ -116,6 +116,14 @@ export function playCue(cue: string): void {
return; return;
} }
if (cue === 'schedule') {
// A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime.
for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square');
tone(t + 0.16, 880, 0.3, 0.09, 'sine');
tone(t + 0.16, 1320, 0.28, 0.05, 'sine');
return;
}
if (cue === 'day') { if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep. // The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) { for (const at of [t, t + 0.22]) {
+1
View File
@@ -402,6 +402,7 @@ describe('the replay behaves like the game it is replaying', () => {
// board and only history said where it went. // board and only history said where it went.
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound'); assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound'); assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same // One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow // accounting the live game is held to. The switching cues are counted separately: they follow
+71 -7
View File
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts'; import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts'; import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { timetableHtml } from '../src/web/panels.ts';
import { record, renderHtml } from '../src/sim/replay.ts'; import { record, renderHtml } from '../src/sim/replay.ts';
import { snapshot } from '../src/sim/view.ts'; import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts'; import { createGame as createEngineGame } from '../src/engine/setup.ts';
@@ -343,18 +344,54 @@ describe('the board shows freight work happening', () => {
); );
}; };
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn'); // A Mine Tipple only ships OUT, so it has no red Unloading box at all — only the boxes an
// industry actually uses are drawn now.
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw'], 'the waiting load is not drawn');
game.state.clock.phase = 'loadUnload'; game.state.clock.phase = 'loadUnload';
game.state.clock.currentActor = 0; game.state.clock.currentActor = 0;
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } })); assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible'); assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw'], 'starting a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 })); assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible'); assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw'], 'advancing a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 })); assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK'); assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL'], 'the load did not reach WORK');
});
it('draws the pipeline the way the freight actually flows', () => {
// REPORTED after unloading at a warehouse: "it went W, A, M, and then to the red box at the far
// right." §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way entirely:
// car → WORK → AT → MEN → red. So green and red BOTH sit beside MEN and the car sits beside
// WORK — but red was drawn at the far right, which is exactly where the car is, so an unload
// looked like it ran backwards across the row and landed on the end it came from.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const fac = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
});
area.grid.set('-1,0', fac('grocersWarehouse', false, true) as never);
area.grid.set('-1,1', fac('mineTipple', true, false) as never);
const cells = view(game).cells;
const order = (row: number, col: number): string[] => {
const cell = cells.find((c) => c.row === row && c.col === col)!;
return [...officeSvg([cell], 0).matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
};
// Receives only: the red box sits BEFORE the sign, which is the end an unload arrives at.
assert.deepEqual(order(-1, 0), ['red', 'maw', 'maw', 'maw'], 'the red box is not beside MEN');
// Ships out only: green before the sign, and no red box at all.
assert.deepEqual(order(-1, 1), ['green', 'maw', 'maw', 'maw'], 'the green box is not beside MEN');
}); });
it('names where the work has got to, in the tooltip', () => { it('names where the work has got to, in the tooltip', () => {
@@ -1465,6 +1502,32 @@ describe('the static build', () => {
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list'); assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
}); });
it('shows where a played train card landed on the timetable', () => {
// REPORTED: playing a train card rolls 1D12 for its departure Stage and the card simply left the
// hand — the answer arrived only as one line in the history panel. The twelve slots have been in
// the Frame all along and only the standalone replay ever drew them.
const game = newGame(430);
let landed: number | null = null;
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
if (game.scheduled !== null) {
landed = game.scheduled;
break;
}
}
assert.ok(landed !== null, 'no train was ever scheduled');
const f = view(game);
assert.ok(f.timetable[landed!] !== null, 'the slot the roll reported is empty');
// The roll gets a sound, because it is the one die a player rolls and it landed silently.
assert.ok(game.cues.includes('schedule'), 'scheduling a train made no sound');
// And the panel flashes the slot that changed, marking the moment rather than the state.
const html = timetableHtml(f, landed);
assert.match(html, /tt-slot[^"]*fresh/, 'the new slot is not flagged');
assert.equal((timetableHtml(f, null).match(/fresh/g) ?? []).length, 0, 'the flash must not persist');
assert.match(html, new RegExp(`T${f.timetable[landed!]}`), 'the train is not named in its slot');
});
it('draws the side panels from one place, on both screens', () => { it('draws the side panels from one place, on both screens', () => {
// A replay is the game being WATCHED rather than played, so it should look like the game. The // A replay is the game being WATCHED rather than played, so it should look like the game. The
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no // site's viewer had three panels against the play page's eight — no facilities, no blockers, no
@@ -1632,12 +1695,13 @@ describe('the Division map shows the whole route', () => {
it('draws the Division as a line with two ends, never as a loop', () => { 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 // 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. // does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
// and even run east, so each Division Point is a way on AND a way off.
for (const players of [1, 2, 3, 4]) { for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players); const svg = divisionFor(players);
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`); 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, /west end · in and out/, `${players}p does not label the west end`);
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`); assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
} }
}); });
}); });