/** * Drawing the board. * * Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html): * * - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a * join is rail meeting rail rather than two descriptions that happen to agree. * - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so * it is drawn as a dispatcher's diagram: one line per track, count them to know * what fits. * * SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports, * no module-level helpers. The playable app imports them normally; the replay is a single HTML file * with an inline script and cannot import anything, so it embeds these via `Function.toString()`. * That keeps ONE implementation: a second copy would eventually draw a different board from the same * state, which is the exact failure a track diagram exists to prevent. * * Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can * therefore never claim a connection the rules do not have. */ import type { CellView, DivisionView } from './view.ts'; /** Anything the renderers need to know about a train standing somewhere. */ export type BoardTrain = { label: string; consist: string[] }; /** * The Division as a dispatcher would see it: one continuous line per running track, sections * separated by thin seams, capacity legible because the lines can be counted. */ /** * Who is at the table, so an Office can be labelled with its owner rather than only its tier. * * Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing * about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them. * Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`) * keeps working unchanged. */ export type DivisionRoster = { players: { index: number; seat: number; name: string }[]; /** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */ actor: number | null; /** The player this map is being drawn for. */ viewer: number; }; export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string { /** * THE WHOLE DIVISION, west to east, as one continuous route. * * It used to draw one box per node, which collapsed every player's district into a single * "Office" tile — so the thing a train actually runs along, the Running Track between the Limits, * was invisible on the only map that shows where trains are. Now an Office expands into its * Running Track, Limits to Limits, and the route reads end to end: * * West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP * * SEATING. Players sit around a table, so the route is laid out the way they do: one row alone, * two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop * — trains enter at one Division Point and leave at the other — so the shape is deliberately left * open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it * into a ring would promise a connection the rules do not have. * * Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for * anything outside its own body. */ const CW = { dp: 118, ml: 152, run: 78 }; const CH = 58; const GAP = 6; /** * ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold * them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass"). * Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS * holds still as they come and go, where sizing by occupancy moved the East Division Point (and * everything past it) sideways every time an A/D track filled or cleared. */ const CHIP_W = 54; /** * 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 = 22; const SIDE_GAP = 34; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); // -- flatten the route into drawable cells, remembering which belong to whose district -------- type Cell = { kind: 'dp' | 'ml' | 'run'; label: string; sub: string; trains: { label: string; consist: string[]; /** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */ cars?: string[]; engineAt?: number; /** East or west, always — a Division runs east and west and so does its rolling stock. */ facing?: 'e' | 'w'; /** The train's printed card, so a train out on the Mainline can still be asked what it is. */ what?: string; region?: number; direction?: string; stagesLeft?: number; }[]; cap: number | null; tip: 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. */ owner?: { name: string; isTurn: boolean; isYou: boolean } | null; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; w: number; x: number; y: number; }; const cells: Cell[] = []; const sides: number[][] = []; let side: number[] = []; const push = (c: Omit): void => { side.push(cells.length); cells.push({ ...c, x: 0, y: 0 }); }; for (const n of nodes) { if (n.kind === 'office') { const cap = n.capacity; const ad = n.trains.flat(); /** * THE NAME IS THE HEADLINE, the tier is the detail. * * "Where does Bob sit?" is the question this map could not answer: an Office was labelled * with its tier, which every player's Office also has, so four districts read the same. The * owner's name takes the headline and the tier moves down beside the A/D count, because the * name is what is being looked for and the tier is what is being referred to once found. */ const seatOwner = roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null; const owner = seatOwner ? { name: seatOwner.name, isTurn: roster!.actor === seatOwner.index, isYou: roster!.viewer === seatOwner.index, } : null; for (const rc of n.running ?? []) { const isOffice = rc.kind === 'office'; const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`; push({ kind: 'run', label: isOffice && owner ? owner.name : rc.label, owner: isOffice ? owner : null, // With an owner on the headline the tier would otherwise vanish, so it joins the A/D // count on the line below. sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '', /** * A train standing at the Office occupies an A/D track, which is where it is — but it is * ALSO standing on the Office grid card, so it arrives here in both lists and used to be * drawn twice. Reported as two T10 chips on one Office. */ trains: isOffice ? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))] : rc.trains, cap: isOffice ? cap : null, tip: owner && isOffice ? `${owner.name}'s ${rc.label}` + (owner.isYou ? ' — this is your railroad' : '') + // "their move" is wrong when the reader is the one being waited on. (owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') : `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, seat: n.seat ?? null, // No regions inside a district: a crew moves by Moves there, not by Stages, so it // occupies a card outright rather than a part of one. regions: 0, w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run, }); } // A crew below the Running Track has no position ON it, so it is reported against the // district rather than drawn somewhere it is not. const below = n.switching ?? []; if (below.length > 0) { const last = cells[cells.length - 1]; if (last) last.sub = `${below.length} switching below`; } sides.push(side); side = []; continue; } const dp = n.kind === 'dp'; push({ kind: dp ? 'dp' : 'ml', label: n.label, sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`, trains: n.trains.flat(), cap: n.capacity, tip: dp ? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit' : `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}` + // What the card actually DOES. The name alone left Hilly and Uncontrolled Siding as // words with no gameplay attached — reported exactly that way. (n.what ? `\n\n${n.what}` : ''), seat: null, // A Division Point is one region — the queue trains enter and leave the Division through. regions: dp ? 1 : (n.regions ?? 0), w: dp ? CW.dp : CW.ml, }); } if (side.length > 0) sides.push(side); // Each player's side carries their district and the Mainline card leading into it; whatever is // left over (the last Mainline and the East DP) joins the final side. const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length)); const lanes: number[][] = []; for (let i = 0; i < seats; i++) lanes.push([]); sides.forEach((grp, i) => { const target = Math.min(i, seats - 1); for (const idx of grp) lanes[target]!.push(idx); }); // -- lay the sides out around the table ------------------------------------------------------- // top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a // horseshoe open to the west, and a square broken at the same place. const dir: ('top' | 'right' | 'bottom' | 'left')[] = seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left']; const runLen = (idxs: number[]): number => idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP); const widest = Math.max(...lanes.map((l) => runLen(l)), 200); const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH; const vertCount = dir.filter((d) => d === 'right' || d === 'left').length; const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0); const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30; lanes.forEach((idxs, i) => { const d = dir[i]!; if (d === 'top' || d === 'bottom') { const y = d === 'top' ? PAD : boardH - PAD - CH - 22; const order = d === 'bottom' ? [...idxs].reverse() : idxs; let x = PAD; for (const idx of order) { const c = cells[idx]!; c.x = x; c.y = y; x += c.w + GAP; } } else { const x = d === 'right' ? boardW - PAD - CW.run : PAD; const order = d === 'left' ? [...idxs].reverse() : idxs; let y = PAD + CH + SIDE_GAP; for (const idx of order) { const c = cells[idx]!; c.x = x; c.y = y; c.w = CW.run; y += CH + GAP; } } }); // -- draw ------------------------------------------------------------------------------------- const rail = (x1: number, y: number, x2: number): string => { let o = `` + ``; const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9)); for (let i = 0; i <= n; i++) { const tx = x1 + ((x2 - x1) * i) / n; o += ``; } return o; }; // The same rail turned through ninety degrees, for the sides of the table. const railV = (x: number, y1: number, y2: number): string => { let o = `` + ``; const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9)); for (let i = 0; i <= n; i++) { const ty = y1 + ((y2 - y1) * i) / n; o += ``; } return o; }; let out = ``; // The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join // that crosses from one player's side to the next is drawn heavier and labelled: that boundary is // the Mainline card two districts share, and it is the thing a table needs to see. for (let i = 0; i + 1 < cells.length; i++) { const a = cells[i]!; const b = cells[i + 1]!; const sameRow = Math.abs(a.y - b.y) < 1; const sameCol = Math.abs(a.x - b.x) < 1; if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x); else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x); else if (sameCol) { // Stacked down one side of the table: still one straight run of track, not a turn. const top = Math.min(a.y + CH, b.y + CH); const bot = Math.max(a.y, b.y); out += railV(a.x + a.w / 2, top, bot); } else { // A turn between sides: an elbow, so the route is visibly continuous around the table. const ax = a.x + a.w / 2; const bx = b.x + b.w / 2; const ay = a.y + CH; const by = b.y; out += ``; } } cells.forEach((c) => { const full = c.cap !== null && c.trains.length >= c.cap; out += ``; out += ``; /** * WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather * than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)" * is spelled out because a colour alone cannot say which of four railroads is the reader's, * and that is the first thing anybody wants to know at a table they just sat down at. */ const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : ''; const suffix = c.owner?.isYou ? ' (you)' : ''; out += `${esc(c.label + suffix)}`; out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); if (c.sub) out += `${esc(c.sub)}`; // REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage. // The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them, // so a 60 card is crossed in one Stage and a slow train on a 30 takes three. const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0; for (let r = 0; r < c.regions; r++) { out += ``; } /** * ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars. * * It was drawn as a full consist here, matching the Office Area card, and reported as too large * and hard to read: a Mainline cell is 152px and a Running Track cell 78px, against a district * card's 166px, and a four-car train plus its engine has to squeeze into a fifth of that. The * Office card is where the consist is worth drawing, because that is where the switching * decisions are made and where there is room to read it. * * So this keeps the two things the Division map is actually for — where a train is and which way * it is going — and leaves the cars to the tooltip and to the district. */ c.trains.forEach((t, k) => { const cars = t.cars ?? []; const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length; const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`; /** * THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread. * * Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was * sized for the cards it holds, not for its trains — so two chips at a Station used to land at * x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards * either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because * the cell was sized for exactly that many slots (see `CHIP_W` above). */ const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0; const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0; const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12); // 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 tx = isOfficeRun ? c.x + 6 + slotW * (k + 0.5) : (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) + (inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4)); const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : ''; const stages = typeof t.stagesLeft === 'number' ? ` \u00b7 ${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 += `` + `` + `${esc(label)}`; out += ''; }); out += ''; }); // THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is // 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 += `` + ``; out += `west end · in and out`; } if (last && last !== first) { out += `` + ``; out += `east end · in and out`; } out += ''; return out; } /** * The Office Area as a map: one card per grid square, rails drawn edge to edge. * * THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the * DEAD CENTRE of every card, so abutting cards produce one unbroken line and a gap is visibly a * gap. Everything that leaves through the north or south edge does so at 45°, through the middle of * that edge — which is what lets two stacked cards read as one continuous diagonal. * * The card is 166 x 96, the printed sheet's 259:150 proportions. The aspect is not decoration: the * frog where a 45° leg meets the through track sits H/2 from the centre, so a squarer card pushes * it almost to the edge and a turnout's two routes stop being tellable apart. */ 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 }[] = [], /** * The columns the Limits signs stand in, drawn as the district's east and west edges. * * Omitted by the previews and by the replay viewers' single-card renders, which have no district * to bound. Where it is given, the boundary is drawn down the WHOLE canvas: track may not be laid * outside these columns at any row (§2.1), and two signs sitting on one row could not say that — * a player looking at open ground past a sign had no way to know nothing of his could ever go * there until the square failed to light up. */ limits: { west: number; east: number } | null = null, /** * Which train's consist is drawn at the rail, on an Office card holding more than one — the value * the "Which train are you switching?" picker already carries. Defaults to the first train on the * card, so every call site that has never heard of this feature keeps working unchanged. */ selectedTrayId: string | null = null, ): string { const W = 166; const H = 96; const PAD = 3; const RAIL = H / 2; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); /** * ONE COORDINATE SYSTEM, AND ONE CANVAS. * * Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in * afterwards. It computed its own origin over cells PLUS spots while this function sized itself * over cells alone, so the two disagreed the moment a legal square lay outside the played cards — * and every legal square that extends the district does. Measured on seed 555: three placements * offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The * list said three, the board showed two, and the third could not be clicked. * * Sizing over cells AND ghosts together is what makes that class of bug impossible. */ const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts]; if (all.length === 0) return ''; const rows = all.map((c) => c.row); const cols = all.map((c) => c.col); const r0 = Math.min(...rows); const r1 = Math.max(...rows); const c0 = Math.min(...cols); const c1 = Math.max(...cols); const width = (c1 - c0 + 1) * (W + PAD); const height = (r1 - r0 + 1) * (H + PAD) + 4; // Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest // and the district hangs beneath it, as the rules describe it. const px = (col: number): number => (col - c0) * (W + PAD); const py = (row: number): number => (r1 - row) * (H + PAD); const rail = (x1: number, y1: number, x2: number, y2: number): string => { const dx = x2 - x1; const dy = y2 - y1; const len = Math.hypot(dx, dy); const nx = (-dy / len) * 2.5; const ny = (dx / len) * 2.5; let out = `` + ``; const n = Math.max(2, Math.floor(len / 9)); for (let i = 0; i <= n; i++) { const tx = x1 + (dx * i) / n; const ty = y1 + (dy * i) / n; out += ``; } return out; }; /** * A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner. * * It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge), * so an abutting straight card's through-rail still reads as one unbroken line, and tangent to * exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous * diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are * cubic-Bezier control points chosen to hit those two tangents — see the call site. * * Sampled as a short polyline rather than left as one SVG path command, because the two parallel * rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at * each one — `rail()`'s straight-line version does the same sampling, just on a line. */ const curvedRail = ( p0: { x: number; y: number }, p1: { x: number; y: number }, p2: { x: number; y: number }, p3: { x: number; y: number }, ): string => { const at = (t: number): { x: number; y: number } => { const u = 1 - t; return { x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x, y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y, }; }; const tangentAt = (t: number): { x: number; y: number } => { const u = 1 - t; return { x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x), y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y), }; }; const N = 24; const left: string[] = []; const right: string[] = []; let ties = ''; for (let i = 0; i <= N; i++) { const t = i / N; const pt = at(t); const tan = tangentAt(t); const tlen = Math.hypot(tan.x, tan.y) || 1; const nx = (-tan.y / tlen) * 2.5; const ny = (tan.x / tlen) * 2.5; left.push(`${pt.x + nx} ${pt.y + ny}`); right.push(`${pt.x - nx} ${pt.y - ny}`); // Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar // length, not tied to `N` itself. if (i % 3 === 0) { ties += ``; } } return ( `` + `` + ties ); }; // Where each port meets the card edge. East and west sit at the rail height — the card's middle — // so a straight run stays straight across the whole row; north and south are centred on the edge. const port = (p: string): { x: number; y: number } => { if (p === 'e') return { x: W, y: RAIL }; if (p === 'w') return { x: 0, y: RAIL }; if (p === 'n') return { x: W / 2, y: 0 }; return { x: W / 2, y: H }; }; /** * ROOM FOR THE BOUNDARY. The Limits edge is drawn just outside the sign's own column, and the west * sign is usually the leftmost card on the canvas — so at x = −1.5 the line lands outside a viewBox * starting at 0 and is simply not there. The margin is added only when there is a boundary to draw. */ const margin = limits ? 3 : 0; let out = ``; for (const cell of cells) { const x = px(cell.col); const y = py(cell.row); const kind = cell.kind === 'office' ? 'office' : cell.kind === 'limits' ? 'lim' : cell.kind === 'facility' ? 'fac' : cell.kind === 'modifier' ? 'mod' : 'trk'; // An Office card's roster band (below) takes the y-band the BOARDS + ALIGHTS flow label used to // print in, so that explanation folds into the card's own tooltip instead of being drawn — it // prints identically on every Office of every tier and is already implied by the card name and // the Porter pill, so nothing is lost by reading it on the card rather than beside it. const isOfficeRoster = cell.adTracks !== null; const flowFold = isOfficeRoster && cell.facility ? '\n\nBOARDS + ALIGHTS: passengers only — no freight is handled here. They wait in a green ' + 'box, are boarded onto a coach by Porters, and arrive into a red one.' : ''; // Addressable so a caller can outline a square as a legal destination without re-rendering. // The explanation rides on the group as a tooltip rather than being printed: it is reference // detail, read once, and printing it costs the space the board itself needs. out += ``; out += ``; // The rails. A Modifier is not track and deliberately gets none — that is why nothing can be // routed through it, and the picture should say so. for (const link of cell.links) { const from = link[0] ?? 'e'; const to = link[1] ?? 'w'; const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : ''; if (vertical === '') { // The through track: edge to edge along the middle of the card. out += rail(port(from).x, port(from).y, port(to).x, port(to).y); } else { /** * A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the * old two-segment version bent at, so the underlying geometry (and the `frog` position used * elsewhere for the actual joining/matching rules) is unchanged; only the picture is. * * The frog is `H/2` from the centre because a 45° run climbing half the card's height * travels half its height sideways. The control points sit halfway from each endpoint to the * frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an * abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching * the card above or below) — see `curvedRail`. */ const side = vertical === from ? to : from; const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL }; const start = port(side); const edge = port(vertical); const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y }; const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) }; out += curvedRail(start, c1, c2, edge); } } out += `${esc(cell.label)}`; // X,Y — east/west then north/south, not the internal row/col storage order. out += `${cell.col},${cell.row}`; /** * THE ROSTER, one chip per A/D track — not one per train. * * The Office is the one square where more than one train may legally stand at once (its A/D * tracks, §2.1), and the old single `cell.train` field only ever showed whichever tray * `s.trays` happened to yield first: a second train at a busy Station was counted in the old * A/D pips and never drawn at all. `adTracks` of them, always, whether or not a train is * holding there — the cell is drawn for the tracks it HAS, so a free one reads a dashed, * dimmed chip saying "free" rather than simply vanishing. * * Selected train's chip is lit the same amber the crew strip and action buttons already use, * and it is the one whose consist is drawn at the rail below. Every occupied chip carries * `data-crew`, so the play page can wire a click to switch which train that is. * * COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null` * branch below, so it was only ever assigned on the Office card — a train standing anywhere else * in the district (which is everywhere it stands while actually being switched) got no chip at * all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED: * dropping a car at the Freight House made "the train vanish off the face of the earth" — it had * simply left the one square this code drew a crew on. The Office is the only square that can hold * more than one train, so `cell.trains[0]` is exactly right everywhere else too. */ const selectedTrain: CellView['trains'][number] | null = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null; if (cell.adTracks !== null) { const adTracks = cell.adTracks; const gaps = Math.max(0, adTracks - 1); const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks); for (let i = 0; i < adTracks; i++) { const train = cell.trains[i] ?? null; const cx = 6 + i * (chipW + 4); if (!train) { out += `` + `free`; continue; } const arrow = train.facing === 'w' ? '◀' : '▶'; const chipLabel = chipW >= 40 ? `${train.label} ${arrow}${train.cars.length}` : `${train.label}${arrow}`; const consistWords = train.cars.length === 0 ? 'no cars' : train.cars.join(', '); const on = train.trayId === selectedTrain?.trayId; out += `` + `` + `${esc(chipLabel)}`; } } /** * THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS. * * §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way * entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car * belongs beside WORK. * * This drew green, then MEN|AT|WORK, then red — putting the red box at the far right, which is * exactly where the car is. An unload therefore appeared to run backwards across the whole row * and then land on the end it had just come from. Reported after unloading at a warehouse: * "it went W, A, M, and then to the red box at the far right". * * Only the boxes an industry actually uses are drawn, and the arrows point the way its freight * really moves. */ const f = cell.facility; if (f) { const cellW = 13; const box = (bx: number, by: number, filled: boolean, cls: string, label?: string): string => `` + (label ? `${esc(label)}` : ''); let bx = 7; const by = H - 42; /** * The MEN | AT | WORK sign is a FREIGHT fitting, so a Passenger Facility has none — `maw` comes * back empty for one. The arrows below point into and out of that sign, so they go with it: * without the sign they were chevrons aimed at nothing. A Depot keeps its green and red boxes, * because passengers really do wait in one and arrive into the other. */ const hasSign = f.maw.length > 0; // Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows // point opposite ways. An industry that only receives has no green boxes at all. if (f.allowsOut) { for (let i = 0; i < Math.max(1, f.greenCap); i++) { out += box(bx, by, i < f.green.length, 'bs-green'); bx += cellW + 2; } if (hasSign) { out += `›`; bx += 9; } } if (f.allowsIn) { for (let i = 0; i < Math.max(1, f.redCap); i++) { out += box(bx, by, i < f.red.length, 'bs-red'); bx += cellW + 2; } if (hasSign) { out += `‹`; bx += 9; } } // MEN | AT | WORK — one physical sign, worked left to right loading and right to left // unloading. The spotted car sits past WORK, standing on the card itself. for (let i = 0; i < f.maw.length; i++) { out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]); bx += cellW + 2; } if (hasSign) { out += `${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}`; } /** * 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. */ // A Passenger Facility is not an industry and must not borrow an industry's word for it: a // Depot read "SHIPS + RECEIVES", which says freight. Passengers board and alight instead. const passenger = f.kind === 'passenger'; const flow = passenger ? 'BOARDS + ALIGHTS' : f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES'; const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in'; // An Office card's roster band now occupies this y-band; its tooltip already folded into the // card's own data-tip above, so the label is not printed twice. if (!isOfficeRoster) { out += `${esc(flow)}`; } /** * 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 += `` + `` + `${esc(isPorter ? 'P' : 'L')} ${esc(crew)}`; } } /** * THE CARS STANDING HERE — NO PRINTED SIDING BEHIND THEM. * * An industry card used to draw one empty square per unit of its capacity, which read as a * siding of a particular length printed on the card. No industry card prints one. Worse, the * length came from the box count, so a one-box industry drew a single square and looked like it * could only ever hold one car — which is exactly what the engine was wrongly enforcing. * * Every card, industry or not, now draws what is actually standing on it and nothing more. Four * is the ceiling everywhere (`MAX_CONSIST`), so the row cannot overflow the card. * * The 4 is written out rather than imported: this function's SOURCE is serialized into the * standalone replay page, where module imports are not in scope. Referencing the constant threw * `MAX_CONSIST is not defined` in the rendered HTML while typechecking perfectly here. */ /** * WHICH SIDE OF THE ENGINE THE CUT IS ON. * * Reported from play: "right after dropping my cars I need to be able to see if those cars are * ahead or behind the train." They were drawn as one strip starting at the left edge whether a * train was on the card or not, so the picture could not say. * * `cars` runs WEST TO EAST and `standingWest` is how many of them are west of the train, so the * row is split there: the west cars stay left-aligned, the east cars are pushed over to the * right-hand end, and the gap between them is where the engine is standing. Whether that gap * means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than * leaving the player to work out from the compass. */ const hasTrain = cell.trains.length > 0; const split = hasTrain ? Math.max(0, Math.min(cell.cars.length, cell.standingWest)) : 0; const shown = Math.min(cell.cars.length, 4); for (let i = 0; i < shown; i++) { // With no train on the card there is no gap to leave, so the whole cut stays left-aligned. const westOfEngine = hasTrain ? i < split : true; const side = !selectedTrain ? 'standing here' : (selectedTrain.facing === 'e') === westOfEngine ? 'standing BEHIND the engine — it would couple onto the tail backing up' : 'standing AHEAD of the engine — it would couple onto the nose pulling forward'; const carTip = `${cell.cars[i]} ${side}`; /** * WHICH CAR IS STANDING HERE, not just that one is. * * The switching game is entirely about getting the RIGHT car to the right industry, so "a car * is here" is not enough — a Grocer's Warehouse wants a boxcar or a reefer and nothing else * will do. This printed the first three characters of the label, which for "loaded hopper" and * "loaded boxcar" alike is `loa`: every car on the board looked the same. * * Type by colour and by three letters, loaded by a filled slot and empty by an outlined one — * the same distinction the printed game makes with coloured tokens. */ const car = cell.cars[i] ?? ''; const type = /caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box' : /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : ''; // 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 = ` 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. */ // West cars run rightwards from the left edge; east cars run leftwards from the right edge, so // the engine's place in the row is the gap left between them. const sx = westOfEngine ? 8 + i * 30 : W - 34 - (shown - 1 - i) * 30; out += ``; out += `${esc(type)}`; } if (cell.enhancements.length > 0) { // ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the // load pipeline below and the through track — and with the rail now down the middle of the // card, the old y = H-46 baseline printed the label straight along the rail itself. // Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word // on the card with nothing to say what it did, or that it does not do it yet. out += `${esc(cell.enhancements.join(' · '))}`; } if (selectedTrain) { /** * THE SELECTED TRAIN, DRAWN AS IT SITS IN THE TRAY. * * The badge used to be a name and nothing more, which makes the switching game unplayable: * every decision is about car ORDER — which car comes off next, which end a cut couples onto — * and none of it was on the board. Now the engine and its cars are drawn in sequence, nose * first, with an arrow for which way the engine points, so "set out the caboose off the back" * is a thing you can see before you choose it. Where an Office holds more than one train, only * this one is drawn here — the rest are the roster chips above, each with its own tooltip. */ const t = selectedTrain; 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) || /caboose/.test(c) ? 'ld' : 'mt', what: c, })); const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; 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 is drawn east-west like every other train, because `facing` is * now always east or west (`railFacingOf`). It used to keep its compass port and draw \u25b2 or \u25bc * with the consist pinned nose-left, which meant the one thing the picture is for \u2014 which end * the engine is on \u2014 flipped its convention the moment a crew turned a corner. Playtested and * reported as more confusing than a strip drawn the same way every time. */ const laid = t.facing === 'e' ? [...items].reverse() : items; const cw = 17; const tw = Math.min(W - 8, laid.length * cw + 30); const tx = W / 2 - tw / 2; const facingWord = t.facing === 'e' ? 'east' : 'west'; const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', '); out += ``; out += `${esc(t.label)}`; laid.forEach((it, n) => { const bx = tx + 28 + n * cw; out += `` + `${esc(it.label)}`; }); out += ''; } out += ''; } // A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame. for (const g of ghosts) { out += `` + `` + `place here` + `${g.col},${g.row}` + ``; } /** * 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 += `` + `` + `${esc(l.label)}`; } /** * THE LIMITS, AS THE EDGE OF THE BUILDABLE DISTRICT. * * Drawn just OUTSIDE the sign's own column, because the sign stands on the boundary rather than * beyond it: a siding may run under the sign, and the line has to agree with the rule or it * teaches the wrong one. It moves outward on its own as the Running Track is extended, which is * the reward for extending made visible. */ if (limits) { const edge = (x: number, side: string): string => `` + `LIMITS`; out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e'); } // Mark the Running Track so the spine of the district is unmistakable. out += `RUNNING TRACK`; out += ''; return out; } /** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */ export const BOARD_CSS = ` .bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible} .bs-rail{stroke:#cfd6e0;stroke-width:1.6} .bs-tie{stroke:#8d97a5;stroke-width:1.1} .bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4} .bs-open .bs-tie{stroke:#59626f} .bs-card{fill:#161b21;stroke:#39424e;stroke-width:1} .bs-card.bs-run{fill:#1d232c} .bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2} .bs-lim{fill:#241d1d;stroke:#8a5a5a} .bs-fac{fill:#201c26;stroke:#7a5f9a} .bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3} .bs-sec{fill:#161b21;stroke:#39424e} .bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} .bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2} /* THE DIVISION, drawn as the route rather than as a row of boxes. Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains come from and go to, a Mainline card is shared ground between two districts, and a Running Track card belongs to one player's Office Area. */ .bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1} .bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} .bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6} .bs-drun rect{fill:#1b2534;stroke:#4d6fa8} .bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2} /* The elbow where the route turns from one player's side of the table to the next. Drawn heavier than a plain join because that turn is the boundary two districts share. */ .bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round} /* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point and leave at the other, and a seated layout must not be read as a ring. */ .bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} .bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} /* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the 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{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-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 engine is the one that carries the arrow — which is what makes "reverse" mean something. */ .bs-tcar{stroke:#3a2f14;stroke-width:.8} .bs-tcar.bs-t-ld{fill:#e8dcc0} .bs-tcar.bs-t-mt{fill:#6b5a2e} .bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4} .bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700} /* One chip per A/D track, not one per train — a full Office is why the next arrival collides, so the count is worth seeing without hovering, and a free track reads "free" rather than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons use. */ .bs-adchip rect{fill:#20262e;stroke:#4a5361;stroke-width:1} .bs-adchip text{fill:#cfd6e0;font:600 8px ui-monospace,monospace} .bs-adchip.on rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.4} .bs-adchip.on text{fill:#fff4d6} .bs-adchip.mt rect{fill:none;stroke:#3d4650;stroke-dasharray:3 2} .bs-adchip.mt text{fill:#6b7480} .bs-adchip[data-crew]{cursor:pointer} .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)} .bs-wb.bs-maw{stroke:#8a6d1f} .bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)} .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} .bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-name.bs-you{fill:#5aa9e6} /* Their move — wins over .bs-you when both apply, because whose turn it is changes every few seconds and which railroad is yours never does. NO WEIGHT BUMP. This was 700 and the name came out fuzzy to the point of being unreadable: the base is already 600, so at 11px a monospace face has to be synthesised the rest of the way, and the extra ink lands as blur rather than as weight. Amber against #e6e9ee is the distinction; it does not need help. */ .bs-name.bs-turn{fill:#f0b64a} .bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace} .bs-cap.bs-full{fill:#e0a060;font-weight:600} .bs-grade{fill:#e08060;font:10px ui-monospace,monospace} .bs-mod{font:10px ui-monospace,monospace} text.bs-mod{fill:#c8a04a} .bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace} .bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em} /* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */ .bs-limitline{stroke:#5f6b7a;stroke-width:1.5;stroke-dasharray:3 5;opacity:.7} .bs-limitlab{fill:#5f6b7a;font:600 8px ui-monospace,monospace;letter-spacing:.12em} .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} /* THE SQUARE THE ACTION UNDER THE CURSOR MEANS. White, and brighter than anything else the board marks, because it answers a question being asked RIGHT NOW rather than describing a standing state — and it has to win when it lands on a square that is already legal, focused or blocked. */ g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none} g[data-cell].bs-point .bs-card{fill:#2b3b52} /* 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 — marked on the crew STRIP rather than the whole card, because the Office is the one square where a second train may share that card: outlining the card would claim it belongs to both. */ g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5} .bs-crew.bs-from rect:first-of-type{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} `;