From 08339effbaac9da844508a5c662039ae0e3b7deb Mon Sep 17 00:00:00 2001 From: Jesse Date: Sun, 16 Aug 2026 21:45:19 -0400 Subject: [PATCH] more info on train moves in history. train info added to tooltip on timetable and division map. support for multiple trains in area switching. more info on train7/8 wrt location of coach on initial train build. --- CHANGELOG.md | 183 ++++++++++++++++++++++++++++++++ TODO.md | 15 +++ package.json | 2 +- src/engine/advance.ts | 49 ++++++++- src/engine/events.ts | 10 +- src/sim/board-svg.ts | 8 +- src/sim/narrate.ts | 24 +++-- src/sim/view.ts | 113 +++++++++++++++++--- src/web/game.ts | 168 +++++++++++++++++++++++++++-- src/web/main.ts | 74 ++++++++++++- src/web/panels.ts | 11 +- src/web/play.html | 13 +++ test/advance.test.ts | 159 ++++++++++++++++++++++++++++ test/web.test.ts | 240 ++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1029 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac5549..e83107c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,189 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## Unreleased +## 0.4.6 — 2026-08-16 + +Four play reports in one release: trains that seemed to move before you could work them, a train card +you could not look at again, a switching list that never said which train it meant, and a Freight +Agent button that looked like it might be where the money was. + +### Why did that train move? The history now says + +**Reported:** "some trains seem to be moving before I can switch or do other operations on them. It +may be that the rules as written and implemented are just wrong. It may be that it's my perception." + +It was perception — but the log was feeding it, in one place with an outright falsehood. + +**The arrival line was wrong about Expedite.** It said an expedited train *"leaves again this same +Mainline Phase; there is no turn in which to work it"*. The expedited departure was moved to +Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so **Cargo is +available and only Local Operations is not** — the log was talking players out of the one turn they +had. Both branches now name the phases: + +> Train 8 ARRIVED at the Whistle Post carrying loaded boxcar, empty coach — it stands here for the +> rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, +> and it departs in that Stage's Mainline Phase. + +**Every departure now carries the rule that released it.** They all read alike before, so the one +that matters — an Expedited train going at the end of the Stage it arrived — looked exactly like an +ordinary train going a Stage later: + +> Train 8 HIGHBALLED — departed the Western Division Point onto the Mainline. **Why now:** it was +> made up and the Subdivision ahead was clear, so its run begins + +**EXPEDITE TURNS OUT TO BE CONDITIONAL**, which is most of why it feels arbitrary at the table. +`shiftChange` has always said so in a comment — *"an expedited train that would need a ruling simply +stays, and runs normally next Stage"* — and it happens often: on one seed with ordinary traffic +running, Train 6 The Sparrow was held this way and collected **four** Local Operations turns instead +of none. That was silent. It now says so, and the card and the arrival line no longer promise that an +Expedited train can never be switched. + +Three tests pin the claims the log makes, so a phase-order change cannot leave the narration lying: +an ordinary train gets a Local Operations turn and leaves in a Mainline Phase; an Expedited train +alone on the Division gets Cargo, no Local Operations, and leaves in Supervisor Shift; an Expedited +train held for a ruling gets its Local Operations turns after all. + +*Also fixed:* a Division Point departure was narrated as a **fake phase marker** — "▸ train 8 +highballed phase" — and the new departure line reported the wrong end of the railroad, because it +read the train's position after `enterMainline` had already moved it onto the Mainline. + +### The train's card, after the card is gone + +**Reported:** "once a train card's been played, how would I see that particular train card again — +what it's allowed to do and not allowed to do, and how it has to be loaded? Could I see it on a +timetable tooltip? If a train is on the board, could its tooltip include its special rules?" + +Yes to both. `trainRules` already produced the card as one line and the Office card already showed +it; it now also rides on: + +- **the Timetable** — hovering a slot gives the train's card under `ITS CARD —`, which is where a + player already looks for that train; +- **the Division map chip** — a train out on the Mainline can now be asked what it is, which is + exactly where "why did that leave without me?" gets asked. + +Two lines in that card were wrong and are corrected. **The coach rule** said *"a cut carrying it may +only be set out at the Office"*, which reads as a place you can do it — you cannot, §A.4 refuses the +Office square outright, so the coach can never be set out anywhere. **The Expedite rule** said only +"it departs in the same Stage it arrives", which is true and useless; it now names the phases, and +the clearance exception above. + +### Which train are you switching? + +**Reported from play:** "when switching, make it clear which train you are switching — it is possible +to have more than one train available." + +A train standing on an A/D track while a local shunts is ordinary, and the page got it wrong twice +over. `Frame.moves` was built from the **first tray in the map**, with a comment admitting it — *"One +crew. Solitaire has one, and with more the answer would depend on which is selected — a question the +page does not yet ask."* So the board highlighted one crew's reachable squares while the action list +offered every crew's moves under a single "Switching" heading of bare coordinates. + +Worse, and not noticed until this was pulled apart: identical labels were collapsed across the whole +action kind. *"move to (0, 2)"* describes one crew's move exactly as it describes another's, so **one +of the two was silently dropped and could not be chosen at all** — a legal move with no button. + +Now: + +- **One heading per crew**, naming the train and where it stands — *"Switching Train 8, standing at + (-1, -3)"*. The train is named in the heading rather than on every button, so the buttons stay + short. +- **A "Which train are you switching?" row** when there is more than one, and the crew chosen there + is the crew whose squares the board draws. One crew at a time on purpose: every crew's highlights + at once merge into a blob and stop meaning "here is where *this* train can go". +- **De-duplication is per crew**, so two trains that can both reach the same square each get a button. +- **A train that may not switch is not offered as one.** `movesFor` is pure track geometry and six + cards print "no switching", which `check` enforces and it does not — the Circus Train was being + offered as a crew to switch, with every one of its moves refused. + +Display state, deliberately not in the game: which train a player is looking at is not a fact about +the railroad, and two players may reasonably be looking at different ones. + +*Found while fixing it:* the grouping key had briefly been `type + separator + trayId`, and a stray +byte in that separator collapsed every crew back into one group. The crew now travels as **data on +the entry** rather than encoded into a map key that `GROUP_ORDER` also prefix-matches on. + +### The Freight Agent's red box says what it does, and what it does not pay + +**Reported:** "it wasn't obvious if that was a mechanical thing or if that's the actual revenue +generation. I believe that's actually where you get the revenue, and that completes unloading the +car." + +It is the mechanical one, and the button now says so. The Revenue for an inbound load is paid **one +step earlier** — §9.3: *"The last [Laborer] places the load on a red Unloading box. **Earn a Revenue +point.**"* Clearing the box is §6.3's Freight Agent operation, *"select one Rolling Stock from a +Facility's red Inbound box and place it into the Classification Yard pile"*, and the printed rule +attaches no Revenue to it. Confirmed against the engine as well as the rules: `clearInbound` emits +`inboundCleared` alone, with no `revenueChanged` beside it, and the score does not move. + + clear red box at (0,0) + +becomes + + send the loaded coach in the red Inbound box at (0,0) to the Classification Yard — pays nothing + (the Revenue was paid when the passengers detrained); it frees the last slot so more passengers + can detrain here + +The wording follows the facility: an inbound freight load and a coach whose passengers have detrained +both wait in the same red box, and both were paid for a step earlier. + +### Making up a Local says which order the cars go on in + +**Reported from play:** "I can't drop a car at all — trying to get an empty to an industry, but I +can't drop any cars on the siding first." Chased through two wrong guesses (it was not movement, and +it was not the 0.4.5 fix falling short) to a single train and a single arrangement. + +Trains **7/8 Local** print *"coach must remain on station track if switching"*, which the engine +reads as "the coach is never set out". A cut always comes off an **outer end**, so if the coach is on +one outer end and the engine is on the other, every cut on offer contains the coach — and the train +is locked. It cannot set out its freight car, and it cannot even uncouple to **run around**, because +that means leaving the coach standing too. The one escape is a Small Yard, of which there is exactly +1 copy in the 213-card deck. + +All six arrangements, checked against the rules rather than reasoned about: + +``` +ENGINE boxcar coach NOTHING — the train is locked +boxcar ENGINE coach can set out: boxcar +boxcar coach ENGINE can set out: boxcar +ENGINE coach boxcar can set out: boxcar +coach ENGINE boxcar can set out: boxcar +coach boxcar ENGINE NOTHING — the train is locked +``` + +Two of six lock, and they are exactly the two where the coach holds one outer end and the engine +holds the other. **It is only the Local.** Over 60 games, every other train that stood in a district +with cars where a set-out was allowed managed one — Drag Freight, Heavy Freight, the Freight Extra, +the Director's private car, Yard Xfer, Appleseed — 127 positions, zero blocked. The Local: 1,181 +positions, every one refused, all `COACH_MUST_STAY`. + +The cruelty is that the locking order `ENGINE boxcar coach` is both the prototypical mixed-train +make-up and what the game naturally produces: cars are appended as they are clicked with the engine +on the nose, so the **last car added takes the outer end**, and the freight car is the natural first +pick. + +**So the make-up panel now says so.** The whole remedy is "do not add the coach last", and it is +stated at the only moment it can still be acted on: + +- nothing on the train yet → *"Add the coach FIRST… ENGINE, coach, freight is the order that works."* +- coach on, freight still to come → *"Now add the freight car — it takes the outer end, leaving the + coach safely inside."* A **hint**, not a warning: the coach lands on the outer end the moment it + goes on, including for the player who has just been told to put it there, and colouring that as a + mistake punishes them for taking the advice. +- coach on the outer end with nothing left to add → a real **warning**, because there is no next step. +- freight already on and the coach still to come → *"send it out without the coach if you want it to + work the district"*, since "add the coach first" is advice it is too late to take. + +Shown **only** for a train the order can lock, so it is not a standing caption a player learns to +skip — every other train, and a Local with no coach coming, get nothing. + +**The rules are untouched.** This is guidance, not a rules change; the open §A.4 question about where +the Local's coach stands while its engine works is still open in `TODO.md`, and answering it (letting +the coach be set out at the Office, as the card's wording suggests) would let the prototypical +make-up work and make this advice unnecessary. + +**Verified end to end**, not just unit-tested: played to a real Local make-up, followed the advice +through both steps, and confirmed the resulting `ENGINE coach boxcar` can set a car out. + ## 0.4.5 — 2026-08-14 ### A train can back out of a curve again diff --git a/TODO.md b/TODO.md index efa6922..b548d6b 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,21 @@ Ordered within each section by how much it is currently costing us. ## Next +- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.** + Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never + set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the + other locks the train completely: it cannot set its freight car out, and cannot uncouple to run + around either, because that leaves the coach standing. Measured over 60 games — **1,181 + positions where a set-out should have been possible, every one refused; no other train blocked + once.** Two of the six possible arrangements lock, and `ENGINE boxcar coach` — the one that + locks — is both prototypical and what make-up naturally produces. + + **Worked around, not solved.** The make-up panel now tells the player to add the coach first + (v0.4.6), which produces `ENGINE coach boxcar` and works. The rules question is untouched: if + the coach may be set out **at the Office**, which is what the card's wording plainly says and + what a real mixed train does, then the prototypical make-up works and the advice becomes + unnecessary. That needs one exception to §A.4's blanket refusal to leave Rolling Stock at the + Office, for the coach and only on the Local. - [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick up one freight car at every location"* and also prints **Expedite**. Expedite means the train departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and diff --git a/package.json b/package.json index 54fd66b..89a2c05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.4.5", + "version": "0.4.6", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 008273f..fa03b95 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -507,12 +507,23 @@ function moveTrain( id: TrayId, tray: CrewTray, events: GameEvent[], + /** + * Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the + * Expedited arrivals to go again. The train cannot tell the difference, but the player must — see + * `trainHighballed.why`. + */ + pass: 'mainline' | 'expedite' = 'mainline', ): MoveOutcome { const dir = step(tray.direction); if (tray.position.at === 'divisionPoint') { + // Read BEFORE `enterMainline` overwrites `tray.position`: the departure line named the side the + // train was leaving FROM, and taking it afterwards read the mainline position instead — so every + // train reported leaving the Eastern Division Point, including the eastbound ones that had just + // been made up at the Western one. + const fromSide = (tray.position as { side: Direction }).side; const dpIndex = s.division.nodes.findIndex( - (n) => n.kind === 'divisionPoint' && n.side === (tray.position as { side: Direction }).side, + (n) => n.kind === 'divisionPoint' && n.side === fromSide, ); const target = dpIndex + dir; const node = s.division.nodes[target]; @@ -525,7 +536,15 @@ function moveTrain( enterMainline(s, node, id, tray, target); const dp = s.division.nodes[dpIndex]; if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id); - events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` }); + events.push({ + type: 'trainHighballed', + trainNumber: tray.trainNumber ?? 0, + from: `the ${fromSide === 'west' ? 'Western' : 'Eastern'} Division Point`, + to: 'the Mainline', + // Narrated as a phase marker until now — "▸ train 8 highballed phase" — which is not a phase + // and told the player nothing about why the train had started its run. + why: 'it was made up and the Subdivision ahead was clear, so its run begins', + }); return 'moved'; } @@ -580,6 +599,10 @@ function moveTrain( trainNumber: tray.trainNumber ?? 0, from: 'the Office', to: 'the Mainline', + why: + pass === 'expedite' + ? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it' + : 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase', }); return 'moved'; } @@ -1019,9 +1042,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { .sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0)); for (const [id, tray] of expedited) { tray.departsThisStage = false; - // A clearance question cannot be asked here — the Superintendent's phase is over — so an - // expedited train that would need a ruling simply stays, and runs normally next Stage. - if (moveTrain(s, id, tray, events) === 'needsClearance') continue; + /** + * An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on. + * + * Said in the log, because it is the difference between "this train leaves at the end of the + * Stage" and "this train is still here next turn", and a player watching one Expedited train + * vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary + * traffic running, Train 6 was held this way and collected four Local Operations turns. + */ + if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') { + events.push({ + type: 'trainHeld', + trainNumber: tray.trainNumber ?? 0, + reason: + 'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' + + 'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' + + 'runs like an ordinary train from now on — so you DO get a Local Operations turn with it', + }); + continue; + } } // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. diff --git a/src/engine/events.ts b/src/engine/events.ts index 4dbb315..f7a78c0 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -99,7 +99,15 @@ export type GameEvent = | { type: 'trainHeld'; trainNumber: number; reason: string } /** A train whose card pays for standing still (X18 Circus) collected on it. */ | { type: 'trainStoodStill'; trainNumber: number; where: string } - | { type: 'trainHighballed'; trainNumber: number; from: string; to: string } + /** + * `why` is the RULE that let it go now, not a restatement of the move. + * + * Reported from play: "some trains seem to be moving before I can switch or do other operations on + * them." Every departure looked identical in the history, so the one that mattered — an Expedited + * train leaving at the end of the Stage it arrived, with no Local Operations turn in between — + * read exactly like an ordinary train leaving a Stage later. + */ + | { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: 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; diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 327c958..eb97189 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -79,6 +79,8 @@ export function divisionSvg(nodes: DivisionView[]): string { engineAt?: number; /** East or west, always — a Division runs east and west and so does its rolling stock. */ facing?: 'e' | 'w'; + /** The train's printed card, so a train out on the Mainline can still be asked what it is. */ + what?: string; region?: number; direction?: string; stagesLeft?: number; @@ -311,7 +313,11 @@ export function divisionSvg(nodes: DivisionView[]): string { : ''; out += `` + + }${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}${ + // What the card prints. A train on the Mainline is exactly where "why did that leave without + // me?" gets asked, and EXPEDITED is the answer more often than not. + t.what ? `\n\n${esc(t.what)}` : '' + }">` + `` + `${esc(label)}`; out += ''; diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 8de9e2e..c1eb7d3 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -244,24 +244,32 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`, }; case 'trainHighballed': + // The RULE that released it, not a restatement of the move. "Some trains seem to be moving + // before I can switch them" was reported against a log in which every departure read alike. return { tone: 'plain', - text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`, + text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}. Why now: ${e.why}`, }; case 'trainArrived': /** - * AN EXPEDITED TRAIN IS NOT COMING BACK ROUND. + * AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER. * - * 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. + * This said "it will highball again next Mainline Phase" to every arrival, which is backwards + * for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which + * was ALSO wrong and in a way that cost the player work: the expedited departure was moved to + * Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so + * Cargo IS available and only Local Operations is not. Telling a player there is "no turn in + * which to work it" talked them out of the one turn they had. + * + * So both branches now name the phases: what you can still do with this train, and when it + * goes. That is the whole of the reported confusion — "some trains seem to be moving before I + * can switch or do other operations on them". */ return { tone: 'plain', 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`, + ? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.` + : `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`, }; case 'trainDiverted': return { diff --git a/src/sim/view.ts b/src/sim/view.ts index ac99e62..03e5082 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -198,6 +198,14 @@ export type TrainChip = { cars: string[]; engineAt: number; facing: 'e' | 'w'; + /** + * The train's printed card — what it may and may not do. + * + * The Office card has carried this for a while; the Division map chip carried only the consist, so + * a train out on the Mainline could not be asked what it was. Same text either way, from + * `trainRules`, so the two views cannot describe one train differently. + */ + what: string; /** * Which region of a Mainline card the train is standing in, and which way it is going. Absent * everywhere else: a Division Point is a single queue, and inside a district a train moves by @@ -337,11 +345,23 @@ export type Frame = { * `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule * that actually refused the square rather than a second guess at it. */ + /** + * ONE ENTRY PER CREW, not one for the district. + * + * This used to be a single object built from the FIRST tray in the map, with a comment admitting + * it: "One crew. Solitaire has one, and with more the answer would depend on which is selected". + * More than one crew in a district is ordinary — trains stand on the A/D tracks while a local + * shunts — and when it happened the board highlighted one crew's squares while the action list + * offered every crew's moves, with nothing saying which was which. + */ moves: { + trayId: string; + /** "Train 8", "the local crew" — what to call it on screen. */ + label: string; from: { row: number; col: number }; to: { row: number; col: number }[]; blocked: { coord: { row: number; col: number }; kind: string; why: string }[]; - } | null; + }[]; facilities: FacilityView[]; hand: string[]; /** What each hand card does, in the same order — names alone are not a playable hand. */ @@ -382,6 +402,16 @@ export type Frame = { }; /** 12 slots; the train number due out at each Stage, or null. */ timetable: (number | null)[]; + /** + * THE TRAIN'S CARD, SLOT BY SLOT — so a card played on Day 1 can still be read on Day 4. + * + * Reported from play: "once a train card's been played, how would I see that particular train card + * again — what it's allowed to do and not allowed to do, and how it has to be loaded?" The card + * goes onto the Timetable and is then gone, and its restrictions are what decide whether a train + * can be switched, worked by Porters, or loaded at all. The Timetable is where the player already + * looks for that train, so the card rides there. + */ + timetableWhat: (string | null)[]; blocked: Impediment[]; trains: { label: string; where: string }[]; /** @@ -697,8 +727,37 @@ export function describeIntent(s: GameState, i: Intent): string { } case 'freightAgent.unjam': return `unjam ${i.from} at ${at(i.at)}`; - case 'freightAgent.clearInbound': - return `clear red box at ${at(i.at)}`; + /** + * SAY THAT IT PAYS NOTHING, because the obvious guess is that it does. + * + * Reported from play: "it wasn't obvious if that was a mechanical thing or if that's the actual + * revenue generation — I believe that's actually where you get the revenue, and that completes + * unloading the car." It is the first: the Revenue for an inbound load was already paid, one + * step earlier, when the Laborer walked it off the car into the red box (`unloadCompleted` pays + * `freightPerLoad`). Clearing the box banks nothing — it empties the one slot an inbound load + * can finish in, so the NEXT car can be unloaded, and costs the whole Freight Agent action for + * the Stage. + * + * Verified against the engine rather than read off the rules: `freightAgent.clearInbound` emits + * `inboundCleared` alone, with no `revenueChanged` beside it. + */ + case 'freightAgent.clearInbound': { + const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null; + const car = f?.inboundBox[i.index]; + const full = f ? f.inboundBox.length >= f.capacity.inbound : false; + // The red box serves both halves of §9: an inbound freight load that has come off its car, + // and a coach whose passengers have detrained. Both were paid for a step earlier, and both + // sit in the box until the Freight Agent moves them on. + const paid = + f?.kind === 'passenger' + ? 'the Revenue was paid when the passengers detrained' + : 'the Revenue was paid when the load reached the box'; + const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here'; + return ( + `send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` + + ` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}` + ); + } case 'laborer.startLoad': return `start a load at ${at(i.at)}`; case 'laborer.advanceLoad': @@ -1052,6 +1111,7 @@ export function snapshot( classificationTotal: s.yards.classificationYard.length, }, timetable: [...s.timetable], + timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))), decision, wasted, option: turnOf(s, viewer).option, @@ -1276,7 +1336,13 @@ export function trainRules(t: { if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt'); if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else'); if (p.rules.coachStaysOnStationTrack) { - parts.push('THE COACH STAYS AT THE STATION — a cut carrying it may only be set out at the Office'); + // Said "may only be set out at the Office", which reads as a place you can do it. You cannot: + // §A.4 refuses the Office square outright, so the coach can never be set out anywhere — which + // is why the make-up order decides whether this train can switch at all. + parts.push( + 'THE COACH IS NEVER SET OUT — so keep it OFF the outer end of the train, or nothing can come ' + + 'off at all. Add the coach before the freight car when making up.', + ); } if (p.rules.oneFreightPerLocation) { parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn'); @@ -1288,7 +1354,18 @@ export function trainRules(t: { parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward'); } - if (p.rules.expedite) parts.push('EXPEDITED — it departs in the same Stage it arrives (§7)'); + if (p.rules.expedite) { + // The single most-asked question in play — "why did that train leave before I could switch it?" + // — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of + // the Stage, which is after the Porters and Laborers but before the next Local Operations. + parts.push( + 'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' + + 'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' + + 'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' + + 'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' + + 'stays and runs like any other from then on.', + ); + } if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once'); return parts.join(' · '); } @@ -1556,24 +1633,35 @@ function countStock( * Only while a switching turn is actually running and only while Moves remain: a highlight that * survives into the Cargo phase is an invitation to click something that is no longer offered. * - * One crew. Solitaire has one, and with more the answer would depend on which is selected — a - * question the page does not yet ask. + * EVERY crew in the district, each with its own squares. It used to return the first one it found, + * which is the same thing in solitaire's opening but not once a train is standing at the Office + * while a local shunts: the board then drew one crew's reachable squares and the action list offered + * both crews' moves, so half the highlights belonged to a train the player was not moving. */ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] { const turn = turnOf(s, player); - if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null; - if (turn.movesRemaining < 1) return null; + if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return []; + if (turn.movesRemaining < 1) return []; + const out: Frame['moves'] = []; for (const [id, tray] of s.trays) { if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; + /** + * A crew that may not switch is not a crew you can be switching. + * + * `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print + * "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which + * train are you switching?" row is offering a train whose every move the rules refuse. + */ + if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue; const { to, blocked } = movesFor(s, player, id); - return { from: tray.position.coord, to, blocked }; + out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked }); } - return null; + return out; } function trainChip(s: GameState, id: string): TrainChip { const t = s.trays.get(id); - if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' }; + if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e', what: '' }; /** * The engine is drawn IN the consist, at the position it occupies. * @@ -1592,6 +1680,7 @@ function trainChip(s: GameState, id: string): TrainChip { cars, engineAt: at, facing: railFacingOf(t), + what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t), }; } diff --git a/src/web/game.ts b/src/web/game.ts index 39d623e..8d7f3e8 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -204,25 +204,85 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro if (actor === null) return { options: [], groups: [] }; const options = legalActions(game.state, actor); - const byKind = new Map(); + /** + * Entries carry the crew they belong to, rather than it being encoded into the map key. + * + * An earlier version keyed this map by `type + separator + trayId`, which worked and was a + * standing invitation: the key is also what `GROUP_ORDER` prefix-matches on, so the separator had + * to survive every edit to a line nobody would think to check. One stray byte and every crew's + * moves silently collapsed back into a single group. The tray is data; it travels as data. + */ + type Entry = { index: number; label: string; tip?: string; trayId?: string }; + const byKind = new Map(); options.forEach((intent, index) => { const label = describeIntent(game.state, intent); + const trayId = 'trayId' in intent ? intent.trayId : undefined; const list = byKind.get(intent.type) ?? []; const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null; const tip = cardId ? cardDescription(game.state, cardId) : undefined; - // Orientation variants and duplicate copies describe identically; showing one is enough, and a - // list of forty identical rows hides the real choice rather than presenting it. - if (!list.some((a) => a.label === label)) list.push({ index, label, ...(tip ? { tip } : {}) }); + /** + * Identical labels are collapsed, which is right for forty copies of the same track rotation and + * wrong for two different trains: "move to (0, 2)" describes Train 8's move and the local crew's + * move identically, so one of them was silently dropped and could not be chosen at all. Matching + * on the crew as well makes the de-duplication per crew, which is what it always meant. + */ + if (!list.some((a) => a.label === label && a.trayId === trayId)) { + list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}) }); + } byKind.set(intent.type, list); }); + /** Groups take the plain shape; the crew was only ever needed to split them. */ + const plain = (entries: Entry[]): ActionGroup['actions'] => + entries.map(({ index, label, tip }) => ({ index, label, ...(tip ? { tip } : {}) })); + const groups: ActionGroup[] = []; const used = new Set(); for (const { prefix, title } of GROUP_ORDER) { const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k)); + + /** + * ONE GROUP PER CREW, NAMED — because more than one train can be switching in a district. + * + * Reported from play: with two crews on the board, every move from both of them arrived in a + * single "Switching" list of bare coordinates, and there was no way to tell which train a button + * belonged to. Naming the train in the heading rather than on every button keeps the buttons + * short, and the crew's own square is in the heading so the list can be matched to the board. + * + * `switch.end` carries no tray and is the whole turn rather than one crew's, so it keeps its own + * heading at the bottom. + */ + if (prefix === 'switch.') { + const perCrew = new Map(); + const loose: Entry[] = []; + for (const k of kinds) { + used.add(k); + for (const entry of byKind.get(k) ?? []) { + if (entry.trayId === undefined) loose.push(entry); + else perCrew.set(entry.trayId, [...(perCrew.get(entry.trayId) ?? []), entry]); + } + } + for (const [trayId, entries] of perCrew) { + const tray = game.state.trays.get(trayId); + const where = + tray?.position.at === 'grid' + ? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})` + : ''; + groups.push({ + kind: prefix, + title: `Switching ${trainName(game.state, trayId)}${where}`, + actions: plain(entries), + }); + } + // `switch.end` belongs to the TURN rather than to any one crew, so it gets its own heading — + // "Switching" over a lone "End Local Operations" reads as a crew with nothing it can do. + if (loose.length > 0) groups.push({ kind: prefix, title: 'Finish', actions: plain(loose) }); + continue; + } + const actions = kinds.flatMap((k) => { used.add(k); - return byKind.get(k) ?? []; + return plain(byKind.get(k) ?? []); }); if (actions.length > 0) { // The New Train group names the TRAIN and what its card calls for. Without it, Extra X22 @@ -341,7 +401,17 @@ export type Menu = { * is already on screen showing exactly those cars by type and load state. The yard is the surface; * these key each chip to the option that adds it. */ - makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null; + makeUp: { + trayId: string; + title: string; + cars: MakeUpAction[]; + pass: number | null; + /** + * The order to add the cars in, when the order decides whether the train can work at all. + * Absent for every train where it does not matter, which is nearly all of them. + */ + advice: { text: string; tone: 'hint' | 'warn' } | null; + } | null; }; /** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */ @@ -467,6 +537,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu { title: consistTitle(game, filling) ?? 'Making up the train', cars: makeUpCars, pass, + advice: makeUpAdvice(game, filling, makeUpCars), } : null; @@ -628,6 +699,91 @@ function consistTitle(game: Game, trayId: string): string | null { return `Making up ${name}: its card calls for ${calls}${note}`; } +/** + * THE ORDER YOU ADD THE CARS IN CAN DECIDE WHETHER THE TRAIN CAN EVER SWITCH. + * + * Only trains 7/8 Local, and only because of the rule printed on them: "coach must remain on + * station track if switching", which the engine reads as "the coach is never set out". A cut always + * comes off an OUTER end, so if the coach is on one outer end and the engine is on the other, every + * cut on offer contains the coach and the train is locked — it cannot set out its freight car, and + * it cannot even uncouple to run around, because that means leaving the coach standing too. Measured + * over 60 games: 1,181 positions where a set-out should have been possible, every one refused. + * + * Cars are appended as they are clicked and the engine stays on the nose, so the make-up reads + * ENGINE, first car, second car — and the LAST car added is the one on the outer end. Hence the + * whole of the advice: do not let the coach be last. + * + * ENGINE coach boxcar the boxcar is on the outer end and can be set out + * ENGINE boxcar coach locked — nothing can ever come off + * + * Said here rather than left to the player to discover, because the dead end is invisible until the + * train is out on the district with no button to press and no explanation for it. + */ +function makeUpAdvice( + game: Game, + trayId: string, + cars: readonly MakeUpAction[], +): { text: string; tone: 'hint' | 'warn' } | null { + const tray = game.state.trays.get(trayId); + if (!tray) return null; + if (!trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.coachStaysOnStationTrack) return null; + + const hasCoach = tray.consist.some((c) => c.type === 'coach'); + const lastIsCoach = tray.consist[tray.consist.length - 1]?.type === 'coach'; + // Nothing to say about a coach that is not coming: the Division Yard may hold none, and a Local + // made up of freight alone switches perfectly well. + const coachToCome = cars.some((c) => c.carType === 'coach'); + if (!hasCoach && !coachToCome) return null; + + /** + * The coach is on the outer end — which is where it lands the moment it goes on, including for a + * player who has just correctly put it on first. So the tone turns on whether it can still be + * fixed: while a freight car is there to add, this is the NEXT STEP and not a mistake, and + * colouring it as a mistake punishes the player for taking the advice. + */ + if (lastIsCoach) { + const freightToCome = cars.some((c) => c.carType !== 'coach'); + return freightToCome + ? { + tone: 'hint', + text: + 'Now add the freight car — it takes the outer end, leaving the coach safely inside. Sent out ' + + 'as it stands, with the coach on the outer end, this train would not be able to set anything ' + + 'out at all.', + } + : { + tone: 'warn', + text: + 'The coach is on the outer end and there is nothing left to add that would take that end off ' + + 'it. This train may never set its coach out, so it will not be able to set anything out at ' + + 'all — not even to uncouple for a run-around.', + }; + } + + if (!hasCoach) { + // Nothing on yet, so the good order is still free. + if (tray.consist.length === 0) { + return { + tone: 'hint', + text: + 'Add the coach FIRST. This train may never set its coach out, and the last car added is the one ' + + 'on the outer end — so a coach added last blocks the freight car in behind it and the train can ' + + 'never switch. ENGINE, coach, freight is the order that works.', + }; + } + // Freight is already on, so a coach added now can only land on the outer end. Saying "add the + // coach first" here would be advice it is too late to take. + return { + tone: 'warn', + text: + 'The freight car is already on, so a coach added now would land on the outer end — and this train ' + + 'may never set its coach out, which would leave it unable to switch at all. Send it out without ' + + 'the coach if you want it to work the district.', + }; + } + return null; +} + /** * §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is * held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the diff --git a/src/web/main.ts b/src/web/main.ts index 5bdea45..cd50c98 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -69,8 +69,27 @@ let soundOn = false; * ended or what happened in between. Reported exactly that way. */ let lastPhase: string | null = null; +/** + * WHICH CREW THE BOARD IS DRAWING, when the district holds more than one. + * + * Reported from play: "make it clear which train you are switching — it is possible to have more + * than one train available." A train standing on an A/D track while a local shunts is ordinary, and + * the board used to highlight whichever crew came first out of the map while the action list offered + * every crew's moves under one heading. + * + * Display state, deliberately not in the game: which train a player is looking at is not a fact + * about the railroad, and in a multiplayer game two players may reasonably be looking at different + * ones. Cleared whenever the named crew stops being one of the choices. + */ +let selectedCrew: string | null = null; const FOCUS_PHASES = new Set(['localOps', 'loadUnload']); +/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */ +function pickedCrew(f: Frame): Frame['moves'][number] | null { + if (f.moves.length === 0) return null; + return f.moves.find((m) => m.trayId === selectedCrew) ?? f.moves[0] ?? null; +} + const $ = (id: string): HTMLElement => { const el = document.getElementById(id); if (!el) throw new Error(`missing element: ${id}`); @@ -311,14 +330,20 @@ function render(): void { * WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own * tooltip, so "why can I not get into that industry?" is answered by hovering the industry. */ - if (f.moves && !forPlay) { - const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`); + /** + * ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than + * drawing one crew's: the highlights merge into a single blob and stop meaning "here is where + * THIS train can go", which is the whole reason they are on the board. + */ + const crew = pickedCrew(f); + if (crew && !forPlay) { + const here = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"]`); if (here) here.classList.add('bs-from'); - for (const c of f.moves.to) { + for (const c of crew.to) { const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`); if (g) g.classList.add('bs-focus'); } - for (const b of f.moves.blocked) { + for (const b of crew.blocked) { const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`); if (!g) continue; // Two different things wearing two different marks. A turnout you cannot STOP on is not in @@ -711,6 +736,26 @@ function renderActions( // no worker able to act, the only thing offered is "End my Cargo phase". let html = `

