user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.

This commit is contained in:
Jesse
2026-08-05 19:18:26 -04:00
parent 1c321d4cea
commit 543584a2ca
20 changed files with 1851 additions and 320 deletions
+63 -5
View File
@@ -96,8 +96,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
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,
/**
* 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'}`,
owner: n.owner ?? null,
@@ -420,6 +426,20 @@ export function officeSvg(
}
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
/**
* 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 += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
}
}
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
@@ -503,9 +523,35 @@ export function officeSvg(
// card, the old y = H-46 baseline printed the label straight along the rail itself.
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.tray) {
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
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 }[] = t.cars.map((c) => ({
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
kind: /^loaded/.test(c) ? 'ld' : 'mt',
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
const cw = 17;
const tw = Math.min(W - 8, items.length * cw + 30);
const tx = W / 2 - tw / 2;
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
items.forEach((it, n) => {
const bx = tx + 28 + n * cw;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
});
out += '</g>';
}
out += '</g>';
}
@@ -579,6 +625,18 @@ export const BOARD_CSS = `
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.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}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}