Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c4d9fa92 | ||
|
|
3a5a909ec0 | ||
|
|
859817d173 |
+193
@@ -19,6 +19,199 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9d — 2026-08-21
|
||||
|
||||
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
|
||||
0.5.0 line: a display bug that made a switching train look like it had vanished, an engine bug that
|
||||
silently ignored which car the player chose to unload, and a UI bug that could render a legal
|
||||
decision with zero buttons — indistinguishable from a hang.
|
||||
|
||||
### A switching train vanished from the board the moment it left the Office
|
||||
|
||||
**REPORTED:** Train 3 stood at the Office; dropping the loaded boxcar off the back at the Freight
|
||||
House made the train "vanish off the face of the earth," regardless of which way it moved
|
||||
afterward. The game state was never wrong — replaying the reported save
|
||||
(`docs/station-master-seed116956197-day2.json`) through the engine directly showed `tray3` fully
|
||||
intact, in the right place, with the right consist, for the rest of the game. Nothing was ever lost
|
||||
from `state.trays`.
|
||||
|
||||
The bug was in `officeSvg` (`board-svg.ts`): `selectedTrain` — the value that decides whether a
|
||||
card draws a train's crew badge (its label, engine arrow, and cars) — was assigned only inside the
|
||||
`cell.adTracks !== null` branch, which is true for the Office card and nothing else. A train
|
||||
standing on any other card, which is every card it stands on while actually being switched, got no
|
||||
badge at all. It disappeared on the very first move off the Office, not only at the Freight House —
|
||||
that was simply the first place a player was likely to pause and look. Traced to the Roster Pass
|
||||
(37b1e5b), which rewrote `CellView.train` into `CellView.trains[]` to show every train sharing the
|
||||
Office's A/D tracks and, in doing so, nested the general consist-drawing logic inside that
|
||||
Office-only branch instead of leaving it to run for any card with a train on it.
|
||||
|
||||
**Fixed** by hoisting the `selectedTrain` assignment out of the `adTracks !== null` guard so it
|
||||
runs for every card; the A/D roster-chip loop itself stays inside the guard, since only the Office
|
||||
has more than one A/D track to draw chips for. New test: a train parked on an ordinary facility
|
||||
card, `adTracks: null`, now has to draw its crew badge — it did not before this fix.
|
||||
|
||||
### Unloading always took the westmost car, whichever one was picked
|
||||
|
||||
**REPORTED:** three loaded cars stood at a Freight House; asking to unload the east (right-hand)
|
||||
car unloaded the west one instead, every time, no matter which car was actually chosen.
|
||||
|
||||
`laborer.beginUnload` already carried the player's choice as `carIndex`, and `check()` validated
|
||||
that specific car — but the event it produced, `unloadBegan`, carried only the car's `carType`, not
|
||||
which one it was. The reducer that actually performs the swap re-derived the target itself with
|
||||
`industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in track
|
||||
order regardless of what was requested — so the player's choice was thrown away between `check` and
|
||||
the reducer, and the westmost loaded car came off every time.
|
||||
|
||||
**Fixed** by adding `carIndex` to the `unloadBegan` event and using it directly in the reducer
|
||||
(`src/engine/apply.ts`, `case 'unloadBegan'`), instead of re-deriving a car by scanning for
|
||||
`loaded`. New test in `apply.test.ts`: three loaded cars of three different types, asking to unload
|
||||
index 2 (east) now leaves indices 0 and 1 alone and empties exactly the one requested — it did not
|
||||
before this fix.
|
||||
|
||||
### A legal decision could render with zero buttons — the game looked hung
|
||||
|
||||
**REPORTED:** working the Freight House mid-cycle (unloading three cars, then reloading them), the
|
||||
game "seemed to have hung up. Cannot advance." No trains were on the Division or the Limits.
|
||||
|
||||
Replaying the reported save turned up no engine deadlock at any point — every step had a legal
|
||||
action — but the exact final state (an Extra train due to start, no tray yet being made up) exposed
|
||||
a UI bug: `newTrain.startExtra`'s action group is titled by `trainCardTitle`, which begins "Making
|
||||
up Extra X22…" — the same prefix `renderActions()` (`main.ts`) used to strip from the action list on
|
||||
the assumption it always belonged to the separate yard-chip car-placement panel. With no tray being
|
||||
filled yet, that panel (`menu.makeUp`) is `null`, so nothing else rendered the Extra's "choose where
|
||||
it starts" decision either — a legal, present, correctly-computed option with no button anywhere on
|
||||
the page. Clicking nothing did nothing, which is indistinguishable from a hang.
|
||||
|
||||
**Fixed** by matching the exact title of the one group `menu.makeUp` actually covers
|
||||
(`g.title !== menu.makeUp?.title`) instead of a title-prefix regex, so any other group that happens
|
||||
to start "Making up …" — however it got that title — stays on screen. The existing softlock
|
||||
regression test (`never strands the player with a legal move and no way to make it`) now mirrors the
|
||||
real render filter instead of only checking the raw menu, so this class of bug fails that test
|
||||
directly; a new deterministic test constructs the exact reported scenario (a pending Extra, no
|
||||
active make-up) and asserts its group survives the filter.
|
||||
|
||||
590 tests, 0 failures.
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9a — 2026-08-20
|
||||
|
||||
Four small tweaks to the splash page (`index.html`, `splash.ts`), no engine changes.
|
||||
|
||||
### Box art opens full size on click
|
||||
|
||||
The thumbnail beside the title was too small to read. Clicking (or Enter/Space on keyboard focus)
|
||||
now opens it in a full-size lightbox overlay; click the overlay or press Escape to close.
|
||||
|
||||
### A third card balances the bottom row
|
||||
|
||||
The doors row was nested beside the box art, two cards wide, leaving the width under the box art
|
||||
unused. Moved to a full-width row below the hero (three columns at roughly the same card width as
|
||||
before) and added **Play multiplayer**, modeled on the Play Solitaire card but describing the
|
||||
not-yet-built feature, with "Coming soon" in place of a link. Its text is shorter than Play
|
||||
Solitaire's so the row height — and the page's vertical size — doesn't change.
|
||||
|
||||
### Footer's "runs in your browser" claim scoped to solitaire
|
||||
|
||||
Now reads "solitaire runs entirely in your browser" rather than an unqualified claim that would no
|
||||
longer hold once multiplayer needs a server.
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9 — 2026-08-19
|
||||
|
||||
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first
|
||||
real audio beyond a placeholder. Six changes: one display fix, two rules corrections, one engine bug
|
||||
found while explaining a report rather than reported directly, three new sound cues, and the splash
|
||||
page's box art.
|
||||
|
||||
### Office coordinates read X,Y now, not Y,X
|
||||
|
||||
Reported as confusing for anyone reading them the ordinary way: "at game start the office is 0,0;
|
||||
the western limit should be -1,0, the eastern +1,0; a restaurant due north should be 0,1." The
|
||||
engine's internal `GridCoord{row,col}` already has exactly that geometry — row increases north, col
|
||||
increases east (`track.ts:59`) — so this was a **display-only fix**, not a data-model change: every
|
||||
place a coordinate is shown to a player now prints `(col,row)` instead of `(row,col)`. The on-card
|
||||
label in `board-svg.ts` (the one everyone sees), the switching-crew tooltip and button label in
|
||||
`main.ts`, the rejected-option and switching-group text in `game.ts` and `view.ts`, and the
|
||||
blocked-move text in `narrate.ts`. Internal `Map` keys built from `${row},${col}` are untouched —
|
||||
nothing reads them but lookup code.
|
||||
|
||||
### "No switching" now means no adding or dropping cars — not "never touch it"
|
||||
|
||||
Reported from play: a train sitting at the Office with NO SWITCHING printed on its card (the two
|
||||
expresses, Light Engine, Campaign, Circus, Military) could not be moved onto Secondary Track to
|
||||
clear the mainline for other traffic, even though nothing on the card says that. `switchingRefusal()`
|
||||
(`apply.ts`) blocked `switch.move` outright for these six cards; it now only refuses a move that
|
||||
would COUPLE a fresh car — the same way `rules.dropOnly` was already handled for X13 — so the crew
|
||||
can still be shunted clear, it just cannot pick up, set out or sort. `switch.dropCars` and
|
||||
`switch.sortConsist` stay blocked. The switching-moves list (`view.ts`) no longer excludes these
|
||||
trains, and the card description text was rewritten from "it runs the Division and does not shunt"
|
||||
to "may not add or drop cars, but may still be moved clear of the mainline."
|
||||
|
||||
### Q3, corrected: Expedite is about WHERE a train may be left, not WHEN it leaves
|
||||
|
||||
**The reported symptom:** Train 1 arrived at an Office and was forced out at the end of the same
|
||||
Stage — the read since v0.4.2 — leaving no Local Operations turn to switch it. **The rule question
|
||||
underneath it:** why should "Expedite" mean a train is rushed out early at all, rather than simply
|
||||
never being left parked off the station?
|
||||
|
||||
Corrected to the latter. An expedited train now arrives and stands exactly like any other train,
|
||||
released by the ordinary §8.1 "stood a full Stage at the Office" rule — it can be switched normally
|
||||
in between. Deleted entirely: `arriveAtOffice`'s `departsThisStage` flag and the whole
|
||||
expedited-departures pass that used to run in `shiftChange`, plus the now-dead `pass: 'mainline' |
|
||||
'expedite'` parameter on `moveTrain`.
|
||||
|
||||
In its place: a new fault. At the top of every Mainline Phase, any expedited train sitting on the
|
||||
grid but not on the Office square itself — left on Secondary Track to clear a switching move, say —
|
||||
costs 1 Revenue (`EXPEDITE_FAULT_PENALTY`, `expediteFault`), charged again every Phase it is still
|
||||
caught there. A train the ordinary rules are legitimately holding AT the station — even one §8.1 is
|
||||
asking the Superintendent to rule on — is not a fault; only being left elsewhere in the district is.
|
||||
|
||||
**This resolves "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE"** (`TODO.md`) as a side effect: the
|
||||
Express can now actually reach a Local Operations turn to use its printed "may drop or pick up one
|
||||
freight car at every location," which the old same-stage departure made structurally impossible.
|
||||
|
||||
**Measured (30 fresh bot games, standard length):** the fault fires, but the bot has no strategy for
|
||||
it yet — one game left Train 4 (3/4 Express) parked off the station from Day 3 Stage 10 to the end of
|
||||
the game, taking the -1 penalty 26 times. Not an engine bug: the mechanism is working as designed and
|
||||
correctly reveals that the bot doesn't know to bring an expedited train home before ending a
|
||||
switching turn. Logged in `TODO.md` under Bot Performance for a future pass.
|
||||
|
||||
### `evaluateClearance` now checks every occupant, not just the first one it finds
|
||||
|
||||
Found while explaining a second playtest report from the same game: the Superintendent was asked to
|
||||
rule on a same-direction train instead of being automatically held against an opposite-direction one
|
||||
also occupying the card. Root cause: the occupant loop (`advance.ts`) returned on whichever occupant
|
||||
it examined first, in `node.transits` insertion order — correct while a Subdivision held at most one
|
||||
train, but a card can now legitimately hold two at once, which is exactly what the
|
||||
Telegraph/Telephone/Radio dispatch exception creates (a facing train dispatched past another sits on
|
||||
the same card as it). Fixed to two passes: every occupant is checked for an opposite-direction
|
||||
absolute bar first (subject to the dispatch exception) before any same-direction judgment call is
|
||||
offered. Pinned with a regression test that fails against the old single-pass code
|
||||
(`enhancements.test.ts`).
|
||||
|
||||
### Three more sounds, synthesised like the rest
|
||||
|
||||
`arrive`, `depart` and `crash` join `stage`/`couple`/`drop`/`schedule`/`completed`/`day`/`train` in
|
||||
`sound.ts` — a train pulling into an Office (decelerating chuffs, a soft bell tap), one highballing
|
||||
out of one (accelerating chuffs, a falling horn note), and a collision (dissonant tones bent sharply
|
||||
down over a noise boom and a sub-bass thump), wired to `trainArrived`, `trainHighballed` (Office
|
||||
departures only, so it doesn't double up with a fresh make-up's "All aboard" or a full division run's
|
||||
`completed`), and `trainsDestroyed`. Three real WAV clips were sourced for this and considered, but
|
||||
carried no license information anywhere — no `LICENSE` file, no embedded metadata, no note of where
|
||||
they came from — so they were not committed; the synthesised versions are good enough to keep as the
|
||||
real thing rather than a placeholder.
|
||||
|
||||
### The splash page has its box art
|
||||
|
||||
`docs/StationMasterSplashScreen.png` (1122×1402, 3.0 MB) is now a 560×700 JPEG at 145 KB
|
||||
(`public/images/`, copied verbatim into `dist/images/` by a small addition to `build-web.ts`) and
|
||||
sits beside the title, tagline, blurb and both buttons in a side-by-side hero on `index.html`,
|
||||
stacking to image-above-text under the existing mobile breakpoint.
|
||||
|
||||
---
|
||||
|
||||
## 0.4.8 — 2026-08-19
|
||||
|
||||
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.4.8",
|
||||
"version": "0.4.9d",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
+12
-1
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -151,6 +151,17 @@ if (existsSync(replaySrc)) {
|
||||
}
|
||||
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||
|
||||
/**
|
||||
* Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays
|
||||
* above: an image needs no validation, only a place in `dist/` to be served from.
|
||||
*/
|
||||
const imageSrc = join(root, 'public/images');
|
||||
if (existsSync(imageSrc)) {
|
||||
const imageOut = join(dist, 'images');
|
||||
mkdirSync(imageOut, { recursive: true });
|
||||
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
||||
}
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
writeFileSync(
|
||||
join(dist, 'README.txt'),
|
||||
|
||||
+76
-89
@@ -18,6 +18,7 @@
|
||||
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
EXPEDITE_FAULT_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
crossingStages,
|
||||
@@ -307,6 +308,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
||||
});
|
||||
|
||||
/**
|
||||
* Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins
|
||||
* was not kept ready to highball, wherever in the district it has been left — switched onto
|
||||
* Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase
|
||||
* moves anything, and charged every Phase it is still caught away: the fault is in leaving it
|
||||
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
|
||||
* unaffected — this only bites when the train is not even in the queue to leave.
|
||||
*/
|
||||
for (const [, tray] of order) {
|
||||
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
|
||||
const area = areaAtSeat(s, tray.position.seat);
|
||||
const { coord } = tray.position;
|
||||
if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue;
|
||||
const owner = playerAtSeat(s, tray.position.seat);
|
||||
const p = s.players[owner];
|
||||
if (!p) continue;
|
||||
p.revenue -= EXPEDITE_FAULT_PENALTY;
|
||||
events.push({
|
||||
type: 'expediteFault',
|
||||
player: owner,
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
where: `(${coord.col},${coord.row})`,
|
||||
});
|
||||
events.push({
|
||||
type: 'revenueChanged',
|
||||
player: owner,
|
||||
delta: -EXPEDITE_FAULT_PENALTY,
|
||||
total: p.revenue,
|
||||
reason: 'expedited train left off the station',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, tray] of order) {
|
||||
if (s.movedThisPhase.has(id)) continue;
|
||||
const where = tray.position;
|
||||
@@ -329,13 +362,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
(tray.position.at !== 'grid' ||
|
||||
where.at !== 'grid' ||
|
||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||
if (!tray.departsThisStage && stillThere) {
|
||||
if (stillThere) {
|
||||
tray.stopPointClaimed = true;
|
||||
// The point goes to whoever is SITTING in the district it stopped in.
|
||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.row},${tray.position.coord.col})`
|
||||
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
||||
: tray.position.at === 'mainline'
|
||||
? `Mainline card ${tray.position.index}`
|
||||
: `the ${tray.position.side} Division Point`;
|
||||
@@ -475,14 +508,18 @@ function spendDispatchBonus(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
/**
|
||||
* Does this train leave in the same Stage it arrives (§7)?
|
||||
* Q3 — is this train subject to the "kept ready" fault (§7)?
|
||||
*
|
||||
* Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any
|
||||
* other train, and may be switched normally while it stands. What it means is that it must not be
|
||||
* held anywhere but the station: a train the player parks on Secondary Track to clear a switching
|
||||
* move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`).
|
||||
*
|
||||
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
|
||||
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
|
||||
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
||||
* stop, so the train is slow once and fast thereafter.
|
||||
* stop, so the train is exempt once and subject to the fault thereafter.
|
||||
*/
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||
@@ -491,18 +528,7 @@ function isExpedited(tray: CrewTray): boolean {
|
||||
return rules.stopThenExpedite === true && tray.speechMade === true;
|
||||
}
|
||||
|
||||
function moveTrain(
|
||||
s: GameState,
|
||||
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 {
|
||||
function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome {
|
||||
const dir = step(tray.direction);
|
||||
|
||||
if (tray.position.at === 'divisionPoint') {
|
||||
@@ -588,10 +614,7 @@ 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',
|
||||
why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
@@ -751,28 +774,44 @@ function evaluateClearance(
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
/**
|
||||
* TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked
|
||||
* against EVERY occupant before any same-direction judgment call is offered.
|
||||
*
|
||||
* A single pass returned on whichever occupant it examined first, in `node.transits` insertion
|
||||
* order — which is fine while a Subdivision holds at most one train, but the dispatch exception
|
||||
* below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same
|
||||
* card as whatever it was cleared past. Found from a playtest report where the Superintendent was
|
||||
* asked to rule on a same-direction train instead of being auto-held against an uncleared
|
||||
* opposite-direction one also on the card — the loop had reached the same-direction occupant
|
||||
* first simply because it entered the transit list first, and returned before ever looking at
|
||||
* the other.
|
||||
*/
|
||||
for (const { tray: other } of occupants) {
|
||||
if (other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray || otherTray.direction === tray.direction) continue;
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
//
|
||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
for (const { tray: other, onCard } of occupants) {
|
||||
if (other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
if (!otherTray || otherTray.direction !== tray.direction) continue;
|
||||
const onNode = s.division.nodes[onCard];
|
||||
|
||||
if (otherTray.direction !== tray.direction) {
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
//
|
||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
//
|
||||
// Both of these now read the card the OTHER train is standing on rather than the card being
|
||||
// Both of these read the card the OTHER train is standing on rather than the card being
|
||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
||||
// they are not, and the protection belongs where the train it protects actually is.
|
||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
@@ -871,27 +910,9 @@ function arriveAtOffice(
|
||||
});
|
||||
|
||||
/**
|
||||
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE.
|
||||
*
|
||||
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made
|
||||
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
|
||||
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
|
||||
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
|
||||
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
|
||||
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
|
||||
*
|
||||
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
|
||||
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
|
||||
* §8.1 condition when it goes, so it can be held.
|
||||
*/
|
||||
if (isExpedited(tray)) {
|
||||
tray.departsThisStage = true;
|
||||
return 'moved';
|
||||
}
|
||||
/**
|
||||
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
|
||||
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
|
||||
* runs expedited.
|
||||
* X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after
|
||||
* that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every
|
||||
* later arrival, so it needs no guard against re-firing.
|
||||
*/
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
|
||||
tray.speechMade = true;
|
||||
@@ -1018,40 +1039,6 @@ function retireTrain(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
/**
|
||||
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
|
||||
*
|
||||
* Run here rather than as a phase of its own because there is no decision in it: the trains that
|
||||
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
|
||||
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
|
||||
* Porters their one Stage with the train.
|
||||
*/
|
||||
const expedited = [...s.trays.entries()]
|
||||
.filter(([, t]) => t.departsThisStage)
|
||||
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
|
||||
for (const [id, tray] of expedited) {
|
||||
tray.departsThisStage = false;
|
||||
/**
|
||||
* 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.
|
||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||
|
||||
+32
-7
@@ -486,11 +486,14 @@ function freightBudgetLeft(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a train may do switching work at all (§7).
|
||||
* Whether a train may set out or sort cars (§7).
|
||||
*
|
||||
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
||||
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
||||
* sorting alike, because all three are switching.
|
||||
* Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
|
||||
* train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
|
||||
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
|
||||
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
|
||||
* move would make, not on the move itself.
|
||||
*/
|
||||
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||
@@ -626,8 +629,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
@@ -652,6 +653,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
*/
|
||||
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
|
||||
if (fresh.length > 0) {
|
||||
/**
|
||||
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
|
||||
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
|
||||
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
|
||||
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
|
||||
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
|
||||
*/
|
||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
@@ -1654,7 +1663,13 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'laborer.beginUnload': {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
return [
|
||||
{ type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type },
|
||||
{
|
||||
type: 'unloadBegan',
|
||||
player,
|
||||
at: i.at,
|
||||
carType: f.industryTrack.cars[i.carIndex]!.type,
|
||||
carIndex: i.carIndex,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2197,7 +2212,17 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'unloadBegan': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
|
||||
/**
|
||||
* THE CAR THE PLAYER PICKED, not merely "a loaded one".
|
||||
*
|
||||
* REPORTED: with several loaded cars spotted on one industry track, unloading always took the
|
||||
* WESTMOST car regardless of which one was chosen. This used to re-derive the target with
|
||||
* `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first
|
||||
* loaded car in track order — no matter which `carIndex` the intent actually named; `check`
|
||||
* had already validated that specific car, and the choice was thrown away between there and
|
||||
* here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent.
|
||||
*/
|
||||
const ci = e.carIndex;
|
||||
if (ci >= 0) {
|
||||
/**
|
||||
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
|
||||
|
||||
@@ -1004,6 +1004,12 @@ export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
export const COLLISION_PENALTY = 5;
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
/**
|
||||
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
|
||||
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
|
||||
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
|
||||
*/
|
||||
export const EXPEDITE_FAULT_PENALTY = 1;
|
||||
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
|
||||
+15
-4
@@ -124,6 +124,13 @@ 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 }
|
||||
/**
|
||||
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
|
||||
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
|
||||
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
|
||||
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
|
||||
*/
|
||||
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
|
||||
/**
|
||||
* `why` is the RULE that let it go now, not a restatement of the move.
|
||||
*
|
||||
@@ -134,9 +141,9 @@ export type GameEvent =
|
||||
*/
|
||||
| { 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;
|
||||
* every other train stands until the next Mainline Phase and can be switched in between.
|
||||
* `expedited` flags a train that must not be left standing off the station (Q3) — it may be
|
||||
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||
* next Mainline Phase begins, or `expediteFault` fires.
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
@@ -160,7 +167,11 @@ export type GameEvent =
|
||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
/**
|
||||
* `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment
|
||||
* on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it.
|
||||
*/
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||
// -- consequences
|
||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||
|
||||
+4
-9
@@ -384,17 +384,12 @@ export type CrewTray = {
|
||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||
*
|
||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
|
||||
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
|
||||
* once, so there is no later visit to hang it on.
|
||||
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
||||
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
|
||||
* visit to hang it on.
|
||||
*/
|
||||
speechMade?: boolean;
|
||||
/**
|
||||
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
|
||||
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
|
||||
* Load/Unload, which is what gives the Porters their one Stage with the train.
|
||||
*/
|
||||
departsThisStage?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+13
-4
@@ -552,7 +552,8 @@ export function officeSvg(
|
||||
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.col},${cell.row}</text>`;
|
||||
|
||||
/**
|
||||
* THE ROSTER, one chip per A/D track — not one per train.
|
||||
@@ -567,13 +568,21 @@ export function officeSvg(
|
||||
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
||||
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
||||
* `data-crew`, so the play page can wire a click to switch which train that is.
|
||||
*
|
||||
* COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null`
|
||||
* branch below, so it was only ever assigned on the Office card — a train standing anywhere else
|
||||
* in the district (which is everywhere it stands while actually being switched) got no chip at
|
||||
* all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED:
|
||||
* dropping a car at the Freight House made "the train vanish off the face of the earth" — it had
|
||||
* simply left the one square this code drew a crew on. The Office is the only square that can hold
|
||||
* more than one train, so `cell.trains[0]` is exactly right everywhere else too.
|
||||
*/
|
||||
let selectedTrain: CellView['trains'][number] | null = null;
|
||||
const selectedTrain: CellView['trains'][number] | null =
|
||||
cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||||
if (cell.adTracks !== null) {
|
||||
const adTracks = cell.adTracks;
|
||||
const gaps = Math.max(0, adTracks - 1);
|
||||
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
||||
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||||
for (let i = 0; i < adTracks; i++) {
|
||||
const train = cell.trains[i] ?? null;
|
||||
const cx = 6 + i * (chipW + 4);
|
||||
@@ -872,7 +881,7 @@ export function officeSvg(
|
||||
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.col},${g.row}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
|
||||
|
||||
+24
-30
@@ -280,25 +280,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
case 'trainArrived':
|
||||
/**
|
||||
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER.
|
||||
* AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION.
|
||||
*
|
||||
* 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".
|
||||
* This used to say the train stands through Cargo and is forced out at the end of the Stage —
|
||||
* true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not
|
||||
* that it is rushed out early. It is switched, worked and released exactly like any other
|
||||
* arrival; the only difference is what happens if it is left on Secondary Track when the next
|
||||
* Mainline Phase begins (`expediteFault`).
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `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)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `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 'expediteFault':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`,
|
||||
};
|
||||
case 'trainDiverted':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -582,7 +582,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
// the board says it on the card instead.
|
||||
if (b.kind === 'noStopping') continue;
|
||||
out.push({
|
||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
|
||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`,
|
||||
why: b.why,
|
||||
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
||||
// the puzzle rather than a fault. Amber, not red.
|
||||
@@ -602,12 +602,10 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
* said so.
|
||||
*
|
||||
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload
|
||||
* them", which turned out to be three separate silences. This block only looked at trains carrying
|
||||
* them", which turned out to be two separate silences. This block only looked at trains carrying
|
||||
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
|
||||
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
|
||||
* Porters used" — to a player who had used none. And an expedited train leaves at the end of the
|
||||
* Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains
|
||||
* those are.
|
||||
* Porters used" — to a player who had used none.
|
||||
*
|
||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
|
||||
* mentioned at all.
|
||||
@@ -620,8 +618,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
|
||||
|
||||
if (coachTrains.length > 0) {
|
||||
const label = (t: (typeof coachTrains)[number]): string =>
|
||||
t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
|
||||
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
|
||||
out.push({ where: 'Office — passengers', why, severity });
|
||||
};
|
||||
@@ -658,17 +654,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
}
|
||||
}
|
||||
|
||||
// NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only
|
||||
// Load/Unload phase it will ever stand for — the exact thing the playtest report was about.
|
||||
for (const t of coachTrains) {
|
||||
if (!t.departsThisStage) continue;
|
||||
say(
|
||||
`${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` +
|
||||
'Load/Unload phase it will stand for. Work it now or not at all.',
|
||||
'risk',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +733,15 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
||||
// A train running the whole length of the Division pays every player, and it is the one event
|
||||
// nobody made happen on the turn it lands. It should be heard, not found in the log.
|
||||
if (e.type === 'trainCompleted') out.push('completed');
|
||||
// A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not
|
||||
// included — that one goes straight to a work track without the A/D stop `arrive` depicts.
|
||||
if (e.type === 'trainArrived') out.push('arrive');
|
||||
// Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already
|
||||
// sounds `train` ("All aboard"), and a train clearing the whole Division already sounds
|
||||
// `completed` — this is the one case neither of those covers.
|
||||
if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart');
|
||||
// §10 — the one event nobody wants to hear and everybody needs to.
|
||||
if (e.type === 'trainsDestroyed') out.push('crash');
|
||||
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,
|
||||
|
||||
+14
-19
@@ -678,7 +678,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
|
||||
/** One readable line for a single intent. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`;
|
||||
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
||||
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
|
||||
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
|
||||
@@ -1229,7 +1230,7 @@ export function snapshot(
|
||||
? `${t.position.side} Division Point`
|
||||
: t.position.at === 'mainline'
|
||||
? `Mainline card ${t.position.index}`
|
||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
||||
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1424,7 +1425,9 @@ export function trainRules(t: {
|
||||
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
||||
* Military train that cannot be worked by Porters plays nothing like a Local.
|
||||
*/
|
||||
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
|
||||
if (p.rules.noSwitching) {
|
||||
parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline');
|
||||
}
|
||||
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
||||
if (p.rules.coachStaysOnStationTrack) {
|
||||
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
|
||||
@@ -1446,15 +1449,12 @@ export function trainRules(t: {
|
||||
}
|
||||
|
||||
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.
|
||||
// It is released and switched exactly like any other train — the restriction is on where it may
|
||||
// be LEFT, not on when it leaves.
|
||||
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.',
|
||||
'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' +
|
||||
'back on the Office square when the next Mainline Phase begins, that is a Station Master ' +
|
||||
'fault and costs 1 Revenue.',
|
||||
);
|
||||
}
|
||||
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
|
||||
@@ -1736,14 +1736,9 @@ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
||||
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;
|
||||
// A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting
|
||||
// out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13
|
||||
// (dropOnly) is not allowed to make, so this row is not filtered any differently for either.
|
||||
const { to, blocked } = movesFor(s, player, id);
|
||||
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
|
||||
}
|
||||
|
||||
+3
-2
@@ -344,7 +344,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
const tray = game.state.trays.get(trayId);
|
||||
const where =
|
||||
tray?.position.at === 'grid'
|
||||
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})`
|
||||
? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})`
|
||||
: '';
|
||||
groups.push({
|
||||
kind: prefix,
|
||||
@@ -670,7 +670,8 @@ function subjectOf(
|
||||
node?: number;
|
||||
links: string[];
|
||||
} | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`;
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||
|
||||
+53
-21
@@ -11,18 +11,28 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
||||
main{width:100%;max-width:760px}
|
||||
.hero{display:flex;gap:24px;align-items:flex-start}
|
||||
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line);cursor:zoom-in}
|
||||
.hero-image img{display:block;width:100%;height:auto}
|
||||
.hero-content{flex:1;min-width:0}
|
||||
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
|
||||
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||
.tag{color:var(--dim);margin:0;font-size:14px}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 0;max-width:66ch}
|
||||
.rule{height:1px;background:var(--line);margin:22px 0}
|
||||
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.doors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:26px}
|
||||
@media(max-width:640px){.doors{grid-template-columns:1fr}}
|
||||
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||
.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
|
||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
.door.disabled .go{color:var(--dim)}
|
||||
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
|
||||
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
|
||||
.lightbox[hidden]{display:none}
|
||||
.lightbox img{display:block;max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||
/* the rule between title and blurb is drawn as rail, not decorated */
|
||||
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
|
||||
.rail{stroke:#4a5361;stroke-width:1.6}
|
||||
@@ -32,24 +42,41 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
<div class="hero">
|
||||
<div class="hero-image" id="hero-image" tabindex="0" role="button" aria-label="View full-size box art">
|
||||
<img src="./images/station-master-splash.jpg"
|
||||
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office."
|
||||
width="560" height="700">
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
<p class="blurb">
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doors">
|
||||
<div class="door disabled">
|
||||
<h2>Play multiplayer</h2>
|
||||
<p>Play with your friends across the internet. You each run your own office area within the
|
||||
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
|
||||
a Station Master server to be running.</p>
|
||||
<span class="go">Coming soon</span>
|
||||
</div>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
@@ -71,10 +98,15 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>runs entirely in your browser — no server code required</span>
|
||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div class="lightbox" id="lightbox" hidden>
|
||||
<img src="./images/station-master-splash.jpg"
|
||||
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office.">
|
||||
</div>
|
||||
|
||||
<script type="module" src="./web/splash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-3
@@ -819,8 +819,8 @@ function renderActions(
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>`;
|
||||
@@ -832,9 +832,19 @@ function renderActions(
|
||||
* Everything about a card in hand now lives on the card, and everything about making up a train
|
||||
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
|
||||
* drawing, switching moves, the Freight Agent, and finishing.
|
||||
*
|
||||
* EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the
|
||||
* assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose
|
||||
* where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra
|
||||
* X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is
|
||||
* null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight
|
||||
* House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons,
|
||||
* and nothing short of restarting looked like it would ever move again. Matching the exact
|
||||
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
||||
* however it is titled, on screen where a player can act on it.
|
||||
*/
|
||||
html += menu.direct
|
||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
|
||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
|
||||
+81
-17
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
|
||||
osc.stop(at + dur + 0.02);
|
||||
};
|
||||
|
||||
/**
|
||||
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
|
||||
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
|
||||
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
|
||||
*/
|
||||
const noiseBurst = (
|
||||
at: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
freq: number,
|
||||
q: number,
|
||||
type: BiquadFilterType = 'bandpass',
|
||||
): void => {
|
||||
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||
const buf = c.createBuffer(1, frames, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const filt = c.createBiquadFilter();
|
||||
filt.type = type;
|
||||
filt.frequency.value = freq;
|
||||
filt.Q.value = q;
|
||||
const amp = c.createGain();
|
||||
amp.gain.setValueAtTime(0, at);
|
||||
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
src.connect(filt).connect(amp).connect(c.destination);
|
||||
src.start(at);
|
||||
src.stop(at + dur + 0.02);
|
||||
};
|
||||
|
||||
const t = c.currentTime + 0.02;
|
||||
|
||||
if (cue === 'stage') {
|
||||
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
|
||||
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
|
||||
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
|
||||
}
|
||||
const frames = Math.floor(c.sampleRate * d);
|
||||
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const band = c.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = 1400;
|
||||
band.Q.value = 0.8;
|
||||
const amp = c.createGain();
|
||||
amp.gain.setValueAtTime(0, t);
|
||||
amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
||||
src.connect(band).connect(amp).connect(c.destination);
|
||||
src.start(t);
|
||||
src.stop(t + d);
|
||||
noiseBurst(t, d, 0.05, 1400, 0.8);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'arrive') {
|
||||
/**
|
||||
* A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
|
||||
* road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
|
||||
* `day` bell, not a repeat of it.
|
||||
*/
|
||||
let at = t;
|
||||
const gaps = [0.13, 0.17, 0.22, 0.28];
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
|
||||
at += gaps[i]!;
|
||||
}
|
||||
for (const fg of [[1046, 0.07], [1568, 0.04]]) {
|
||||
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'depart') {
|
||||
/**
|
||||
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
|
||||
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
|
||||
* since this train is only leaving the Office, not clearing the whole Division.
|
||||
*/
|
||||
let at = t;
|
||||
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
|
||||
at += gaps[i]!;
|
||||
}
|
||||
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'crash') {
|
||||
/**
|
||||
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
|
||||
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
|
||||
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
|
||||
* weight under the screech.
|
||||
*/
|
||||
tone(t, 311, 0.35, 0.16, 'square', 70);
|
||||
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
|
||||
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
|
||||
tone(t, 45, 0.3, 0.18, 'sine');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,22 @@ if (ties) {
|
||||
}
|
||||
ties.innerHTML = out;
|
||||
}
|
||||
|
||||
/** Clicking the box art opens it full size, since the thumbnail is too small to read. */
|
||||
const heroImage = document.getElementById('hero-image');
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
if (heroImage && lightbox) {
|
||||
const open = () => { lightbox.hidden = false; };
|
||||
const close = () => { lightbox.hidden = true; };
|
||||
heroImage.addEventListener('click', open);
|
||||
heroImage.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
lightbox.addEventListener('click', close);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
|
||||
});
|
||||
}
|
||||
|
||||
+60
-43
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, 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';
|
||||
@@ -690,9 +690,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
* 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.
|
||||
* arrival line for an Expedited train said there was "no turn in which to work it" — which was
|
||||
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
|
||||
* the Cargo turn they did have.
|
||||
*
|
||||
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
|
||||
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
|
||||
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
|
||||
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
|
||||
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
|
||||
* change cannot silently bring back the old forced departure.
|
||||
*/
|
||||
/**
|
||||
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
|
||||
@@ -704,11 +711,10 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
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.
|
||||
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
|
||||
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
|
||||
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
|
||||
* this test is trying to pin, so clearing the timetable isolates the rule from it.
|
||||
*/
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
const id = 'watched';
|
||||
@@ -779,40 +785,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
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.
|
||||
it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
|
||||
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
|
||||
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
|
||||
// Local Operations turn the same as Train 12 above.
|
||||
const r = phasesWith(6);
|
||||
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
|
||||
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
|
||||
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');
|
||||
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
|
||||
});
|
||||
|
||||
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 it happens often: driven by the developer bot this was reached by
|
||||
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
|
||||
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
|
||||
* meet depends on the deck, and the deck moves.
|
||||
*
|
||||
* So the log and the card must not promise that an Expedited train can never be switched.
|
||||
*/
|
||||
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
|
||||
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
|
||||
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
|
||||
// switching, say — it must not still be there when the next Mainline Phase begins.
|
||||
const s = game(7, { length: 'standard' });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
|
||||
});
|
||||
|
||||
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
|
||||
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
|
||||
'no fault was raised for an Expedited train left off the station',
|
||||
);
|
||||
assert.equal(
|
||||
s.players[0]!.revenue,
|
||||
before - EXPEDITE_FAULT_PENALTY,
|
||||
'the fault did not cost Revenue',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fault an Expedited train legitimately held at the station itself', () => {
|
||||
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
|
||||
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
|
||||
const s = game(7, { length: 'standard' });
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
departsThisStage: true,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
|
||||
@@ -829,26 +850,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||||
'the Expedited departure was not put to the Superintendent',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.position.at,
|
||||
'grid',
|
||||
'the Expedited train left despite needing a ruling nobody could give',
|
||||
'the departure was not put to the Superintendent',
|
||||
);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
|
||||
'the log does not say why the Expedited train stayed',
|
||||
!r.events.some((e) => e.type === 'expediteFault'),
|
||||
'a train properly parked at the station was faulted anyway',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.departsThisStage,
|
||||
false,
|
||||
'it should run as an ordinary train from now on, not retry the expedited departure',
|
||||
s.players[0]!.revenue,
|
||||
before,
|
||||
'Revenue changed for a train legitimately held at the station',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -685,6 +685,61 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
|
||||
});
|
||||
|
||||
it('unloads the car the player actually picked, not always the westmost one', () => {
|
||||
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
|
||||
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
|
||||
// The reducer for `unloadBegan` used to re-derive the target with
|
||||
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
|
||||
// track order no matter which `carIndex` the intent actually named.
|
||||
const s = game();
|
||||
const coord = at(-1, 0);
|
||||
addCard(s, coord, {
|
||||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: {
|
||||
cars: [
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'tank', loaded: true },
|
||||
],
|
||||
},
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
s.clock.phase = 'loadUnload';
|
||||
// An empty replacement of every type standing on the track, so `check` never refuses for a
|
||||
// reason unrelated to what this test is about.
|
||||
s.yards.divisionYard.push(
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: false },
|
||||
{ type: 'tank', loaded: false },
|
||||
);
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
|
||||
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
|
||||
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
|
||||
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
|
||||
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
|
||||
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
|
||||
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
|
||||
});
|
||||
|
||||
it('takes four Laborer actions in total for one point', () => {
|
||||
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
||||
const s = game();
|
||||
|
||||
+56
-19
@@ -468,9 +468,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
||||
* would break in silence. These pin it.
|
||||
*
|
||||
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs
|
||||
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at
|
||||
* all. It stands through Load/Unload first, which is what makes its coaches workable.
|
||||
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
|
||||
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
|
||||
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
|
||||
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
|
||||
*/
|
||||
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
||||
const id = 'arriving';
|
||||
@@ -503,12 +504,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
);
|
||||
});
|
||||
|
||||
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => {
|
||||
it('an Expedite train follows the same rule now — no exception any more', () => {
|
||||
/**
|
||||
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that
|
||||
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which
|
||||
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable —
|
||||
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them.
|
||||
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
|
||||
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
|
||||
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
|
||||
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
|
||||
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
|
||||
* exactly like Train 9 above.
|
||||
*/
|
||||
const s = game();
|
||||
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
||||
@@ -518,19 +521,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
|
||||
assert.equal(
|
||||
s.trays.get(id)!.position.at, 'grid',
|
||||
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it',
|
||||
'an Expedite train departed again in the same Stage it arrived from a mainline card',
|
||||
);
|
||||
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
||||
|
||||
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
|
||||
// is the departure pass that follows it — not that phase's own input handling.
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(
|
||||
s.trays.get(id)?.position.at, 'grid',
|
||||
'an Expedite train should be gone by the end of the Stage it arrived',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -666,6 +659,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
||||
});
|
||||
|
||||
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
|
||||
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
|
||||
// instead of being automatically held against an opposite-direction one also occupying the
|
||||
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
|
||||
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
|
||||
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
|
||||
// what creates: a facing train dispatched past another sits on the same card as it.
|
||||
const s = game();
|
||||
const ml = s.division.nodes[1];
|
||||
assert.equal(ml?.kind, 'mainline');
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
// SAME direction, entered the transit list FIRST — what the old code asked about.
|
||||
s.trays.set('sameDir', {
|
||||
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
|
||||
s.trays.set('facing', {
|
||||
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
|
||||
const r = advance(s);
|
||||
|
||||
assert.equal(
|
||||
s.trays.get('mine')!.position.at, 'divisionPoint',
|
||||
'the train entered despite an uncleared opposite-direction occupant on the card',
|
||||
);
|
||||
assert.ok(
|
||||
!r.events.some((e) => e.type === 'clearanceRequested'),
|
||||
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a junior train win the meet with a Telegraph', () => {
|
||||
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
||||
const s = game();
|
||||
|
||||
@@ -46,6 +46,7 @@ const KNOWN_UNREDUCED = [
|
||||
'carPassed',
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
'phaseBegan',
|
||||
'stageBegan',
|
||||
'trainArrived',
|
||||
|
||||
+8
-1
@@ -58,7 +58,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
||||
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper', carIndex: 0 },
|
||||
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
|
||||
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
|
||||
];
|
||||
@@ -446,6 +446,13 @@ describe('the replay behaves like the game it is replaying', () => {
|
||||
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');
|
||||
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
|
||||
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
|
||||
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
|
||||
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
|
||||
// stride and seed count. If this starts failing after either changes, widen the pool rather than
|
||||
// deleting the assertion — §10 is the one event a player most needs to hear.
|
||||
assert.ok(kinds.has('crash'), 'a collision never made a sound');
|
||||
|
||||
// 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
|
||||
|
||||
+2
-2
@@ -345,8 +345,8 @@ describe('end-of-game statistics', () => {
|
||||
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
|
||||
* facilities a game, down from 3.8 when industries could sit on the main.
|
||||
*
|
||||
* That is the bot's district-building weakness, which is the top item in TODO.md, not a rule
|
||||
* that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||
* That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a
|
||||
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||
* and so removing this line is what proves the bot has been fixed.
|
||||
*/
|
||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
|
||||
|
||||
@@ -76,13 +76,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§7 — no switching', () => {
|
||||
it('refuses every switching act to a train whose card forbids it', () => {
|
||||
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
|
||||
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
|
||||
// Moving, setting out and sorting are all switching, so all three are refused.
|
||||
// A train held at the Office may still need to clear onto Secondary Track ahead of other
|
||||
// traffic, so a move that couples nothing is legal.
|
||||
const s = game();
|
||||
switching(s, 1, false, [boxcar()]);
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
||||
});
|
||||
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING');
|
||||
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
|
||||
const s = game();
|
||||
switching(s, 1, false, [boxcar()], [boxcar()]);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
|
||||
'PICKUP_NOT_ALLOWED',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
|
||||
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
|
||||
});
|
||||
|
||||
+73
-8
@@ -841,7 +841,7 @@ describe('board highlighting', () => {
|
||||
}
|
||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
||||
assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1787,7 +1787,19 @@ describe('the static build', () => {
|
||||
// click-through happens to visit.
|
||||
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
|
||||
const out = new Set<number>();
|
||||
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
|
||||
/**
|
||||
* THE SAME EXCLUSION `renderActions()` (main.ts) APPLIES, not the raw menu.
|
||||
*
|
||||
* `menu.direct` alone is what the ENGINE offers; `renderActions()` then drops any group whose
|
||||
* title matches the one `menu.makeUp` already covers, so a group counted here but filtered
|
||||
* there is exactly the gap that stranded a pending Extra with `menu.makeUp` null and its own
|
||||
* "Making up Extra X22…" title caught by the OLD broader regex this once was. Mirroring the
|
||||
* real filter is what makes this test the one that would have caught that.
|
||||
*/
|
||||
const onScreen = menu.direct.filter(
|
||||
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||
);
|
||||
for (const g of onScreen) for (const a of g.actions) out.add(a.index);
|
||||
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
|
||||
for (const h of menu.hand) {
|
||||
if (h.playNow !== null) out.add(h.playNow);
|
||||
@@ -1817,6 +1829,37 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('offers "where does this Extra start" on screen, not just in the menu', () => {
|
||||
// REPORTED: the game "hung" mid-Freight-House-work with nothing to click. `newTrain.startExtra`
|
||||
// was legal and present in `menu.direct` — the engine was never stuck — but its group is titled
|
||||
// by `trainCardTitle`, which begins "Making up Extra X22…" exactly like the yard-chip panel's
|
||||
// OWN group. `renderActions()` used to drop every group titled that way, on the assumption only
|
||||
// the yard-chip panel used the prefix; with no tray yet being filled (`menu.makeUp` is null,
|
||||
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
|
||||
// it vanished from the page — a legal decision with zero buttons.
|
||||
const game = newGame(430);
|
||||
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
game.state.clock.phase = 'newTrain';
|
||||
game.state.clock.currentActor = 0;
|
||||
assert.equal(currentActor(game), 0, 'a decision should be waiting');
|
||||
|
||||
const menu = actionMenu(game);
|
||||
assert.equal(menu.makeUp, null, 'no tray is being filled — the Extra has not started yet');
|
||||
const extraGroup = menu.direct.find((g) => g.actions.some((a) => menu.options[a.index]?.type === 'newTrain.startExtra'));
|
||||
assert.ok(extraGroup, 'newTrain.startExtra should appear in some menu.direct group');
|
||||
assert.match(extraGroup!.title, /^Making up /, 'this is exactly the title shape that used to collide');
|
||||
|
||||
// The actual filter `renderActions()` applies (main.ts) — reproduced here for the reason given
|
||||
// on `reachable()` above: there is no DOM harness in this suite to call the real renderer.
|
||||
const onScreen = menu.direct.filter(
|
||||
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||
);
|
||||
assert.ok(
|
||||
onScreen.includes(extraGroup!),
|
||||
'the Extra\'s "choose where it starts" group was filtered out along with the yard-chip panel',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders every part of the menu it is given', () => {
|
||||
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
|
||||
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
|
||||
@@ -2054,6 +2097,27 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the crew chip on a train standing away from the Office', () => {
|
||||
// REPORTED: dropping a loaded boxcar off the back of Train 3 at the Freight House made "the
|
||||
// train vanish off the face of the earth". The game state was never wrong — `selectedTrain` was
|
||||
// only ever assigned inside the `cell.adTracks !== null` branch, so a train standing on any card
|
||||
// OTHER than the Office (which is every card it stands on while actually being switched) got no
|
||||
// badge at all: no label, no engine arrow, no cars. This is the Freight House from that report —
|
||||
// an ordinary facility card, `adTracks: null`, one train parked on it mid-switch.
|
||||
const cell = {
|
||||
row: 1, col: 0, kind: 'facility', label: 'Freight House', running: false,
|
||||
enhancements: [], enhancementsWhat: [], adTracks: null,
|
||||
trains: [{
|
||||
trayId: 'tray3', label: 'T3', cars: ['empty boxcar'], engineAt: 0, facing: 'w' as const,
|
||||
what: 'Train 3 "Express"',
|
||||
}],
|
||||
cars: [], standingWest: 0, facility: null, links: ['ew'], what: '',
|
||||
};
|
||||
const svg = officeSvg([cell as never], 0);
|
||||
assert.match(svg, /class="bs-crew"/, 'the crew badge is not drawn off the Office square');
|
||||
assert.match(svg, />T3</, 'the train\'s own label is missing from the board');
|
||||
});
|
||||
|
||||
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||
@@ -3113,18 +3177,19 @@ describe('two crews switching are told apart', () => {
|
||||
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.
|
||||
it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
|
||||
// v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
|
||||
// "may never be touched": a train held at the Office may still need to clear onto Secondary
|
||||
// Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
|
||||
// is not filtered any differently for a no-switching train than for any other.
|
||||
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');
|
||||
assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
|
||||
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user