/** * 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. */ .bs-name.bs-turn{fill:#f0b64a;font-weight:700} .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} `;