v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs

Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
Jesse
2026-08-08 05:23:17 -04:00
co-authored by Claude Opus 5
parent f4c0f49604
commit 5d825b97d2
20 changed files with 1604 additions and 191 deletions
+242 -37
View File
@@ -52,11 +52,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
const CH = 58;
const GAP = 6;
/**
* Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
* letters — the west end read "TER" and the east "tra".
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
*
* They used to hang off the ends — "west end · in and out" anchored outside the first cell — which
* needed about 126px of padding to fit and had 92, so both ends were clipped mid-word. Widening
* the padding to fit them would have spent that space on two captions instead of on the map.
* They now sit UNDER their own Division Point, where they are neither clipped nor in the way, and
* this is back to what a buffer stop actually needs.
*/
const PAD = 92;
const PAD = 22;
const SIDE_GAP = 34;
const esc = (t: string): string =>
@@ -67,7 +71,17 @@ export function divisionSvg(nodes: DivisionView[]): string {
kind: 'dp' | 'ml' | 'run';
label: string;
sub: string;
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
trains: {
label: string;
consist: string[];
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
cars?: string[];
engineAt?: number;
facing?: string;
region?: number;
direction?: string;
stagesLeft?: number;
}[];
cap: number | null;
tip: string;
owner: number | null;
@@ -262,19 +276,66 @@ export function divisionSvg(nodes: DivisionView[]): string {
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
}
/**
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
*
* The chip was a name and a figure, and the figure was Stages left to cross this card: read
* once as the car count, and once the unit was spelled out, read as redundant beside the
* position the card already draws. What it never said is the thing worth knowing at a glance —
* what the train is carrying, whether those cars are loaded, and which end the engine is on.
*
* WEST ON THE LEFT here too. `cars` is nose first, so an east-facing train is drawn reversed and
* its engine lands at the east end, exactly as on the district card.
*/
c.trains.forEach((t, k) => {
const w = 30 + Math.min(t.consist.length, 4) * 8;
const kindOf = (car: string): string =>
/caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box'
: /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : '?';
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '◀' : t.facing === 'e' ? '▶' : t.facing === 'n' ? '▲' : '▼';
const items: { label: string; kind: string }[] = cars.map((car) => ({
label: kindOf(car),
kind: /^loaded/.test(car) || /caboose/.test(car) ? 'ld' : 'mt',
}));
items.splice(Math.max(0, Math.min(items.length, t.engineAt ?? 0)), 0, { label: arrow, kind: 'eng' });
const laid = t.facing === 'e' ? [...items].reverse() : items;
const nameW = t.label.length * 6.5 + 6;
const w = Math.min(c.w - 8, nameW + laid.length * 13 + 6);
/**
* NARROW THE CARS RATHER THAN DROP THEM.
*
* A Running Track cell is 78 wide and a four-car train plus its engine wants more than that.
* Squeezing is honest; leaving cars out is not — a chip showing three of five cars is a chip
* that lies about the train. Below 11px the three-letter type will not fit, so the blocks
* carry the load state alone and the tooltip carries the words.
*/
const CARW = Math.max(5, Math.min(13, (w - nameW - 6) / Math.max(1, laid.length)));
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
const tx = slotX + spread;
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
const stages =
typeof t.stagesLeft === 'number'
? ` · ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
' train takes over them depends on the card speed and the train)'
: '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(cars.join(', ') || 'no cars')}, nose first${
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
}">` +
}${esc(stages)}">` +
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
`<text class="bs-tlab" x="${tx - w / 2 + 4}" y="${c.y + 35}">${esc(t.label)}</text>`;
laid.forEach((it, n) => {
const bx = tx - w / 2 + nameW + n * CARW;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${c.y + 25}" width="${Math.max(3, CARW - 2)}" height="13" rx="2"/>`;
if (CARW >= 11) {
out += `<text class="bs-tcarlab" x="${bx + (CARW - 2) / 2}" y="${c.y + 35}" text-anchor="middle">${esc(it.label)}</text>`;
}
});
out += '</g>';
});
out += '</g>';
});
@@ -283,19 +344,27 @@ export function divisionSvg(nodes: DivisionView[]): string {
// what stops a seated layout being read as a loop.
const first = cells[0];
const last = cells[cells.length - 1];
/**
* UNDER THE DIVISION POINT, NOT BESIDE IT.
*
* Both labels used to hang off the outside of the end cells, anchored away from the board — which
* needed padding wider than the caption to survive, did not get it, and so printed clipped at both
* ends while spending the space anyway. Centred under their own cell they are neither cut off nor
* competing with the map for width.
*
* BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
* Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said the
* Division ran one way, which it does not — the buffer stops only mean it is a LINE, not a loop.
*/
if (first) {
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
// BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
// Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said
// the Division ran one way, which it does not — the point of the buffer stops is only that it is
// a LINE and not a loop.
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">west end · in and out</text>`;
out += `<text class="bs-end" x="${first.x + first.w / 2}" y="${first.y + CH + 13}" text-anchor="middle">west end · in and out</text>`;
}
if (last && last !== first) {
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">east end · in and out</text>`;
out += `<text class="bs-end" x="${last.x + last.w / 2}" y="${last.y + CH + 13}" text-anchor="middle">east end · in and out</text>`;
}
out += '</svg>';
@@ -317,6 +386,15 @@ export function officeSvg(
cells: CellView[],
runningRow: number,
ghosts: { row: number; col: number }[] = [],
/**
* Legal squares that already HAVE a card on them, each with what playing there would do.
*
* An empty legal square gets a whole ghost card saying "place here"; an occupied one used to get a
* blue outline and nothing else, so the two most important placements in the game — extending the
* Running Track at a Limits sign, and attaching an Enhancement to a card already down — were
* offered without a word. Reported as the Depot lighting up for no visible reason.
*/
legal: { row: number; col: number; label: string }[] = [],
): string {
const W = 166;
const H = 96;
@@ -495,12 +573,58 @@ export function officeSvg(
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
/**
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
*
* A Refinery ships out and a Grocer's Warehouse receives, and the drawing told them apart by
* the STROKE COLOUR of one 13x12px box and the direction of a 10px chevron. Asked directly in
* playtesting whether the two were supposed to look the same: they were not, and effectively
* they did. A word costs almost nothing and cannot be misread.
*/
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
)}">${esc(flow)}</text>`;
/**
* THE WORKERS, ON THE CARD.
*
* "How do I see the number of laborers in an industry card?" — you could not. The number that
* decides every Cargo phase lived only in a side panel, so a Modifier's +1 Laborer landed
* where the player was not looking and an Oil Depot appeared to do nothing at all. Porters
* were worse: nothing on the page drew them anywhere.
*
* FREE OVER TOTAL, because free is what can be spent right now. A facility has one kind of
* worker or the other — Laborers work freight, Porters work passengers — so the card carries
* whichever one it actually has.
*/
const isPorter = f.laborers.startsWith('0/0');
const crew = isPorter ? f.porters : f.laborers;
if (!crew.startsWith('0/0')) {
const free = Number(crew.split('/')[0]);
out += `<g data-tip="${esc(
isPorter
? `${crew} Porters free this Stage. A Porter boards or detrains passengers — one action each, and the Revenue arrives in that single action.`
: `${crew} Laborers free this Stage. One Laborer moves one load one square along MEN | AT | WORK, so this is the whole of what this industry can do before the Stage turns over.`,
)}">` +
`<rect class="bs-crewpill${free > 0 ? ' bs-ready' : ''}" x="${W - 44}" y="${by - 1}" width="38" height="14" rx="7"/>` +
`<text class="bs-crewlab" x="${W - 25}" y="${by + 9}" text-anchor="middle">${esc(isPorter ? 'P' : 'L')} ${esc(crew)}</text></g>`;
}
}
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
const filled = i < cell.cars.length;
const carTip = filled
? `${cell.cars[i]} standing here`
: 'an empty space on this track — a car may be spotted here';
/**
* WHICH CAR IS STANDING HERE, not just that one is.
*
@@ -526,11 +650,24 @@ export function officeSvg(
: /coach/.test(car)
? 'cch'
: '';
const loaded = /^loaded/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
// it is a car that is doing its job, not an empty waiting for one.
const loaded = /^loaded/.test(car) || /caboose/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : '';
/**
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
*
* REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train
* tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a
* generic blue fill first and only a LOADED car overrode it with its type colour, so empty
* read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was.
*
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
*/
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
}
}
@@ -551,22 +688,41 @@ export function officeSvg(
* is a thing you can see before you choose it.
*/
const t = cell.train;
const items: { label: string; kind: string }[] = t.cars.map((c) => ({
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
kind: /^loaded/.test(c) ? 'ld' : 'mt',
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
what: c,
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
/**
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
*
* `consist` is ordered NOSE FIRST (state.ts), and this drew index 0 leftmost whatever the
* train was doing \u2014 so a westbound train came out right and an eastbound one came out
* MIRRORED. Reported from seed 270861860: Train 10 running east arrived at the Whistle Post
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
*
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
*/
const laid = t.facing === 'e' ? [...items].reverse() : items;
const cw = 17;
const tw = Math.min(W - 8, items.length * cw + 30);
const tw = Math.min(W - 8, laid.length * cw + 30);
const tx = W / 2 - tw / 2;
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
const facingWord =
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
`${t.label} \u2014 engine pointing ${facingWord}, carrying ${consistWords} (nose first). Drawn as it stands: west is on the left.`,
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
items.forEach((it, n) => {
laid.forEach((it, n) => {
const bx = tx + 28 + n * cw;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2" data-tip="${esc(it.what)}"/>` +
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle" data-tip="${esc(it.what)}">${esc(it.label)}</text>`;
});
out += '</g>';
}
@@ -582,6 +738,23 @@ export function officeSvg(
`</g>`;
}
/**
* A LEGAL SQUARE THAT ALREADY HAS A CARD ON IT, captioned like the empty ones.
*
* The two are the same question — you have picked a card, now pick where it goes — and they were
* answered in two different registers: a full ghost card reading "place here" for an empty square,
* and a 2px blue outline for an occupied one. So the Limits sign (which is how a district grows at
* all) and the Depot (which is where an Enhancement attaches) both lit up saying nothing.
*
* Drawn as a band across the card rather than over its middle: there is a rail, a train and a load
* pipeline under here, and the caption must not sit on top of them.
*/
for (const l of legal) {
out += `<g class="bs-legalcap" transform="translate(${px(l.col)},${py(l.row)})">` +
`<rect x="6" y="${H - 46}" width="${W - 12}" height="14" rx="7"/>` +
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
@@ -624,22 +797,25 @@ export const BOARD_CSS = `
train is measured against, not something to look at instead of the train. */
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
type has to be readable at a glance; and a loaded car and an empty one of the same type are
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
are the same fact about the same car. An occupied slot used to take a generic blue fill and only
a loaded car overrode it, so a 26x15px square said "a car" and left "which car, carrying what?"
to be worked out. Empty is now hollow with a dark face, loaded a solid one, exactly as in the
tray; the type colour is the stroke either way. */
.bs-slot.bs-car-box{stroke:#c08a4a}
.bs-slot.bs-car-hop{stroke:#8d97a5}
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
.bs-slot.bs-car-cch{stroke:#5aa9e6}
.bs-slot.bs-car-cab{stroke:#c05a5a}
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.bs-slot.bs-occ.bs-mt{fill:#181d24}
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.85)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.85)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.85)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.85)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
@@ -655,7 +831,10 @@ export const BOARD_CSS = `
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
light on an empty one's hollow. */
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700}
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
.bs-wb.bs-green{stroke:#3f7a52}
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
@@ -664,6 +843,17 @@ export const BOARD_CSS = `
.bs-wb.bs-red{stroke:#8a5a5a}
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
/* WHICH WAY THE INDUSTRY RUNS. Green for ships-out, red for receives, in the same hues as the boxes
they describe — the word and the colour saying the same thing twice. */
.bs-flow{font:700 8px ui-monospace,monospace;letter-spacing:.06em;cursor:help}
.bs-flow-out{fill:#5f9b74}
.bs-flow-in{fill:#b07272}
.bs-flow-both{fill:#8b94a3}
/* THE WORKERS. Free over total, on the card, because a Laborer that is already spent this Stage is
not a Laborer you can use. Lit while any remain, dim once they are all gone. */
.bs-crewpill{fill:#20262e;stroke:#4a5361;stroke-width:1}
.bs-crewpill.bs-ready{fill:rgba(190,150,40,.22);stroke:#8a6d1f}
.bs-crewlab{fill:#cfd6e0;font:600 9px ui-monospace,monospace}
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
@@ -677,9 +867,24 @@ text.bs-mod{fill:#c8a04a}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
same words in kind, because it is the same question: you have picked a card, now pick where. */
.bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1}
.bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em}
.bs-legalcap{pointer-events:none}
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
card reads the same way an action button does. bs-from is where it is standing now. */
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
is merely uninteresting. The reason rides on the card's own tooltip. */
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
/* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and
simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber,
because it IS reachable, just not a place to stop. */
g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4}
g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help}
`;
+41 -7
View File
@@ -17,7 +17,7 @@
import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -212,12 +212,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good',
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced':
return {
tone: 'good',
where: e.at,
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
};
case 'enhancementPlaced': {
const name = e.key.replace(/([A-Z])/g, ' $1');
// Out on the Mainline is not a square in anyone's district, so it is named rather than
// given a coordinate the Office Area does not have.
if (e.at === undefined) {
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
}
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
}
case 'secondSectionOrdered':
return {
tone: 'bad',
@@ -491,6 +494,37 @@ export function impediments(s: GameState, player = 0): Impediment[] {
}
}
/**
* WHY THE CREW CANNOT GET THERE.
*
* The panel is called "why nothing is moving" and covered everything except movement: jammed
* facilities, held trains, a full Office. A crew standing one card short of an industry it cannot
* enter had nothing here at all — reported as trains being blocked "in certain conditions", with
* no way to find out which.
*
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
* squares the movement walk reached and refused, not every square on the board.
*/
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { blocked } = movesFor(s, player, id);
for (const b of blocked) {
// A turnout is not an obstruction — a train runs through one all day and simply may not
// STOP on it. Listing every one would bury the four reasons that are genuinely in the way;
// 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})`,
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.
severity: 'waiting',
});
}
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
+156 -19
View File
@@ -10,7 +10,14 @@
* drift into two different pictures of the same board.
*/
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
areaOf,
destinationsFor,
facilityCarType,
laborersLeft,
movesFor,
portersLeft,
} from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
@@ -29,7 +36,7 @@ import {
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
@@ -108,7 +115,7 @@ export type FacilityView = {
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number };
base: { out: number; in: number; laborers: number; porters: number };
/**
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
*
@@ -133,7 +140,19 @@ export type FacilityView = {
export type TrainChip = {
label: string;
/** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */
consist: string[];
/**
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
*
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
* number was Stages left to cross, which reads as redundant beside the position already drawn on
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
*/
cars: string[];
engineAt: number;
facing: string;
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -141,6 +160,15 @@ export type TrainChip = {
*/
region?: number;
direction?: string;
/**
* Stages still to run before it is off this Mainline card — NOT the same as regions left.
*
* A card is two regions of fixed distance; the Stages are how long this train takes over them
* (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they
* coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count;
* it belongs in the tooltip, where there is room to say which it is.
*/
stagesLeft?: number;
};
/**
* One card of a player's Running Track, as the Division sees it.
@@ -223,6 +251,22 @@ export type Frame = {
* moves themselves.
*/
movesLeft: number | null;
/**
* WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise.
*
* The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with
* nothing on the board and no account of the squares that were missing from the list. Reported as
* trains being blocked from entering an industry "in certain conditions", with no way to see what
* the conditions were.
*
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
* that actually refused the square rather than a second guess at it.
*/
moves: {
from: { row: number; col: number };
to: { row: number; col: number }[];
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
} | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -334,7 +378,7 @@ function facilityView(
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
allowsOut: f.allows.outbound,
allowsIn: f.allows.inbound,
base: baseOf(card),
base: baseOf(card, officeName),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
@@ -345,13 +389,29 @@ function facilityView(
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
function baseOf(
card: { geometry: { kind: string; facility?: string } },
officeName: string,
): { out: number; in: number; laborers: number; porters: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 };
}
return { out: 0, in: 0, laborers: 0 };
/**
* A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER.
*
* The comment above this function has said so for a long time and the code returned zeros, so the
* panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no
* Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played.
* The tier is the printed number here, exactly as the industry card is for an industry.
*/
if (g.kind === 'office') {
const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost';
const p = officeProfile(tier);
return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters };
}
return { out: 0, in: 0, laborers: 0, porters: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
@@ -459,8 +519,30 @@ export function describeIntent(s: GameState, i: Intent): string {
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.move': {
/**
* SAY WHAT THE MOVE WILL PICK UP.
*
* Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the
* train, whether or not you wanted them. The button said "move to (0, -2)" and the only
* account of the coupling was a line in the history panel — which is how a playtester ended up
* reporting that "cars magically appeared on my train".
*
* Taken from the engine's own destination list, so the count on the button is the count that
* will actually couple.
*/
const tray = s.trays.get(i.trayId);
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = '';
if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && dest.couples.length > 0) {
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
}
}
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
}
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
@@ -708,13 +790,14 @@ export function snapshot(
return {
...chip,
/**
* SAY WHAT THE NUMBER IS.
* THE NUMBER COMES OFF THE CHIP.
*
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
* car count twice, by the same player, because that is the obvious guess. It is Stages
* left to cross this Mainline card. Naming the unit costs three characters.
* It read "TX14 (2)" and was taken for the car count — twice, by the same player — so
* it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as
* redundant: the card already draws WHERE the train is, and how many Stages it still
* needs is a detail for the tooltip. The chip draws the train instead.
*/
label: `${chip.label} · ${t.stagesRemaining}⧗`,
stagesLeft: t.stagesRemaining,
region: place(t),
direction: t.direction,
};
@@ -826,6 +909,7 @@ export function snapshot(
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
moves: switchingMoves(s, 0),
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -978,15 +1062,44 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
/**
* THE WHOLE RULE, not half of it.
*
* This said "lay track HERE to extend the Running Track", which is true and leaves out the
* part a player has to know: the sign is the ONLY growth point on the main, it moves outward
* with the card, and nothing anywhere on the board can be inserted between two cards already
* down. Asked for directly after a playtest — "somewhere it should be clear that track can
* only be added to expand outwards".
*/
return (
'the edge of your control area. Lay track HERE and the sign moves one card further out — ' +
'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' +
'A district only ever expands: no card can be inserted between two cards already down.'
);
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
/**
* READ THE OFFICE AS IT STANDS, not as its card was printed.
*
* This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area,
* Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out —
* changed the Office and left this line saying what a bare Depot has. Reported as an extra
* porter with "no indication of it anywhere": the Modifier had worked and nothing said so.
*
* The facility record is the Office; the profile is only what it started as.
*/
const f = card.facility;
const porters = f ? f.porters : p.porters;
const out = f ? f.capacity.outbound : p.passengerOut;
const inb = f ? f.capacity.inbound : p.passengerIn;
const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` +
(added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '')
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
@@ -1193,9 +1306,29 @@ function countStock(
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
}
/**
* The switching crew's reach, for the board to draw.
*
* Only while a switching turn is actually running and only while Moves remain: a highlight that
* survives into the Cargo phase is an invitation to click something that is no longer offered.
*
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
* question the page does not yet ask.
*/
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
if (s.turn.movesRemaining < 1) return null;
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked };
}
return null;
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
@@ -1206,10 +1339,14 @@ function trainChip(s: GameState, id: string): TrainChip {
*/
const cars = t.consist.map(carLabel);
const at = Math.max(0, Math.min(cars.length, t.engineAt));
cars.splice(at, 0, 'ENG');
const seated = [...cars];
seated.splice(at, 0, 'ENG');
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: cars,
consist: seated,
cars,
engineAt: at,
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
}