/** * 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. */ export function divisionSvg(nodes: DivisionView[]): 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; /** * 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'; 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; /** 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(); for (const rc of n.running ?? []) { const isOffice = rc.kind === 'office'; push({ kind: 'run', label: rc.label, sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '', /** * 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: `${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: 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(' · ')}` : ''}`, 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 += ``; out += `${esc(c.label)}`; 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}`; const w = 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 slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2; const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4); const tx = slotX + spread; const dir = t.direction === 'west' ? ' \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 }[] = [], ): 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; }; // 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 }; }; 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'; // 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 the card prints it: along the centre line from the east or west edge * to the FROG, then out at exactly 45° through the middle of the north or south edge. * * The frog is `H/2` from the centre because a 45° run climbing half the card's height * travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2) * — below the rail on the assumption everything diverged downward — which drew a leg * reaching north as a hook that dropped past the rail and came back up, and drew nothing at * any angle the matching rule cares about. */ const side = vertical === from ? to : from; const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL }; const edge = port(vertical); out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y); } } out += `${esc(cell.label)}`; /** * A/D TRACKS, as pips. * * The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that * decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it * belongs to. There is no room to draw more rails, and the count is what matters: one pip per * track, filled for taken. */ if (cell.ad) { for (let i = 0; i < cell.ad.of; i++) { out += ``; } } out += `${cell.row},${cell.col}`; /** * 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, on the siding drawn below. 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'; 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)}`; } } // Standing room, drawn as the printed squares: filled means occupied. // // `Math.max(1, …)` applies only to an industry, whose track is at least one car long. A Passenger // Facility has NO industry track — passengers board straight off the platform — so a Depot was // being drawn a siding slot it cannot spot a car on. const spots = cell.facility ? cell.facility.kind === 'passenger' ? cell.facility.trackCap : Math.max(1, cell.facility.trackCap) : cell.cars.length; for (let i = 0; i < Math.min(spots, 4); i++) { const filled = i < cell.cars.length; const carTip = filled ? `${cell.cars[i]} standing here` : 'an empty space on this track — a car may be spotted here'; /** * WHICH CAR IS STANDING HERE, not just that one is. * * 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 = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : ''; /** * LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT. * * REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train * tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a * generic blue fill first and only a LOADED car overrode it with its type colour, so empty * read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was. * * Now the card matches the tray — solid face for loaded, hollow for empty — and every slot * carries the car's own words as a tooltip, so "loaded hopper" is one hover away. */ out += ``; if (filled) { 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 (cell.train) { /** * THE 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. */ const t = cell.train; 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.row},${g.col}` + ``; } /** * 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)}`; } // 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 pip per A/D track, filled when a train is holding on it. A full Office is why the next arrival collides, so this is the count worth seeing without hovering. */ .bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2} .bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee} .bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf} .bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace} /* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face, light on an empty one's hollow. */ .bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace} .bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700} .bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1} .bs-wb.bs-green{stroke:#3f7a52} .bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)} .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-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} .bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4} .bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace} /* The caption on a legal square that already carries a card. Same blue as the ghost squares and the same words in kind, because it is the same question: you have picked a card, now pick where. */ .bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1} .bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em} .bs-legalcap{pointer-events:none} .bs-ghost,g[data-cell].bs-legal{cursor:pointer} g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5} g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246} /* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable card reads the same way an action button does. bs-from is where it is standing now. */ g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5} g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3} /* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that is merely uninteresting. The reason rides on the card's own tooltip. */ g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3} /* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber, because it IS reachable, just not a place to stop. */ g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4} g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help} `;