diff --git a/CHANGELOG.md b/CHANGELOG.md index ba117ae..aec6051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,70 @@ The target is 20 Revenue over 5 Days. ## 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 **Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 1911266..df15be8 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -756,6 +756,7 @@ function arriveAtOffice( trainNumber: tray.trainNumber ?? 0, consist: tray.consist.map((c) => ({ ...c })), 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 diff --git a/src/engine/events.ts b/src/engine/events.ts index a102f9f..5b2787d 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -84,7 +84,12 @@ export type GameEvent = | { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string } | { type: 'trainHeld'; trainNumber: number; reason: 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: 'trainCompleted'; trainNumber: number; consist: RollingStock[] } | { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock } diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index f58a568..8b0f28c 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -132,7 +132,7 @@ export function divisionSvg(nodes: DivisionView[]): string { trains: n.trains.flat(), cap: n.capacity, 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(' · ')}` : ''}`, owner: null, // 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) { out += `` + ``; - out += `trains enter`; + // 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 += `west end · in and out`; } if (last && last !== first) { out += `` + ``; - out += `trains leave`; + out += `east end · in and out`; } out += ''; @@ -443,12 +447,19 @@ export function officeSvg( out += `${cell.row},${cell.col}`; /** - * 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 - * freight. It was drawn only in the side panel, so advancing a load changed nothing on the card - * the player was looking at. The printed cards put these squares under the track for exactly - * this reason. + * §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way + * entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car + * belongs beside WORK. + * + * 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; if (f) { @@ -459,25 +470,31 @@ export function officeSvg( let bx = 7; const by = H - 42; - // Green: what is waiting to be worked. - for (let i = 0; i < Math.max(1, f.greenCap); i++) { - out += box(bx, by, i < f.green.length, 'bs-green'); - bx += cellW + 2; + // Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows + // point opposite ways. An industry that only receives has no green boxes at all. + if (f.allowsOut) { + 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 += `›`; + bx += 9; } - out += `›`; - bx += 8; - // MEN | AT | WORK — a load sits in one of these while it is being worked. + if (f.allowsIn) { + for (let i = 0; i < Math.max(1, f.redCap); i++) { + out += box(bx, by, i < f.red.length, 'bs-red'); + bx += cellW + 2; + } + out += `‹`; + 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++) { out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]); bx += cellW + 2; } - out += `›`; - 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; - } + out += `${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}`; } // Standing room, drawn as the printed squares: filled means occupied. diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index a34e55f..8c77b4e 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -239,9 +239,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`, }; 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 { 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': 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. if (e.type === 'carsCoupled') out.push('couple'); 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') { // 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, diff --git a/src/sim/view.ts b/src/sim/view.ts index 3bb8b61..1d5a14e 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -109,6 +109,16 @@ export type FacilityView = { * anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)". */ 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. */ modifiers: string[]; /** @@ -322,6 +332,8 @@ function facilityView( porters: `${portersLeft(f)}/${f.porters}`, canFinish: canFinishHere(f), jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f), + allowsOut: f.allows.outbound, + allowsIn: f.allows.inbound, base: baseOf(card), 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': return `re-order consist [${i.order.join(',')}]`; - case 'freightAgent.stockOutbound': - return `stock a ${i.carType} at ${at(i.at)}`; + case 'freightAgent.stockOutbound': { + /** + * "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': return `unjam ${i.from} at ${at(i.at)}`; case 'freightAgent.clearInbound': @@ -683,7 +707,14 @@ export function snapshot( const chip = trainChip(s, t.tray); return { ...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), direction: t.direction, }; diff --git a/src/web/game.ts b/src/web/game.ts index a5c861d..4991e6d 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -91,6 +91,14 @@ export type Game = { * would be guesswork from a rendered frame. */ 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. */ @@ -125,7 +133,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, 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 // first, then let the clock take over. 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)); + // 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. if (game.log.length > 400) game.log.splice(0, game.log.length - 400); } diff --git a/src/web/main.ts b/src/web/main.ts index 991e540..479211f 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -7,7 +7,7 @@ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.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 { playCue } from './sound.ts'; import type { Game } from './game.ts'; @@ -293,6 +293,9 @@ function render(): void { ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined; 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]'))) { const node = el as HTMLElement; 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. if (menu.makeUp) { 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); // -- log @@ -354,7 +371,7 @@ function render(): void { lastPhase = f.phaseKey; 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 // not then play a backlog of everything that happened while it was off. @@ -413,7 +430,11 @@ function renderDistrict(f: ReturnType): void { }; } -function renderActions(menu: ReturnType, f: ReturnType): void { +function renderActions( + menu: ReturnType, + f: ReturnType, + justSet: number | null, +): void { const el = $('actions'); if (game.state.status !== 'active') { @@ -474,6 +495,19 @@ function renderActions(menu: ReturnType, f: ReturnType` + + `Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable` + : ''; + const movesNote = f.movesLeft !== null ? `
` + @@ -483,7 +517,7 @@ function renderActions(menu: ReturnType, f: ReturnTypeActions${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

      Facilities

      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`); } }); });