Actions${movesNote}`;
+ let html = `Actions
${scheduledNote}${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
diff --git a/src/web/panels.ts b/src/web/panels.ts
index 3168a25..8c23c60 100644
--- a/src/web/panels.ts
+++ b/src/web/panels.ts
@@ -97,6 +97,43 @@ export function yardHtml(rows: { type: string; loaded: number; empty: number }[]
.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 (
+ `` +
+ `${stage}` +
+ `${t !== null ? `T${t}` : '·'}
`
+ );
+ })
+ .join('');
+ return `${slots}
`;
+}
+
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? 'nothing blocked'
@@ -147,18 +184,28 @@ export function facilitiesHtml(f: Frame): string {
*/
`` +
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.') +
- 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.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
+ (x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
(x.modifiers.length
? `+ ${esc(x.modifiers.join(', '))}`
: '') +
`
` +
- `green${boxes(x.green, x.greenCap)}
` +
- `MEN AT WORK` +
+ /**
+ * 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
+ ? `
waiting to load${boxes(x.green, x.greenCap)}
`
+ : '') +
+ `
MEN AT WORK` +
x.maw.map((m) => `${m ? esc(m) : '·'}`).join('') +
`
` +
- `
red${boxes(x.red, x.redCap)}
` +
- `
siding${boxes(x.track, x.trackCap)}
` +
+ (x.allowsIn
+ ? `
cleared inbound${boxes(x.red, x.redCap)}
`
+ : '') +
+ `
siding${boxes(x.track, x.trackCap)}
` +
`
+
Timetable — who is due out, and when
+ 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.
+
Blocked — why nothing is moving
diff --git a/src/web/sound.ts b/src/web/sound.ts
index 7f65911..aec75c3 100644
--- a/src/web/sound.ts
+++ b/src/web/sound.ts
@@ -116,6 +116,14 @@ export function playCue(cue: string): void {
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') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {
diff --git a/test/replay.test.ts b/test/replay.test.ts
index 707ce9e..26e04eb 100644
--- a/test/replay.test.ts
+++ b/test/replay.test.ts
@@ -402,6 +402,7 @@ describe('the replay behaves like the game it is replaying', () => {
// board and only history said where it went.
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('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
// accounting the live game is held to. The switching cues are counted separately: they follow
diff --git a/test/web.test.ts b/test/web.test.ts
index 4584964..2b0b1ec 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.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 { snapshot } from '../src/sim/view.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.currentActor = 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.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.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', () => {
@@ -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');
});
+ 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', () => {
// 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
@@ -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', () => {
// 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]) {
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`);
+ assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
+ assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
}
});
});