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