v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape. ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said "any Mainline card". The engine was never wrong: check accepts any node whose kind is mainline and legalActions filters by check, so all of them were legal. The failure was the LABEL — describeIntent named i.placement and never i.node, so every placement described itself as plain "play ABS Signals", and the action list drops duplicate labels. All but the lowest-index node were discarded before the menu saw them. This is the THIRD time that trap has fired and the file documents the other two three lines apart: a turnout's two rotations, and three Department discards. Same fix — name what distinguishes them. The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no key-splitter can recover, so the authored names now win. Three of those names were transcribed in sentence case and were CORRECTED rather than adopted: the repository says "Yard Office" 36 times against "Yard office" twice. A lookup that imports its own source's typos is the drift it exists to prevent. NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no mark and you found out by hovering — the same complaint the Heavy Grade wedge answered, and it matters more here because ABS decides whether a second train on that card is safe. It draws a signal mast with a lit lamp now; a signal is the literal object and needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce takes the `became` branch and changes node.card, so a realigned Trestle IS an Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding. A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one line. It asserted an outcome, where §6.3 requires no action and the bot declines deliberately — unjamming a healthy box destroys a load that cost a whole action to stock. An idle Agent was then silent, which read as a dropped turn; a new freightAgentIdled event says so and why, reducing to nothing exactly like switchingEnded. And the work named a coordinate rather than the industry, though a `place` helper has existed for precisely that since the switching lines moved to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House", with the direction in capitals because to-or-from was the question asked. THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why; narrate.ts wrote the internal storage order with no comment at all. So the menu offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side. Pinned by a test that renders one square through BOTH describers and compares them to each other: a test written against either file alone would have passed. THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED. v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five documents by relative path and every one 404'd on the package, verified against the running container. The build publishes the full set, and the test reads the links OUT OF the guide rather than listing them. They are linked from the This Game card, where reference already lives, rather than the header that must not wrap; no mode awareness is needed, because solitaire and multiplayer are the same page on the same origin. THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16 and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they were first written against, kept only because 36 citations pointed at it — and it read as documentation five minor versions stale. They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now, kept current with each release rather than published as editions. Two errors surfaced while checking them against this release, which is the argument for doing it: home-deck.md filed ABS Signals under Enhancements "played into your district" that "change what a square does" — it does neither, this release's bug written down — and mainline-deck.md, which lists everything playable onto a Mainline card, never mentioned it at all. 1010 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
This commit is contained in:
co-authored by
Claude Opus 5
parent
dc31555625
commit
b90c0413d2
+13
-1
@@ -1825,9 +1825,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
|
||||
case 'freightAgent.end':
|
||||
/**
|
||||
* An idle Freight Agent SAYS SO. §6.3 requires no action and choosing to take none is a real
|
||||
* decision — see `freightAgentIdled`. Without the line the log announced the option and then
|
||||
* fell silent, which reads as the game having dropped the turn.
|
||||
*/
|
||||
return [
|
||||
...(turnOf(s, player).freightAgentUsed
|
||||
? []
|
||||
: [{ type: 'freightAgentIdled', player } as const]),
|
||||
{ type: 'phaseEnded', player, phase: 'localOps' },
|
||||
];
|
||||
|
||||
case 'draw.fromHomeOffice': {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
|
||||
+17
-6
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
||||
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||
* `home-deck.md` prints it "Outbound, 1 out / 0 in".
|
||||
*/
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
@@ -297,7 +297,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
/**
|
||||
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
|
||||
* not ship anything out". `home-deck.md` prints it "Inbound, 0 out / 1 in".
|
||||
*
|
||||
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||
@@ -668,7 +668,7 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
|
||||
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
||||
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
||||
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
|
||||
* inventory, transcribed in `docs/mainline-deck.md`, which had already flagged
|
||||
* the mismatch as needing correction.
|
||||
*
|
||||
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
||||
@@ -1008,14 +1008,25 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to
|
||||
* `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals"
|
||||
* beside a tooltip saying ABS).
|
||||
*
|
||||
* So their CASE matters, and three were transcribed in sentence case while the rules, the panels
|
||||
* and every other line of this repository treat them as proper terms: "Yard Office" outnumbered
|
||||
* "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than
|
||||
* special-cased in `cardName`, because this table is the source and a lookup that second-guesses
|
||||
* its own source is the drift it exists to prevent.
|
||||
*/
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
|
||||
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'yardOffice', name: 'Yard Office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small Yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water Column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
||||
/**
|
||||
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
||||
|
||||
@@ -115,6 +115,17 @@ export type GameEvent =
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
/**
|
||||
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
|
||||
*
|
||||
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
|
||||
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
|
||||
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
|
||||
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
|
||||
*
|
||||
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
|
||||
*/
|
||||
| { type: 'freightAgentIdled'; player: PlayerIndex }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
||||
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
||||
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
||||
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
|
||||
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
|
||||
* deal is now a deal: take cards out of it and do not put them back.
|
||||
*
|
||||
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
||||
|
||||
@@ -71,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||
* anything outside its own body.
|
||||
*/
|
||||
/**
|
||||
* The Mainline modifiers, as the three letters drawn on a card.
|
||||
*
|
||||
* DECLARED INSIDE THIS FUNCTION, like every constant around it, because `sim/replay.ts` emits
|
||||
* `divisionSvg.toString()` into the replay page — a module-scope const it closed over would be
|
||||
* undefined there, and the page threw exactly that way before this was moved.
|
||||
*
|
||||
* Keyed by the name the VIEW builds (`prettyKey`'d in `view.ts`), because that is what arrives
|
||||
* here — not the engine's key, which this file never sees. A modifier with no tag draws nothing
|
||||
* rather than a raw key: `flatMap` over a missing entry yields none, so a card added upstream is
|
||||
* silently unmarked instead of printing "rotaryDumps" at a player.
|
||||
*
|
||||
* ABS Signals is deliberately absent: it has a signal mast of its own.
|
||||
*/
|
||||
const MOD_TAGS: Record<string, string> = { Brakeman: 'BRK', Airbrakes: 'AIR', Helpers: 'HLP' };
|
||||
|
||||
const CW = { dp: 118, ml: 152, run: 78 };
|
||||
/**
|
||||
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
||||
@@ -144,6 +160,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
tip: string;
|
||||
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
||||
flash?: boolean;
|
||||
/** ABS Signals standing on this Mainline card — drawn as a signal mast, not only described. */
|
||||
abs?: boolean;
|
||||
/** The Mainline modifiers on this card, as short tags — see `MOD_TAGS`. */
|
||||
mods?: string[];
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
@@ -291,6 +311,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
// words with no gameplay attached — reported exactly that way.
|
||||
(n.what ? `\n\n${n.what}` : ''),
|
||||
seat: null,
|
||||
// Attachments are drawn, not only described: `modifiers` carries ABS Signals alongside the
|
||||
// Mainline modifiers, and the name is the one the view already built for the tooltip.
|
||||
abs: !dp && n.modifiers.includes('ABS Signals'),
|
||||
mods: dp ? [] : n.modifiers.flatMap((m) => MOD_TAGS[m] ?? []),
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||
@@ -416,6 +440,57 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
out += `</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ABS SIGNALS, DRAWN RATHER THAN ONLY SAID — reported from a table on Day 1 Stage 1 of
|
||||
* v0.8.0.16: "when played on the trestle, there was no on-the-card indication; it's only when
|
||||
* you look at the tooltip for trestle that you see that ABS exists."
|
||||
*
|
||||
* Exactly the complaint the grade wedge below answers, and the card it protects is the one a
|
||||
* player is deciding whether to run a second train onto. A signal is the literal object — ABS
|
||||
* is Automatic Block Signals — so the mark is a mast with a lit lamp rather than a badge
|
||||
* reading "ABS", and it needs no room for text, which is what lets it sit clear of a name as
|
||||
* long as "Uncontrolled Siding" on a 152px card.
|
||||
*
|
||||
* TOP-RIGHT, above the rail and clear of the chip registers: the flag owns the cell ends at
|
||||
* rail height and the grade wedge owns the bottom-right, so this is the corner left.
|
||||
*/
|
||||
if (c.abs) {
|
||||
const mx = c.x + c.w - 10;
|
||||
const top = c.y + 5;
|
||||
out += `<g class="bs-abs">`;
|
||||
out += `<line class="bs-abs-mast" x1="${mx}" y1="${top}" x2="${mx}" y2="${top + 15}"/>`;
|
||||
// Two lamps, the upper one lit: a signal showing an aspect, not a bare post.
|
||||
out += `<circle class="bs-abs-lit" cx="${mx}" cy="${top + 3}" r="2.6"/>`;
|
||||
out += `<circle class="bs-abs-dark" cx="${mx}" cy="${top + 9}" r="2.6"/>`;
|
||||
out += `</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE MAINLINE MODIFIERS, DRAWN RATHER THAN ONLY SAID (Jesse, 2026-09-21) — the other half of
|
||||
* the ABS report above: "Brakeman, Airbrakes, Helpers and Realignment should also be drawn on
|
||||
* the card, not just the tooltip."
|
||||
*
|
||||
* ONLY THREE OF THOSE FOUR CAN EVER BE HERE. Realignment does not sit on a card — `reduce`
|
||||
* takes the `became` branch and CHANGES `node.card`, so a realigned Trestle simply is an
|
||||
* Uncontrolled Siding afterwards and the card face already says so. The other three are
|
||||
* `gradeOnly`, so in practice this is a Heavy Grade's row.
|
||||
*
|
||||
* TAGS, NOT NAMES, and only because the measurements leave no choice: "Brakeman · Airbrakes ·
|
||||
* Helpers" is 30 characters where about eleven fit beside a card name on a 152px cell. Three
|
||||
* letters is the most that fits while still mapping to one card each, and the tooltip — which
|
||||
* has always named them in full — is what expands it. The mark says THAT there is one, which
|
||||
* is the half that was missing.
|
||||
*
|
||||
* Right-aligned on the name row: the only band on the cell that is clear, with the chip
|
||||
* registers starting at `CHIP_Y` below and the capacity line and grade wedge at the foot.
|
||||
* Shifted left of the signal when a card carries both.
|
||||
*/
|
||||
if (c.mods && c.mods.length > 0) {
|
||||
out +=
|
||||
`<text class="bs-mod" text-anchor="end" x="${c.x + c.w - (c.abs ? 20 : 7)}" y="${c.y + 14}">` +
|
||||
`${esc(c.mods.join('·'))}</text>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||
*
|
||||
@@ -1392,6 +1467,12 @@ export const BOARD_CSS = `
|
||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||
.bs-mod{font:10px ui-monospace,monospace}
|
||||
text.bs-mod{fill:#c8a04a}
|
||||
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
|
||||
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
|
||||
the amber "it is happening here" or the red flag for meaning. */
|
||||
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
|
||||
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
|
||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
|
||||
+38
-5
@@ -74,7 +74,16 @@ export function carsLabel(cars: RollingStock[]): string {
|
||||
return cars.map(carLabel).join(', ');
|
||||
}
|
||||
|
||||
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
|
||||
/**
|
||||
* X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col
|
||||
* storage order.
|
||||
*
|
||||
* These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for
|
||||
* the same square, side by side on the same screen. `view.ts` carried the comment explaining why
|
||||
* the display order is X,Y; this one had no comment at all and was simply the storage order
|
||||
* reaching the page. Jesse's call — the log and the action menu spell a square the same way.
|
||||
*/
|
||||
const at = (c: GridCoord): string => `(${c.col},${c.row})`;
|
||||
|
||||
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
|
||||
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
|
||||
@@ -188,7 +197,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
|
||||
: e.option === 'draw'
|
||||
? 'Chose to DRAW a card'
|
||||
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
|
||||
/**
|
||||
* SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a
|
||||
* facility" — an assertion — and §6.3 requires no action at all, so when the Freight
|
||||
* Agent went idle the log claimed a car had moved and then fell silent about which.
|
||||
* Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by
|
||||
* `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and
|
||||
* the industry; an idle Agent is narrated by `freightAgentIdled`.
|
||||
*/
|
||||
: 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam',
|
||||
};
|
||||
case 'trayMoved':
|
||||
// `via` rides on the event only when there was another legal route to the same square
|
||||
@@ -466,23 +483,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
|
||||
// -- freight agent
|
||||
/**
|
||||
* THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives:
|
||||
* "(-1,1)" is the grid's notation and means nothing at a table where people are looking at
|
||||
* cards. The switching lines were moved to it and these three were missed, so a Freight Agent
|
||||
* turn was the one place the log still spoke in coordinates. Asked directly from a table on
|
||||
* Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or
|
||||
* from." The car and the direction were already here; the facility was not.
|
||||
*/
|
||||
case 'stockToOutbound':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
|
||||
text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`,
|
||||
};
|
||||
case 'inboundCleared':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
|
||||
text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`,
|
||||
};
|
||||
case 'facilityUnjammed':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
where: e.at,
|
||||
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
};
|
||||
case 'freightAgentIdled':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text:
|
||||
'Freight Agent found nothing worth doing — no green box could be stocked, no red box needed ' +
|
||||
'clearing, and no load was jammed. §6.3 requires no action, and unjamming a healthy box ' +
|
||||
'would destroy a load that cost a whole action to stock.',
|
||||
};
|
||||
|
||||
// -- trains
|
||||
|
||||
+40
-2
@@ -953,9 +953,22 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
/**
|
||||
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
|
||||
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
|
||||
*
|
||||
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
|
||||
* absent and every one of its placements described itself as plain "play ABS Signals". The
|
||||
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
|
||||
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
|
||||
* one. Naming the card is what makes the choice both legible and survivable.
|
||||
*/
|
||||
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
|
||||
const mainline =
|
||||
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
|
||||
return (
|
||||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${turn}`
|
||||
);
|
||||
}
|
||||
case 'card.discard': {
|
||||
@@ -1903,10 +1916,35 @@ export function cardName(s: GameState, id: string): string {
|
||||
case 'mainlineModifier':
|
||||
case 'maneuver':
|
||||
case 'action':
|
||||
return prettyKey(k.key);
|
||||
/**
|
||||
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
|
||||
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
|
||||
*
|
||||
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
|
||||
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
|
||||
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
|
||||
* were title-cased past their printed "Broken coupler" and "Bean house".
|
||||
*/
|
||||
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every authored card name, keyed as the card kinds key themselves.
|
||||
*
|
||||
* Built from the content tables rather than restated, so a card renamed there is renamed here and
|
||||
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
|
||||
*/
|
||||
const SIMPLE_CARD_NAMES: ReadonlyMap<string, string> = new Map(
|
||||
[
|
||||
...SPACE_USE_CARDS,
|
||||
...ENHANCEMENT_CARDS,
|
||||
...MAINLINE_MODIFIER_CARDS,
|
||||
...MANEUVER_CARDS,
|
||||
...ACTION_CARDS,
|
||||
].map((c) => [c.key, c.name]),
|
||||
);
|
||||
|
||||
/**
|
||||
* What a card actually DOES, in one line.
|
||||
*
|
||||
|
||||
+40
-1
@@ -652,9 +652,48 @@ function renderGameCard(f: Frame): void {
|
||||
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||
$('gamecardbody').innerHTML =
|
||||
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||
rulesListHtml(config, players, f.days);
|
||||
rulesListHtml(config, players, f.days) +
|
||||
GUIDE_HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DOCUMENTATION, REACHABLE FROM INSIDE A GAME — asked for directly from a table, 2026-09-21:
|
||||
* "how can we link the documentation so it can be reached from the gameplay, whether someone is
|
||||
* playing solitaire or multiplayer?"
|
||||
*
|
||||
* IT NEEDS NO MODE AWARENESS, which is the whole reason this is three lines rather than a feature.
|
||||
* Solitaire and multiplayer are the same page on the same origin — `play.html?solitaire` and
|
||||
* `play.html?lobby` — and the build publishes the documents beside it, so one relative link
|
||||
* resolves identically in both, on the public site and on a StartOS box alike.
|
||||
*
|
||||
* IN THIS CARD RATHER THAN THE HEADER (Jesse's call, 2026-09-21), for the reason he gave the top
|
||||
* line in the first place on 2026-08-30: it is "not something that they're likely to need all the
|
||||
* time", and the header is the line that must not wrap. The card is already where reference lives
|
||||
* — the seed, the seat, the house rules — and it holds the whole set rather than one door.
|
||||
*
|
||||
* NEW TAB, every one of them: a player reading the rules mid-turn must not lose the game behind
|
||||
* them. `rel="noopener"` because `target="_blank"` without it hands the opened page a handle back.
|
||||
*
|
||||
* Built once at module scope — it never varies, and rebuilding it on every frame would be work
|
||||
* nobody sees, the same reasoning the folded body above already follows.
|
||||
*/
|
||||
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
|
||||
{ href: './quickstart.md', label: 'Quickstart', what: 'What the game is and a first twenty minutes — for anyone who has not played.' },
|
||||
{ href: './rules.md', label: 'Rules', what: 'The rules in full, with the FAQ.' },
|
||||
{ href: './rules/as-built.md', label: 'Every card', what: 'Generated from the code, so it cannot drift from what the game actually does.' },
|
||||
{ href: './home-deck.md', label: 'Home deck', what: 'How the Home Office deck is dealt and played.' },
|
||||
{ href: './mainline-deck.md', label: 'Mainline deck', what: 'The Mainline cards and what each does to a train.' },
|
||||
{ href: './components.md', label: 'Components', what: 'Rolling stock, yards, trays, the Fedora.' },
|
||||
];
|
||||
|
||||
const GUIDE_HTML =
|
||||
`<div class="guide"><h4>Guide</h4><p>` +
|
||||
GUIDE_DOCS.map(
|
||||
(d) =>
|
||||
`<a href="${esc(d.href)}" target="_blank" rel="noopener" data-tip="${esc(d.what)}">${esc(d.label)}</a>`,
|
||||
).join(' · ') +
|
||||
`</p></div>`;
|
||||
|
||||
/**
|
||||
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
||||
*
|
||||
|
||||
@@ -268,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||
#gamecardbody dd.changed{color:#f0b64a}
|
||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
|
||||
line of links rather than a list: six references stacked would make the card scroll past the
|
||||
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
|
||||
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
|
||||
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
|
||||
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
|
||||
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
|
||||
Reference in New Issue
Block a user