Actions

${scheduledNote}${movesNote}`; + /** + * WHICH TRAIN AM I SWITCHING? Only asked when there is more than one crew to be switching, so a + * solitaire opening — one crew, no ambiguity — is unchanged. The chosen one is the crew whose + * reachable squares the board draws, so this row and the highlights are the same statement. + */ + if (f.moves.length > 1) { + const chosen = pickedCrew(f); + html += + `

Which train are you switching?

` + + f.moves + .map( + (m) => + ``, + ) + .join('') + + `
`; + } + /** * WHAT IS LEFT IN THE ACTION LIST. * @@ -757,6 +802,17 @@ function renderActions( ? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.' : 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') + `` + + /** + * WHICH ORDER TO ADD THEM IN, when the order is what decides whether the train can work. + * + * Only ever present for a train the order can lock — 7/8 Local — so this is not a standing + * caption a player learns to skip. Reported from play as "I can't drop a car at all": the + * Local made up ENGINE, freight, coach cannot set anything out for the rest of the game, and + * nothing on screen said so until the crew was out on the district with no button to press. + */ + (menu.makeUp.advice + ? `
${esc(menu.makeUp.advice.text)}
` + : '') + (menu.makeUp.pass !== null ? `` : '') + @@ -817,7 +873,15 @@ function renderActions( for (const b of Array.from(el.querySelectorAll('button.act'))) { const node = b as HTMLElement; - node.onclick = () => apply(Number(node.dataset['i'])); + // The crew buttons choose what the board draws; they submit nothing, so they must not fall + // through to `apply` with an undefined index. + const crewId = node.dataset['crew']; + node.onclick = crewId + ? () => { + selectedCrew = crewId; + render(); + } + : () => apply(Number(node.dataset['i'])); } } diff --git a/src/web/panels.ts b/src/web/panels.ts index 60532bd..f9d813e 100644 --- a/src/web/panels.ts +++ b/src/web/panels.ts @@ -120,9 +120,18 @@ export function timetableHtml(f: Frame, justSet: number | null): string { ] .filter(Boolean) .join(' '); + /** + * THE CARD ITSELF, not just the number. + * + * A train card is played once and then lives only as a number in a Timetable slot — but what + * it prints is what decides whether the train can be switched, worked by Porters, or loaded at + * all. Reported from play: "once a train card's been played, how would I see that particular + * train card again?" This is where the player already looks for that train. + */ 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.` + ? `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.` + + (f.timetableWhat[k] ? `\n\nITS CARD — ${f.timetableWhat[k]}` : '') : `Stage ${stage} — no train due out.`; return ( `
` + diff --git a/src/web/play.html b/src/web/play.html index 77f2232..678bcb4 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -127,6 +127,19 @@ button.cardact.discard{color:#d6b48a} .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} +/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives, + and the chosen one is the crew whose squares the board is drawing, so it wears the same violet + "you are here" the rest of the page uses. */ +.crewpick{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline} +.crewpick h3{flex:0 0 100%;margin:0 0 2px} +button.act.crew{width:auto;display:inline-block;margin:0} +button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);color:#e9dcff} +/* THE ORDER TO ADD THE CARS IN. Only ever drawn for a train whose make-up order can lock it (7/8 + Local), so it is worth a coloured rule down the side: a player who sees this once needs to read + it, and will not see it again on the next train. `warn` is the train already made up badly. */ +.makeup-advice{font-size:11px;line-height:1.45;margin:0 0 6px;padding:5px 8px;border-radius:4px; + border-left:3px solid #4d6fa8;background:rgba(60,100,170,.13)} +.makeup-advice.warn{border-left-color:#e0a060;background:rgba(200,150,60,.15)} /* 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. */ .moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px; diff --git a/test/advance.test.ts b/test/advance.test.ts index 2bd9ee4..21348b9 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -12,6 +12,7 @@ import { applyIntent, areaOf } from '../src/engine/apply.ts'; import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; +import { developerBot } from '../src/sim/bot.ts'; import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts'; import { railFacingOf } from '../src/engine/state.ts'; @@ -680,3 +681,161 @@ describe('a train on the Division points the way it is running', () => { } }); }); + +// --------------------------------------------------------------------------- + +describe('the history says WHY a train moved, and says it truthfully', () => { + /** + * REPORTED from play: "some trains seem to be moving before I can switch or do other operations on + * them — it may be the rules are wrong, or it may be my perception." + * + * It was perception, but the log was actively feeding it. Every departure read alike, and the + * arrival line for an Expedited train said there was "no turn in which to work it" — which is + * false and cost the player the Cargo turn they did have. These pin the two claims the log now + * makes, so a phase-order change cannot leave the narration lying about it. + */ + /** + * Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases + * it spends standing in a district — `advance` is called many times inside one phase, so a raw + * count would say nothing. Driven by the developer bot rather than a hand-rolled phase-ender, + * which deadlocks the moment Local Operations wants an option chosen before it can be ended. + */ + const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => { + const s = game(7, { length: 'standard' }); + for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; + /** + * A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train, + * and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and + * runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages + * and collected four Local Operations turns, which is correct behaviour and the opposite of what + * this test is trying to pin. Clearing the timetable isolates the rule from the traffic. + */ + s.timetable = s.timetable.map(() => null); + const id = 'watched'; + s.trays.set(id, { + id, trainNumber, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'coach', loaded: true }] as never, + direction: 'east', facing: 'e', + position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west'); + if (dp?.kind === 'divisionPoint') dp.holding.push(id); + + const standingIn = new Set(); + let reachedOffice = false; + let leftIn = ''; + + for (let i = 0; i < 20_000; i++) { + const phase = s.clock.phase; + const onGrid = s.trays.get(id)?.position.at === 'grid'; + if (onGrid) { + reachedOffice = true; + standingIn.add(`${s.clock.day}|${s.clock.stage}|${phase}`); + } + + const r = advance(s); + + if (reachedOffice && onGrid && s.trays.get(id)?.position.at !== 'grid' && leftIn === '') { + leftIn = phase; + break; + } + if (s.status === 'finished') break; + if (r.needsInput) { + /** + * A PLAYER WHO DOES NOTHING. The developer bot plays train cards, and a train card rolls + * itself onto the Timetable — so clearing the Timetable above achieved nothing while the bot + * was driving, and the watched train kept meeting traffic it had to be cleared past. This + * ends every turn without playing anything, which is the only way to isolate one train. + */ + const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + if (actor === null) break; + const options = legalActions(s, actor); + if (options.length === 0) break; + const pick = + options.find((x) => x.type === 'mainline.clearance') ?? + options.find( + (x) => + x.type === 'switch.end' || + x.type === 'draw.end' || + x.type === 'loadUnload.end' || + x.type === 'freightAgent.end', + ) ?? + options.find((x) => x.type === 'localOps.choose') ?? + options[0]!; + if (!applyIntent(s, actor, pick).ok) break; + } + } + + const count = (phase: string): number => [...standingIn].filter((k) => k.endsWith(`|${phase}`)).length; + assert.ok(reachedOffice, `train ${trainNumber} never reached a district at all`); + return { localOps: count('localOps'), loadUnload: count('loadUnload'), leftIn }; + }; + + it('gives an ordinary train a Local Operations turn before it goes', () => { + // Train 12 Drag Freight — no Expedite. Arrives in a Mainline Phase, stands, and the player gets + // a Local Operations turn with it in the NEXT Stage. This is what the arrival line promises. + const r = phasesWith(12); + assert.ok(r.localOps >= 1, `an ordinary train got ${r.localOps} Local Operations turns`); + assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase'); + }); + + it('gives an Expedited train the Cargo phase but never Local Operations', () => { + // Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair: + // Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of + // the Stage rather than in a Mainline Phase. + const r = phasesWith(6); + assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all'); + assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase'); + assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift'); + }); + + it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => { + /** + * EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table. + * + * `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs + * normally next Stage" — and with ordinary traffic on the Division it happens a lot: driven by + * the developer bot on the same seed, Train 6 was held and collected FOUR Local Operations turns + * instead of none. So the log and the card must not promise that an Expedited train can never be + * switched; they say "unless the Superintendent has to hold it", and this is why. + */ + const s = game(7, { length: 'standard' }); + for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; + const id = 'watched'; + s.trays.set(id, { + id, trainNumber: 6, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'coach', loaded: true }] as never, + direction: 'east', facing: 'e', + position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west'); + if (dp?.kind === 'divisionPoint') dp.holding.push(id); + + let localOps = 0; + const seen = new Set(); + let heldByClearance = false; + + for (let i = 0; i < 20_000; i++) { + if (s.trays.get(id)?.position.at === 'grid' && s.clock.phase === 'localOps') { + const k = `${s.clock.day}|${s.clock.stage}`; + if (!seen.has(k)) { seen.add(k); localOps += 1; } + } + const r = advance(s); + if (r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id)) heldByClearance = true; + if (s.status === 'finished' || s.clock.day > 3) break; + if (r.needsInput) { + const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + if (actor === null) break; + const options = legalActions(s, actor); + if (options.length === 0) break; + if (!applyIntent(s, actor, developerBot.choose(s, actor, options)).ok) break; + } + } + + assert.ok(heldByClearance, 'the Expedited train was never asked for a clearance ruling'); + assert.ok( + localOps > 0, + 'an Expedited train held for a ruling should get the Local Operations turns it was denied', + ); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 976359e..032d6c8 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -8,6 +8,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { turnOf } from '../src/engine/state.ts'; +import { areaOf, check } from '../src/engine/apply.ts'; +import type { Game } from '../src/web/game.ts'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; @@ -2676,3 +2678,241 @@ describe('the New Game dialog', () => { assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL'); }); }); + +// --------------------------------------------------------------------------- + +describe('making up a Local says which order the cars go on in', () => { + /** + * REPORTED from play: "I can't drop a car at all — trying to get an empty to an industry, but I + * can't drop any cars on the siding first." + * + * Trains 7/8 print "coach must remain on station track if switching", which the engine reads as + * "the coach is never set out". A cut comes off an OUTER end, so a coach on one outer end with the + * engine on the other leaves every available cut containing the coach: the train cannot set out + * its freight car, and cannot uncouple to run around either, because that leaves the coach + * standing too. Measured over 60 games, 1,181 positions where a set-out should have been possible + * and every single one refused — and no other train blocked once. + * + * Cars are appended as they are clicked with the engine on the nose, so the LAST car added takes + * the outer end. The whole remedy is therefore "do not add the coach last", and the whole bug was + * that nothing said so until the crew was on the district with no button to press. + */ + const localTray = (consist: { type: string; loaded: boolean }[]): Game => { + const game = newGame(4242); + const s = game.state; + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber: 8, trainIsExtra: false, engineAt: 0, + consist: consist as never, direction: 'east', + position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west'); + if (dp?.kind === 'divisionPoint') dp.holding.push(id); + s.clock.phase = 'newTrain'; + s.clock.currentActor = 0; + return game; + }; + + const box = { type: 'boxcar', loaded: false }; + const coach = { type: 'coach', loaded: false }; + + it('tells an empty Local to put the coach on first', () => { + const advice = actionMenu(localTray([])).makeUp?.advice; + assert.ok(advice, 'no advice offered while making up a Local'); + assert.equal(advice.tone, 'hint'); + assert.match(advice.text, /coach FIRST/); + }); + + it('reads the coach-on-first step as the next step, not as a mistake', () => { + // The coach lands on the outer end the moment it goes on — including for the player who has just + // been told to put it there first. Colouring that as an error punishes them for taking the + // advice, so while a freight car is still there to add this stays a hint and names it. + const advice = actionMenu(localTray([coach])).makeUp?.advice; + assert.ok(advice, 'no advice offered with the coach on the outer end'); + assert.equal(advice.tone, 'hint'); + assert.match(advice.text, /Now add the freight car/); + }); + + it('turns into a real warning once nothing is left that would fix it', () => { + // Coach on the outer end and an empty Division Yard: there is no next step, and the train is + // about to go out unable to set anything out for the rest of its run. + const game = localTray([coach]); + game.state.yards.divisionYard.length = 0; + const advice = actionMenu(game).makeUp?.advice; + assert.ok(advice, 'no advice offered with the coach stuck on the outer end'); + assert.equal(advice.tone, 'warn'); + assert.match(advice.text, /nothing left to add/); + }); + + it('does not say "coach first" once it is too late to add it first', () => { + // The freight car is already on, so a coach added now can only land on the outer end. Telling + // the player to add the coach first here would be advice they cannot take — the honest answer + // is that the coach is what would lock the train, and it may be left off. + const advice = actionMenu(localTray([box])).makeUp?.advice; + assert.ok(advice, 'no advice offered with the freight car already on'); + assert.equal(advice.tone, 'warn'); + assert.doesNotMatch(advice.text, /FIRST/); + assert.match(advice.text, /without the coach/); + }); + + it('says nothing when there is no coach coming', () => { + // A Division Yard with no coach in it, which is an ordinary state late in a game. A Local made + // up of freight alone switches perfectly well, so there is nothing to warn about. + const game = localTray([]); + for (let i = game.state.yards.divisionYard.length - 1; i >= 0; i--) { + if (game.state.yards.divisionYard[i]!.type === 'coach') game.state.yards.divisionYard.splice(i, 1); + } + assert.equal(actionMenu(game).makeUp?.advice ?? null, null); + }); + + it('says nothing at all for a train the order cannot lock', () => { + // Every other train in the game. A standing caption on all of them is how this one gets skipped. + const game = newGame(4242); + const s = game.state; + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber: 10, trainIsExtra: false, engineAt: 0, // Heavy Freight — no coach rule + consist: [box] as never, direction: 'east', + position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west'); + if (dp?.kind === 'divisionPoint') dp.holding.push(id); + s.clock.phase = 'newTrain'; + s.clock.currentActor = 0; + assert.equal(actionMenu(game).makeUp?.advice ?? null, null); + }); + + it('the order it recommends is one the rules actually allow a set-out from', () => { + // The advice is worthless if it names an arrangement that is merely a different dead end. This + // is the claim under it, checked against `check` rather than against the prose. + const s = newGame(4242).state; + const at = { row: 1, col: 1 }; + areaOf(s, 0).grid.set(`${at.row},${at.col}`, { + geometry: { kind: 'track', geometry: 'straight' }, + baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], + } as never); + const put = (consist: { type: string; loaded: boolean }[]): string => { + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber: 8, trainIsExtra: false, engineAt: 0, + consist: consist.map((c) => ({ ...c })) as never, direction: 'east', facing: 'e', + position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0, + }); + return id; + }; + s.clock.phase = 'localOps'; + s.clock.currentActor = 0; + turnOf(s, 0).option = 'switch'; + + const canDrop = (id: string): boolean => + [1, 2].some((count) => + [false, true].some( + (fromNose) => + check(s, 0, { type: 'switch.dropCars', trayId: id, count, ...(fromNose ? { fromNose } : {}) }) === null, + ), + ); + + assert.equal(canDrop(put([coach, box])), true, 'the recommended order still cannot set out'); + assert.equal(canDrop(put([box, coach])), false, 'the order being warned against is not actually a trap'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('two crews switching are told apart', () => { + /** + * REPORTED from play: "when switching, make it clear which train you are switching — it is + * possible to have more than one train available." + * + * A train standing on an A/D track while a local shunts is ordinary, and the page handled it in + * two ways that were both wrong. `Frame.moves` was built from the FIRST tray in the map, with a + * comment admitting it, so the board highlighted one crew's reachable squares while the action + * list offered every crew's moves under a single "Switching" heading of bare coordinates. + * + * Worse, identical labels were collapsed across the whole action kind: "move to (0, 2)" describes + * one crew's move exactly as it describes another's, so one of the two was silently DROPPED and + * could not be chosen at all. + */ + const twoCrews = (): Game => { + const game = newGame(4242); + const s = game.state; + const area = areaOf(s, 0); + // A row of plain track either side of the Office, so both crews have somewhere to go. + for (const col of [-2, -1, 1, 2]) { + area.grid.set(`${area.officeCoord.row},${area.officeCoord.col + col}`, { + geometry: { kind: 'track', geometry: 'straight' }, + baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], + } as never); + } + // Facing each other across the Office, so BOTH reach it running forward and both moves + // therefore describe identically — which is the collision this fixture exists to create. + const put = (trainNumber: number, col: number, facing: 'e' | 'w'): string => { + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber, trainIsExtra: false, engineAt: 0, consist: [], + direction: facing === 'e' ? 'east' : 'west', facing, + position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col } }, + movesUsed: 0, + }); + return id; + }; + put(10, area.officeCoord.col - 1, 'e'); // Heavy Freight, running east at the Office + put(12, area.officeCoord.col + 1, 'w'); // Drag Freight, running west at it + s.clock.phase = 'localOps'; + s.clock.currentActor = 0; + turnOf(s, 0).option = 'switch'; + return game; + }; + + it('gives each crew its own heading, naming the train and where it stands', () => { + const titles = actionMenu(twoCrews()).direct.map((g) => g.title).filter((t) => /^Switching/.test(t)); + assert.equal(titles.length, 2, `expected one heading per crew, got ${JSON.stringify(titles)}`); + assert.ok(titles.some((t) => /Train 10/.test(t)), `no heading names Train 10: ${JSON.stringify(titles)}`); + assert.ok(titles.some((t) => /Train 12/.test(t)), `no heading names Train 12: ${JSON.stringify(titles)}`); + assert.ok(titles.every((t) => /standing at \(/.test(t)), 'a heading does not say where its crew is'); + }); + + it('keeps a move that both crews can make, instead of dropping one of them', () => { + // The two crews flank the Office, so each can reach it and both moves describe identically. + // Before, the second was collapsed into the first and one train simply could not be sent there. + const game = twoCrews(); + const office = areaOf(game.state, 0).officeCoord; + const label = `move to (${office.row},${office.col})`; + + const groups = actionMenu(game).direct.filter((g) => /^Switching/.test(g.title)); + const offering = groups.filter((g) => g.actions.some((a) => a.label === label)); + assert.equal(offering.length, 2, 'the same move is not offered for both crews'); + + // And they are genuinely different actions, not the same index shown twice. + const indices = offering.map((g) => g.actions.find((a) => a.label === label)!.index); + assert.notEqual(indices[0], indices[1], 'both crews were pointed at one intent'); + + // Each really does move its own crew. + const { options } = actionGroups(game); + const trays = indices.map((i) => (options[i] as { trayId: string }).trayId); + assert.notEqual(trays[0], trays[1], 'the two buttons move the same tray'); + }); + + it('reports every crew on the Frame, so the board can highlight the one you picked', () => { + const f = view(twoCrews()); + assert.equal(f.moves.length, 2); + assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 10', 'Train 12']); + for (const m of f.moves) assert.ok(m.to.length > 0, `${m.label} has no reachable squares`); + // Each entry is that crew's own square, not a shared one. + assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from); + }); + + it('leaves out a train the rules will not let switch at all', () => { + // `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it. + // Offering the Circus Train in a "which train are you switching?" row offers a train whose + // every move is refused. + const game = twoCrews(); + const s = game.state; + const circus = [...s.trays.values()][0]!; + circus.trainNumber = 18; + circus.trainIsExtra = true; // X18 Circus Train — noSwitching + const f = view(game); + assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch'); + assert.equal(f.moves[0]!.label, 'Train 12'); + }); +});