Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass
This commit is contained in:
+283
-82
@@ -29,82 +29,274 @@ export type BoardTrain = { label: string; consist: string[] };
|
||||
* separated by thin seams, capacity legible because the lines can be counted.
|
||||
*/
|
||||
export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const COL = 190;
|
||||
const TOP = 34;
|
||||
const LANE = 30;
|
||||
const width = Math.max(1, nodes.length) * COL;
|
||||
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
|
||||
const height = TOP + maxCap * LANE + 54;
|
||||
/**
|
||||
* 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;
|
||||
const PAD = 26;
|
||||
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[]; region?: number; direction?: string }[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
owner: 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<Cell, 'x' | 'y'>): 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.
|
||||
trains: isOffice ? [...rc.trains, ...ad] : 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'}`,
|
||||
owner: n.owner ?? 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 — trains enter and leave the Division here, and queue without limit'
|
||||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
|
||||
owner: 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 out =
|
||||
let o =
|
||||
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
|
||||
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
|
||||
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;
|
||||
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
||||
o += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
||||
}
|
||||
return out;
|
||||
return o;
|
||||
};
|
||||
|
||||
const chip = (x: number, y: number, t: BoardTrain): string => {
|
||||
const w = 30 + Math.min(t.consist.length, 4) * 9;
|
||||
return (
|
||||
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
|
||||
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
|
||||
);
|
||||
// The same rail turned through ninety degrees, for the sides of the table.
|
||||
const railV = (x: number, y1: number, y2: number): string => {
|
||||
let o =
|
||||
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
|
||||
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
|
||||
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 += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
|
||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
||||
nodes.forEach((node, i) => {
|
||||
const x0 = i * COL;
|
||||
const x1 = x0 + COL;
|
||||
const cap = node.capacity;
|
||||
const lanes = cap ?? 3;
|
||||
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
|
||||
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
|
||||
|
||||
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
|
||||
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
|
||||
if (node.gradeUp) {
|
||||
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
|
||||
// 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 += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
cells.forEach((c) => {
|
||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
|
||||
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
||||
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||
|
||||
// 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 += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||||
}
|
||||
|
||||
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
|
||||
// and labelled, rather than pretending to a number they do not have.
|
||||
for (let k = 0; k < lanes; k++) {
|
||||
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
|
||||
}
|
||||
|
||||
const here = node.trains.flat();
|
||||
here.forEach((t, k) => {
|
||||
const lane = Math.min(k, lanes - 1);
|
||||
const across = here.length > lanes ? (k - lane) * 34 : 0;
|
||||
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
|
||||
c.trains.forEach((t, k) => {
|
||||
const w = 30 + Math.min(t.consist.length, 4) * 8;
|
||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
|
||||
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
||||
const tx = slotX + spread;
|
||||
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
|
||||
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
|
||||
}">` +
|
||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
|
||||
});
|
||||
|
||||
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
|
||||
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
|
||||
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
|
||||
if (node.modifiers.length > 0) {
|
||||
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
|
||||
}
|
||||
out += '</g>';
|
||||
});
|
||||
|
||||
// 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];
|
||||
if (first) {
|
||||
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||||
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||||
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</text>`;
|
||||
}
|
||||
if (last && last !== first) {
|
||||
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||||
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||||
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">trains leave</text>`;
|
||||
}
|
||||
|
||||
out += '</svg>';
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
||||
*
|
||||
* The through rail sits at a constant height on every card — the alignment the printed cards use —
|
||||
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
||||
*/
|
||||
export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
export function officeSvg(
|
||||
cells: CellView[],
|
||||
runningRow: number,
|
||||
ghosts: { row: number; col: number }[] = [],
|
||||
): string {
|
||||
const W = 132;
|
||||
const H = 96;
|
||||
const PAD = 3;
|
||||
@@ -113,9 +305,22 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
||||
const rows = cells.map((c) => c.row);
|
||||
const cols = cells.map((c) => c.col);
|
||||
/**
|
||||
* 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 '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
||||
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);
|
||||
@@ -254,44 +459,21 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
out += '</g>';
|
||||
}
|
||||
|
||||
// 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 += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
|
||||
// Mark the Running Track so the spine of the district is unmistakable.
|
||||
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||
out += '</svg>';
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
|
||||
* placeholder — the commonest placement of all is onto a blank square, and without this the
|
||||
* highlight would have nothing to attach to.
|
||||
*/
|
||||
export function ghostSvg(
|
||||
spots: { row: number; col: number }[],
|
||||
cells: CellView[],
|
||||
runningRow: number,
|
||||
): string {
|
||||
const W = 132;
|
||||
const H = 96;
|
||||
const PAD = 3;
|
||||
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
|
||||
if (all.length === 0) return '';
|
||||
const r1 = Math.max(...all.map((c) => c.row));
|
||||
const c0 = Math.min(...all.map((c) => c.col));
|
||||
let out = '';
|
||||
for (const s of spots) {
|
||||
const x = (s.col - c0) * (W + PAD);
|
||||
const y = (r1 - s.row) * (H + PAD);
|
||||
out +=
|
||||
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
void runningRow;
|
||||
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}
|
||||
@@ -308,6 +490,25 @@ export const BOARD_CSS = `
|
||||
.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{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
|
||||
+87
-9
@@ -36,7 +36,7 @@ export { cardName, describeIntent, snapshot } from './view.ts';
|
||||
import type { Frame } from './view.ts';
|
||||
import type { Decision } from './view.ts';
|
||||
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame shape — only what the viewer draws
|
||||
@@ -147,19 +147,94 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
*
|
||||
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
||||
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
||||
* the field.
|
||||
* the field, and rebuilds each cell from the dictionaries below.
|
||||
*
|
||||
* MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within
|
||||
* it the cost was not where the TODO said. Carrying `links` forward — the item this started as —
|
||||
* would have saved 5% of cells. What actually costs:
|
||||
*
|
||||
* what 32% of cells long prose, and the SAME prose on every turnout in the district
|
||||
* facility 24% of cells the identical object is already in the frame's `facilities`
|
||||
* identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
||||
*
|
||||
* So each of the three is interned instead. A card's identity and its description are written once
|
||||
* for the whole recording and referenced by integer, and a cell points at its facility rather than
|
||||
* carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars
|
||||
* standing there, the crew, and any Enhancement laid on it.
|
||||
*/
|
||||
function compress(frames: Frame[]): unknown[] {
|
||||
type Packed = {
|
||||
/** Static per square, written once: row, col, kind, label, running, links. */
|
||||
cards: unknown[];
|
||||
/** Distinct `what` descriptions, written once. */
|
||||
whats: string[];
|
||||
frames: unknown[];
|
||||
};
|
||||
|
||||
export function compress(frames: Frame[]): Packed {
|
||||
const cards: unknown[] = [];
|
||||
const cardAt = new Map<string, number>();
|
||||
const whats: string[] = [];
|
||||
const whatAt = new Map<string, number>();
|
||||
|
||||
const packed = frames.map((f) => {
|
||||
const cells = f.cells.map((c) => {
|
||||
const idKey = JSON.stringify([c.row, c.col, c.kind, c.label, c.running, c.links]);
|
||||
let ci = cardAt.get(idKey);
|
||||
if (ci === undefined) {
|
||||
ci = cards.length;
|
||||
cards.push([c.row, c.col, c.kind, c.label, c.running, c.links]);
|
||||
cardAt.set(idKey, ci);
|
||||
}
|
||||
let wi = whatAt.get(c.what);
|
||||
if (wi === undefined) {
|
||||
wi = whats.length;
|
||||
whats.push(c.what);
|
||||
whatAt.set(c.what, wi);
|
||||
}
|
||||
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
||||
// cell and the frame's `facilities`, so this always resolves.
|
||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
|
||||
const keys = ['cells', 'facilities', 'division'] as const;
|
||||
let prev: Record<string, string> = {};
|
||||
return frames.map((f, i) => {
|
||||
const out: Record<string, unknown> = { ...f };
|
||||
const out = packed.map((f, i) => {
|
||||
const o: Record<string, unknown> = { ...f };
|
||||
for (const k of keys) {
|
||||
const json = JSON.stringify(f[k]);
|
||||
if (i > 0 && prev[k] === json) out[k] = null;
|
||||
if (i > 0 && prev[k] === json) o[k] = null;
|
||||
prev[k] = json;
|
||||
}
|
||||
return out;
|
||||
return o;
|
||||
});
|
||||
|
||||
return { cards, whats, frames: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild whole CellViews from the compact wire rows.
|
||||
*
|
||||
* Exported and then emitted into the page by `toString()`, exactly as the two board renderers are:
|
||||
* a second copy of this in the page's inline script could drift from the packing above, and the
|
||||
* failure would be a board that draws the wrong cards rather than an error.
|
||||
*/
|
||||
export function rehydrateCells(
|
||||
packed: unknown[],
|
||||
cards: unknown[],
|
||||
whats: string[],
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
return {
|
||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
||||
facility: p[5] < 0 ? null : facs[p[5]],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,7 +397,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
const divisionSvg = ${divisionSvg.toString()};
|
||||
const officeSvg = ${officeSvg.toString()};
|
||||
|
||||
const FRAMES = ${JSON.stringify(compress(rec.frames))};
|
||||
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
||||
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
|
||||
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
||||
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
||||
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
||||
@@ -330,6 +406,8 @@ function carry(i, key) {
|
||||
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
||||
return [];
|
||||
}
|
||||
const rehydrateCells = ${rehydrateCells.toString()};
|
||||
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
|
||||
let i = 0, timer = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
@@ -376,7 +454,7 @@ function render() {
|
||||
$('fno').textContent = i;
|
||||
$('scrub').value = i;
|
||||
|
||||
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
|
||||
+122
-5
@@ -17,6 +17,7 @@ import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
industryProfile,
|
||||
@@ -86,7 +87,35 @@ export type FacilityView = {
|
||||
jammed: boolean;
|
||||
};
|
||||
|
||||
export type TrainChip = { label: string; consist: string[] };
|
||||
export type TrainChip = {
|
||||
label: string;
|
||||
consist: string[];
|
||||
/**
|
||||
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
|
||||
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
|
||||
* Moves rather than by Stages, so it occupies a card outright.
|
||||
*/
|
||||
region?: number;
|
||||
direction?: string;
|
||||
};
|
||||
/**
|
||||
* One card of a player's Running Track, as the Division sees it.
|
||||
*
|
||||
* The through route between the Limits IS the Running Track — everything hanging beneath it is
|
||||
* secondary, and a train crossing the Division never touches it. So the Division map expands an
|
||||
* Office into this row and leaves sidings, industries and the load pipeline to the district view.
|
||||
*/
|
||||
export type RunningCardView = {
|
||||
row: number;
|
||||
col: number;
|
||||
kind: string;
|
||||
label: string;
|
||||
/** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */
|
||||
links: string[];
|
||||
/** Trains standing ON this card of the Running Track. */
|
||||
trains: TrainChip[];
|
||||
};
|
||||
|
||||
export type DivisionView = {
|
||||
kind: string;
|
||||
label: string;
|
||||
@@ -104,6 +133,19 @@ export type DivisionView = {
|
||||
* on that grade has no visible meaning.
|
||||
*/
|
||||
gradeUp: string | null;
|
||||
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
||||
regions?: number;
|
||||
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||
running?: RunningCardView[];
|
||||
/** Office nodes only: whose district this is. */
|
||||
owner?: number;
|
||||
/**
|
||||
* Office nodes only: crews working BELOW the Running Track.
|
||||
*
|
||||
* A crew down a siding has no position on the through route — projecting one would be a lie — so
|
||||
* it is reported against the district as a whole and drawn exactly where it is in the zoomed view.
|
||||
*/
|
||||
switching?: TrainChip[];
|
||||
};
|
||||
|
||||
export type Frame = {
|
||||
@@ -431,12 +473,43 @@ export function snapshot(
|
||||
// with how long each still has to run.
|
||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
||||
/**
|
||||
* WHERE ON THE CARD, from what the crossing already cost.
|
||||
*
|
||||
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
|
||||
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
|
||||
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
|
||||
* point as the thing that varies, exactly as the cards do:
|
||||
*
|
||||
* entry = REGIONS - stagesTotal position = entry + elapsed
|
||||
*
|
||||
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
|
||||
* what "Start positions further along the card" means on the printed art. A 30 card is two
|
||||
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
|
||||
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
|
||||
* card's distance is fixed and the train is simply slow across it.
|
||||
*/
|
||||
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
|
||||
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
|
||||
// two regions, so it notionally starts before the card and spends the extra Stage getting
|
||||
// to the first region. Clamping only the final position keeps that Stage at the START,
|
||||
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
|
||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||
};
|
||||
return {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
regions: REGIONS_PER_MAINLINE_CARD,
|
||||
trains: [n.transits.map((t) => {
|
||||
const chip = trainChip(s, t.tray);
|
||||
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
|
||||
return {
|
||||
...chip,
|
||||
label: `${chip.label} (${t.stagesRemaining})`,
|
||||
region: place(t),
|
||||
direction: t.direction,
|
||||
};
|
||||
})],
|
||||
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
|
||||
modifiers: [
|
||||
@@ -446,13 +519,57 @@ export function snapshot(
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
};
|
||||
}
|
||||
const oa = areaOf(s, n.owner);
|
||||
|
||||
// Where every crew in this district actually is: on a Running Track card, or below it.
|
||||
const onRunning = new Map<string, TrainChip[]>();
|
||||
const below: TrainChip[] = [];
|
||||
for (const [id, tray] of s.trays) {
|
||||
const pos = tray.position;
|
||||
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
|
||||
const c = trainChip(s, id);
|
||||
if (pos.coord.row === oa.runningRow) {
|
||||
const k = `${pos.coord.row},${pos.coord.col}`;
|
||||
onRunning.set(k, [...(onRunning.get(k) ?? []), c]);
|
||||
} else {
|
||||
below.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the
|
||||
// signs are the ends of the through route rather than obstacles on it.
|
||||
const running: RunningCardView[] = [];
|
||||
for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) {
|
||||
const card = oa.grid.get(`${oa.runningRow},${col}`);
|
||||
if (!card) continue;
|
||||
const g = card.geometry;
|
||||
let label: string;
|
||||
if (g.kind === 'office') label = officeProfile(oa.tier).name;
|
||||
else if (g.kind === 'limits') label = 'Limits';
|
||||
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
|
||||
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
|
||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||
else label = geometryLabel(g.geometry);
|
||||
running.push({
|
||||
row: oa.runningRow,
|
||||
col,
|
||||
kind: g.kind,
|
||||
label,
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
trains: onRunning.get(`${oa.runningRow},${col}`) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'office',
|
||||
label: officeProfile(areaOf(s, n.owner).tier).name,
|
||||
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
|
||||
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
|
||||
label: officeProfile(oa.tier).name,
|
||||
trains: [oa.adOccupancy.map((id) => trainChip(s, id))],
|
||||
capacity: officeProfile(oa.tier).adTracks,
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
owner: n.owner,
|
||||
running,
|
||||
switching: below,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user