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
+14 -1
View File
@@ -719,7 +719,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
*
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
* every move — so one reverse move silently spun the train about. Everything then read
* "forward" again, and a run-around became pointless: you could change ends for free by
* backing up twice.
*
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
* Backing up it trails, still pointing the way it came, which is the port it arrived
* through. Both hold around a curve, where the compass heading changes but the engine's
* relationship to its train does not.
*/
facing: i.reverse ? dest.entry : opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
+14 -2
View File
@@ -13,6 +13,7 @@
*/
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -140,8 +141,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
const kind = s.cards.get(cardId)?.kind;
// Enhancements ATTACH to a card already down, so their candidates are the occupied cells.
const targets = kind?.kind === 'enhancement' ? attachments : placements;
/**
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
* the placement meant another, so the card appeared to be playable all over the Office Area.
*/
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
const targets = onMainline
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
: kind?.kind === 'enhancement'
? attachments
: placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
+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}
+4
View File
@@ -547,6 +547,10 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
const out: string[] = [];
for (const e of events) {
if (e.type === 'trainMadeUp') out.push('train');
// Coupling and setting out are what a switching move IS, and both were silent. A car leaving the
// board with only a line of history to say where it went is the thing that most needs a noise.
if (e.type === 'carsCoupled') out.push('couple');
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+15 -8
View File
@@ -37,6 +37,7 @@ import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts';
import { playCue } from '../web/sound.ts';
// ---------------------------------------------------------------------------
@@ -201,7 +202,9 @@ export function compress(frames: Frame[]): Packed {
// 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];
// `train` rides whole rather than being interned: it changes almost every frame, so a table
// of them would be as long as the frames are and buy nothing.
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
});
return { ...f, cells } as unknown as Frame;
});
@@ -235,12 +238,14 @@ export function rehydrateCells(
facs: unknown[],
): unknown[] {
return packed.map((row) => {
const p = row as [number, number, string[], string | null, string[], number];
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
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]],
train: p[6] ?? null,
ad: p[7] ?? null,
};
});
}
@@ -253,6 +258,7 @@ export function renderHtml(rec: Recording): string {
<title>Station Master — replay seed ${rec.seed}</title>
<style>
${BOARD_CSS}
${TURNCHART_CSS}
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
@@ -344,15 +350,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="big" id="when">—</span>
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span class="dim">fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
<!-- The live game's turn chart, from the same renderer, so where you are in the Day looks the same
whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="wrap">
<div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
@@ -414,6 +422,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
const turnChartHtml = ${turnChartHtml.toString()};
const playCue = ${playCue.toString()};
const PACKED = ${JSON.stringify(compress(rec.frames))};
@@ -458,9 +467,7 @@ function boxes(items, cap, cls) {
function render() {
const f = FRAMES[i];
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
$('phase').textContent = f.phase;
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
+126
View File
@@ -0,0 +1,126 @@
/**
* The turn chart — where in the Day we are, and which of the five phases is running.
*
* SELF-CONTAINED ON PURPOSE, exactly like the two board renderers in `board-svg.ts`. The playable
* app and the site's replay viewer import it normally; the standalone replay is a single HTML file
* with an inline script and cannot import anything, so it embeds this via `Function.toString()`.
* That keeps ONE implementation across all three screens.
*
* It lived only in `main.ts` before, so a replay showed the Day and the phase as plain text while
* the live game showed the chart — and the violet "you are here" highlight, which is the single
* most-glanced-at thing on the page, appeared on one screen out of three.
*/
/** What a chart needs from a frame. A `Frame` satisfies this; so does a replay's rehydrated one. */
export type TurnChartFrame = {
day: number;
stage: number;
clock: string;
phase: string;
phaseKey: string;
actor: number | null;
};
/**
* The five phases of a Stage, in order, each with what actually happens in it.
*
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
* does, since the recovered sheet does not spell them out.
*/
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: "End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent's hat passes to the next player.",
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
const chips = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
const who = actorName ?? 'nobody — the Division is running itself';
return (
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
`<ol class="tc-phases">${chips}</ol>`
);
}
/**
* Styling, shared so the three screens cannot drift.
*
* VIOLET, not the amber used for actions. The palette already spends amber on "you can click this",
* blue on "this is a legal square or a link", green on good and red on bad. The turn chart is none
* of those — it reports WHERE YOU ARE, and a player should never read it as something to press.
* Violet is the one hue not already carrying a meaning, so it cannot be confused with any of them.
*/
export const TURNCHART_CSS = `
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid #2c333d;padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:#8b94a3}
.tc-now b{color:#b98cf0;font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
.tc-phase.now{border-color:#b98cf0;background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:#b98cf0}
.tc-phase.now .icf{fill:#b98cf0}
`;
+170 -16
View File
@@ -17,10 +17,12 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
@@ -32,7 +34,7 @@ import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
@@ -47,6 +49,25 @@ export type CellView = {
*/
enhancements: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
*
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
*/
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
/**
* Office card only: A/D tracks taken and how many the tier has.
*
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
* extra rails — there is no room on the card for more track, and the count is what matters.
*/
ad: { used: number; of: number } | null;
cars: string[];
facility: FacilityView | null;
/**
@@ -79,6 +100,17 @@ export type FacilityView = {
trackCap: number;
laborers: string;
porters: string;
/**
* What the INDUSTRY CARD itself prints, before any Modifier beside it.
*
* A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice
* House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number };
/** The Modifier cards standing beside it, by name. */
modifiers: string[];
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
@@ -173,6 +205,14 @@ export type Frame = {
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
/**
* Moves left in this Local Operations turn, or null outside a switching turn.
*
* It was reported only in the history, which is the one panel a player is NOT looking at while
* switching — the count that decides whether a run-around is still possible belongs next to the
* moves themselves.
*/
movesLeft: number | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -256,7 +296,7 @@ const FACILITY_NAMES: Record<string, string> = {
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
@@ -282,9 +322,42 @@ function facilityView(
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
base: baseOf(card),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
/**
* What the card itself prints, before any Modifier beside it.
*
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
}
return { out: 0, in: 0, laborers: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
function trainOnCard(s: GameState, key: string): CellView['train'] {
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid') continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
void id;
}
return null;
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
@@ -344,8 +417,22 @@ export function describeIntent(s: GameState, i: Intent): string {
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.play': {
/**
* NAME THE ROTATION, or the choice disappears.
*
* The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a
* turnout's two orientations produced one identical label each and the second was silently
* discarded before the menu ever saw it. The rotation is the entire decision for a turnout or
* a curve, and it could not be made.
*/
const kind = s.cards.get(i.cardId)?.kind;
const turn =
i.placement && kind?.kind === 'track'
? variantLabel(kind.geometry, i.variant, kind.hand)
: '';
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
}
case 'card.discard': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
@@ -362,8 +449,23 @@ export function describeIntent(s: GameState, i: Intent): string {
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
*
* This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a
* player who knew the caboose was on the back still had to guess; and it read identically for
* a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out
* from the front of the train was silently discarded and could not be chosen at all.
*/
const tray = s.trays.get(i.trayId);
if (!tray) return `drop ${i.count} car(s)`;
const cut = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
@@ -386,8 +488,35 @@ export function describeIntent(s: GameState, i: Intent): string {
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'mainline.modify': {
/**
* NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT.
*
* This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing
* about which stretch of the Division it means, and no clue what the card would do to it.
* Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one
* silently had none at all while the same card in hand did.
*/
const node = s.division.nodes[i.node];
const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key;
const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`;
const where =
node?.kind === 'mainline'
? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east`
: `node ${i.node}`;
let effect = '';
if (key === 'realignment' && node?.kind === 'mainline') {
const to = REALIGNMENTS.find((r) => r.from === node.card);
effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert';
} else if (key === 'brakeman' || key === 'airbrakes') {
effect = 'one Stage off the descent for a train running downhill';
} else if (key === 'helpers') {
effect = 'one Stage off the climb for a train running uphill';
}
// Short head, full detail after the em-dash — `actionButton` puts the first on the button and
// the second in the tooltip, so the list stays narrow without losing the explanation.
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
}
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
@@ -495,6 +624,11 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
card.geometry.kind === 'office'
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
: null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
@@ -660,6 +794,7 @@ export function snapshot(
wasted,
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -772,15 +907,24 @@ export function cardDescription(s: GameState, id: string): string {
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
const ways = variantsFor(k.geometry, k.hand)
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
.join(', or turned about, ');
/**
* A CURVE IS NOT A TURNOUT, and it used to be described as one.
*
* Both said "east-west track with a 45° leg", which is a turnout: a road straight across the
* card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an
* east or west edge, runs along the centre line to the frog, and leaves at 45° through the
* middle of a north or south edge. Nothing runs past it, which is exactly why it may not be
* laid in the Running Track.
*/
const what =
k.geometry === 'turnout'
? 'east-west track with a 45° leg through the middle of the north or south edge'
: 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it';
return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
@@ -876,6 +1020,16 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
}
}
/** Which Mainline card this is, counting only the Mainline cards from the west end. */
function mainlineIndex(s: GameState, node: number): number {
let n = 0;
for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++;
return n;
}
const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`;
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
/**
+178 -6
View File
@@ -33,6 +33,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
cardDescription,
cardName,
describeIntent,
geometryLabel,
@@ -40,10 +41,10 @@ import {
trainName,
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { joins, neighbour, variantsFor } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
@@ -208,15 +209,78 @@ export type Placeable = {
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
* piece has more than one legal rotation there, which is why the label carries the rotation too.
*/
spots: { label: string; index: number; coord: { row: number; col: number } }[];
spots: {
label: string;
index: number;
coord: { row: number; col: number };
/**
* The rails this placement would put on the card, as `connectionsFor` codes.
*
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
* is exact and still leaves a player working out which way the leg points on the board. Taken
* from the engine's own connections, so a preview cannot promise a shape the placement will not
* produce.
*/
links: string[];
}[];
};
/**
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
*
* The hand is the action surface. A card used to appear in two panels under two different models:
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
* as one flat button per Department under "Discard a card from my hand". One card, two mental
* models, and the discard block was a cross-product — four cards x three Departments was twelve
* buttons and about 290px of the action list, repeating the same three choices four times.
*
* Both verbs now hang off the card itself, and both use the pattern the board placement already
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
* already on screen with its top card and depth — exactly what you need to choose between them.
*/
export type HandAction = {
cardId: string;
name: string;
what: string;
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
playNow: number | null;
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
placeKey: string | null;
/** How many squares it may go on, for the count on the button. */
spots: number;
/** The option index for discarding onto each Department pile, or null where that is illegal. */
discard: (number | null)[];
/**
* Every distinct shape the card could be laid as, for the hover preview.
*
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
* on whether there is currently a square for it, and a player picking through a hand needs to see
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
* curve with no legal square showed no preview at all.
*/
shapes: string[][];
};
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
export type Menu = {
options: Intent[];
/** Actions with no further choice to make. */
direct: ActionGroup[];
/** Actions needing a location, grouped under their card or track piece. */
placeable: { title: string; items: Placeable[] }[];
/** One entry per card in hand, with the verbs available to it. */
hand: HandAction[];
/**
* Making up a train: the cars that may be added, and the option to add none.
*
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
* these key each chip to the option that adds it.
*/
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
@@ -241,7 +305,7 @@ export function actionMenu(game: Game): Menu {
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
@@ -253,16 +317,100 @@ export function actionMenu(game: Game): Menu {
title,
items: [...m.values()],
}));
return { options, direct, placeable };
/**
* THE HAND, AND WHAT EACH CARD CAN DO.
*
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
* of objects the player is looking at, not a list to sort.
*/
const handIds = game.state.decks.hands.get(0) ?? [];
const hand: HandAction[] = handIds.map((cardId) => {
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
let playNow: number | null = null;
const discard: (number | null)[] = [null, null, null];
options.forEach((i, index) => {
if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index;
if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index;
});
const kind = game.state.cards.get(cardId)?.kind;
const shapes =
kind?.kind === 'track'
? variantsFor(kind.geometry, kind.hand).map((v) =>
connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`),
)
: [];
return {
cardId,
name: cardName(game.state, cardId),
what: cardDescription(game.state, cardId),
playNow,
placeKey: place ? place.subjectKey : null,
spots: place ? place.spots.length : 0,
discard,
shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()],
};
});
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
// car is where the click belongs.
const makeUpCars: MakeUpAction[] = [];
let pass: number | null = null;
options.forEach((i, index) => {
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
if (i.type === 'newTrain.passCar') pass = index;
});
const makeUp =
makeUpCars.length > 0 || pass !== null
? {
title:
consistTitle(
game,
options,
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
) ?? 'Making up the train',
cars: makeUpCars,
pass,
}
: null;
return { options, direct, placeable, hand, makeUp };
}
/** Split an intent into "what" and "where", or null if it needs no location. */
function subjectOf(
game: Game,
i: Intent,
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
): {
subjectKey: string;
subject: string;
spot: string;
coord: { row: number; col: number };
links: string[];
} | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
/**
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
* "(0, 1)" — which is the wrong half of the board entirely.
*/
const k = game.state.cards.get(i.cardId)?.kind;
if (k?.kind === 'enhancement' && i.placement.row === -1) {
const node = game.state.division.nodes[i.placement.col];
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `on the ${where}, out on the Mainline`,
coord: i.placement,
links: [],
};
}
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
const kind = game.state.cards.get(i.cardId)?.kind;
@@ -276,11 +424,35 @@ function subjectOf(
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${track}`,
coord: i.placement,
links: placementLinks(game, i.cardId, i.variant),
};
}
return null;
}
/**
* The rails a card would lay, in `connectionsFor` codes — the same source the board draws from.
*
* Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out
* the shape for itself would eventually show a different piece from the one the placement lays, which
* is precisely the confusion it exists to remove.
*/
function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] {
const kind = game.state.cards.get(cardId)?.kind;
if (!kind) return [];
if (kind.kind === 'track') {
const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0];
if (!v) return [];
return connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`);
}
// Everything else that goes on the grid is a plain through track, or is not track at all.
if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew'];
return [];
}
/**
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
+274 -188
View File
@@ -6,12 +6,15 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
@@ -25,6 +28,15 @@ const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
* WHAT the picked card is being used for.
*
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
* board for a play, the three Department piles for a discard. Without this the two flows would need
* two selections, which is the thing being removed.
*/
let mode: 'play' | 'discard' | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
@@ -48,6 +60,14 @@ let districtMode: 'auto' | 'open' | 'closed' = 'auto';
* before any audio may start.
*/
let soundOn = false;
/**
* The phase the page last drew, so a change of phase can be announced.
*
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
@@ -59,76 +79,37 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
* "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a
* player working out which way the leg will point once the card is down. Rendered by `officeSvg` —
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
* belong to a position, and this is a card on its own.
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
'',
);
}
// ---------------------------------------------------------------------------
/**
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
*
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
* player could not see what had already happened this Stage or what was coming. The printed game
* has a turn chart for exactly this reason.
*
* Each phase carries a tooltip describing what actually happens in it — the first three are the
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
* sheet does not spell them out.
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
function renderTurnChart(f: ReturnType<typeof view>): void {
$('tc-day').textContent = `Day ${f.day}`;
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
$('tc-clock').textContent = f.clock;
$('tc-phase').textContent = f.phase;
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
$('tc-phases').innerHTML = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
function start(): void {
@@ -152,7 +133,13 @@ function render(): void {
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
// entry that carries its squares is keyed the same way the menu keys it.
const forPlay =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
const chosen = forPlay
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
: undefined;
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
@@ -212,47 +199,52 @@ function render(): void {
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
$('facs').innerHTML = facilitiesHtml(f);
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
/**
* THE HAND, AS THE ACTION SURFACE.
*
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
* again in the action list — once as subjects to play and once as one button per Department to
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
*/
$('hand').innerHTML = menu.hand.length
? menu.hand
.map((h) => {
const canPlace = h.placeKey !== null && h.spots > 0;
const canPlay = canPlace || h.playNow !== null;
const canDiscard = h.discard.some((d) => d !== null);
const picked = selected === `hand:${h.cardId}`;
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
on
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
: '';
// A track card shows what it would look like on the board, in every orientation it has —
// which is how a player sees a turnout has two at all, and what a curve actually is.
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
verb(
'play',
canPlay,
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
canPlace
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
: 'Play it. This card needs no square on the board.',
) +
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
`</div></div>`
);
})
.join('')
: '<span class="dim">empty</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
@@ -263,47 +255,78 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
$('depts').innerHTML = pilesHtml(f);
renderYards(f);
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
// highlighted in place, on the board or on the Department piles.
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
const node = b as HTMLElement;
node.onclick = () => {
const cardId = node.dataset['card']!;
const verb = node.dataset['verb'] as 'play' | 'discard';
const entry = menu.hand.find((h) => h.cardId === cardId);
if (!entry) return;
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
return;
}
const key = `hand:${cardId}`;
const same = selected === key && mode === verb;
selected = same ? null : key;
mode = same ? null : verb;
pendingAt = null;
render();
};
}
// -- discarding: the three Department piles become the targets. They already show their top card
// and their depth, which is exactly what you choose between.
const discarding = mode === 'discard' && selected !== null
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
const index = discarding.discard[slot];
if (index === null || index === undefined) continue;
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
render();
};
}
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
);
if (!car) continue;
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
render();
};
}
}
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
@@ -313,8 +336,25 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
/**
* SAY WHEN THE PHASE TURNS OVER.
*
* The five phases run themselves once Local Operations ends, so the page can change out from under
* a player between one click and the next. The turn chart already shows WHERE you are; this says
* that it moved, which is the part you miss when you were looking at the board.
*/
if (lastPhase !== null && lastPhase !== f.phaseKey) {
const el = $('phasenote');
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent?.startsWith(f.phase)) el.className = '';
}, 2600);
}
lastPhase = f.phaseKey;
renderDistrict(f);
renderActions(menu);
renderActions(menu, f);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
@@ -332,19 +372,8 @@ function render(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: ReturnType<typeof view>): void {
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
)
.join('');
$('divyard').innerHTML = row(f.yards.division);
$('clsyard').innerHTML = row(f.yards.classification);
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
@@ -384,16 +413,7 @@ function renderDistrict(f: ReturnType<typeof view>): void {
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
const el = $('actions');
if (game.state.status !== 'active') {
@@ -419,27 +439,61 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
};
// A long label is two things: the action, and why it is offered. Put the first on the button and
// the second on the tooltip, or the list crowds out the board.
/**
* A long label is two things: the action, and why it is offered. Put the first on the button and
* the second on the tooltip, or the list crowds out the board.
*
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
* label happening to contain an em-dash, so an action naming a card could have none at all while
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
const actionButton = (label: string, index: number): string => {
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
const rest = cut > 0 ? label.slice(cut + 3) : '';
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
/**
* MOVES LEFT, WHERE THE MOVES ARE.
*
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
: '';
// A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = '<h3 class="actions-hd">Actions</h3>';
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
*
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
@@ -455,17 +509,46 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
/**
* MAKING UP A TRAIN.
*
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
* way to finish when the yard holds nothing the train may take.
*
* Moving the cars onto the yard chips without this left a train being made up with no control on
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
* 775569289 with Train 10 waiting at the West Division Point.
*/
if (menu.makeUp) {
const addable = menu.makeUp.cars.length;
html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
`</div>` +
(menu.makeUp.pass !== null
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
: '') +
`</div>`;
}
/**
* The card is picked in the hand and the square on the board, so all that is left here is the
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
*/
const picked =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
for (const group of menu.placeable) {
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
if (relevant.length === 0) continue;
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
for (const item of relevant) {
{
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
@@ -474,7 +557,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
shown
.map((sp) => {
// The picture rides on the button that would lay it, so hovering a rotation shows
// that rotation — which is the question "which of these two do I want?" answered.
const fig = sp.links.length
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
.join('') +
`</div>`;
}
}
@@ -497,15 +592,6 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
@@ -559,7 +645,7 @@ function clearSave(): void {
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(pageStyle);
installTooltips();
+225
View File
@@ -0,0 +1,225 @@
/**
* The side panels a game is read from — cards, yards, blockers, facilities.
*
* SHARED between the playable page and the site's replay viewer, for the same reason the board
* renderers are: a replay is the game being WATCHED rather than played, so it should look like the
* game. The viewer had three panels against the play page's eight — no facilities, no blockers, no
* cards, no yards — which meant a replay could not answer "why is nothing moving?", the question a
* replay mostly exists to answer.
*
* Each function returns HTML rather than writing to the DOM, so the two pages can keep their own
* element ids and their own layout while drawing the same contents.
*/
import type { Frame } from '../sim/view.ts';
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A card, drawn as a card.
*
* `playable === false` crosshatches it: "not yet playable" had to be read, where a hatch is seen.
* The tooltip still explains WHY, which a hatch cannot. A replay passes null throughout — nothing
* there is playable, because nothing there is being played.
*/
export function cardRow(name: string, why: string, playable: boolean | null): string {
return (
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`
);
}
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
return f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
}
/**
* The three Department piles and the Salvage Yard, each with its top card and its depth.
*
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
i,
);
})
.join('') +
pile(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
)
);
}
/** One yard, by car type. Loaded and empty are separate numbers because they do different jobs. */
export function yardHtml(rows: { type: string; loaded: number; empty: number }[]): string {
return rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> ` +
// Each count is its own target: making up a train is choosing a car OF A TYPE AND A LOAD
// STATE, which is exactly what these two numbers already distinguish.
`<span class="ld" data-car="${esc(r.type)}" data-loaded="1">${r.loaded}</span>/` +
`<span class="mt" data-car="${esc(r.type)}" data-loaded="0">${r.empty}</span></span>`,
)
.join('');
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
}
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
export function facilitiesHtml(f: Frame): string {
/**
* One number, with what the card printed and what a Modifier added.
*
* `laborers` arrives as "left/total" and the rest as plain totals, so the total is taken from the
* end either way and compared with the base.
*/
const stat = (label: string, shown: string, base: number, why: string): string => {
const total = Number(shown.split('/').pop() ?? shown);
const added = Number.isFinite(total) ? total - base : 0;
const delta = added > 0 ? `<span class="added">+${added}</span>` : '';
return (
`<span class="cap" data-tip="${esc(why)}${added > 0 ? ` The card prints ${base}; Modifiers beside it add ${added}.` : ''}">` +
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
);
};
const boxes = (filled: string[], cap: number): string => {
let h = '';
for (let i = 0; i < Math.max(cap, filled.length); i++) {
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
}
return h || '<span class="dim">—</span>';
};
return f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
/**
* THE WORKERS AND THE CAPACITIES, ON THE PANEL RATHER THAN IN A TOOLTIP.
*
* Laborers were only ever in the hover text, so "how many actions can I take here this
* Stage?" — the question that decides every Cargo phase — had to be hunted for. And a
* Modifier's whole effect is one of these numbers going up, so showing the number alone
* made an Ice House look like it had done nothing. Each now says what the card prints
* and what the Modifiers beside it added.
*/
`<div class="caps">` +
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
(x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
: '') +
`</div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
}
/** Styling for all of the above, so the two pages cannot drift apart visually. */
export const PANEL_CSS = `
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;margin:9px 0 4px}
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
.handcard{background:#242c36;border:1px solid #2c333d;border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* A pile shows two things: which card is face up on top, and how many are under it. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* Rolling stock is finite and the Classification Yard only returns it when the Division Yard is
bare, so watching the Division Yard run down is real information. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the
legal-square outline on the board: the same question is being asked, so it looks the same. */
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
.handcard.target:hover{background:#2b3d55}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
ul.blocked{margin:0;padding-left:18px}
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
.fac{border-top:1px solid #2c333d;padding:7px 0}
.fac:first-child{border-top:0}
.fac .nm{font-size:12px;font-weight:600}
.caps{display:flex;flex-wrap:wrap;gap:9px;margin-top:3px;font-size:10px;align-items:baseline}
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
.caps .mods{color:#c8a04a}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
`;
+28 -77
View File
@@ -27,37 +27,7 @@ header button:hover{border-color:#4d6fa8}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
/* CARDS YOU HOLD vs CARDS YOU READ.
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
Your Move is clickable and carries the action border; a reference card is flat, unlit and
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
second list, so nothing looked like a card at all. */
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
Yard is bare, so a player has to be able to watch the supply run down. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
count and not a hint — only the top card may ever be drawn, so everything below it is out of
reach, and choosing where to discard is choosing what to put there. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -90,18 +60,28 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* THE HAND IS THE ACTION SURFACE. Each card carries its own verbs, so the twelve-button discard
block and the separate "play a card" subject list are both gone. The buttons are small and quiet
until the card is picked — the card is the thing being read, they are what you do with it. */
.handcard .cardacts{display:flex;gap:4px;margin-top:5px}
button.cardact{margin:0;padding:1px 7px;font-size:10px;background:#2f3a48;border:1px solid #46536440;
border-radius:9px;color:#cfd6e0;text-align:center}
button.cardact:hover{background:#3d4c60;border-color:#5aa9e6}
button.cardact.on{background:#2b3444;border-color:#c8912f;color:#f2e6cf;font-weight:700}
button.cardact.discard{color:#d6b48a}
.handcard.picked{border-color:#c8912f;box-shadow:0 0 0 2px rgba(200,145,47,.18)}
#phasenote{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:40;
background:#241d33;border:1px solid var(--now);color:#e7dcfb;border-radius:14px;padding:5px 16px;
font-size:13px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
.makeup-note{font-size:11px;margin:0 0 5px}
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
buttons underneath it; struck through in red once it is gone. */
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
background:rgba(200,145,47,.16);border:1px solid #8a6d1f;color:#f2e6cf}
.moves.spent{background:rgba(190,50,50,.20);border-color:#8a4040;color:#e58080}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
@@ -154,35 +134,6 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
/* turn chart */
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid var(--line);padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:var(--dim)}
.tc-now b{color:var(--now);font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
.tc-who b{color:var(--now);background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:var(--dim);background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
/* VIOLET, not the amber used for actions.
The palette already spends amber on "you can click this", blue on "this is a legal square or a
link", green on good and red on bad. The turn chart is none of those — it reports WHERE YOU ARE,
and a player should never read it as something to press. Violet is the one hue not already
carrying a meaning here, so it cannot be confused with any of them. */
.tc-phase.now{border-color:var(--now);background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:var(--now)}
.tc-phase.now .icf{fill:var(--now)}
</style>
</head>
<body>
@@ -202,12 +153,12 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
<div id="turnchart">
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
<div class="tc-now">phase <b id="tc-phase">—</b></div>
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
<ol class="tc-phases" id="tc-phases"></ol>
</div>
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
you are in the Day identically. -->
<div id="turnchart"></div>
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<main>
<div>
+23 -4
View File
@@ -99,22 +99,41 @@ input[type=range]{flex:1;min-width:180px}
<input type="range" id="vscrub" min="0" max="0" value="0">
<span class="dim" id="vpos">0 / 0</span>
</div>
<!-- The same turn chart the live game shows, from the same renderer — where you are in the
Day should look identical whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="vbar">
<span id="vclock">&mdash;</span>
<span>phase: <b id="vphase">&mdash;</b></span>
<span>Revenue <b id="vrev">0</b></span>
<span class="dim">&larr; &rarr; to step, space to play</span>
</div>
</section>
<!-- SAME PANELS, SAME ORDER as the playable page. A replay is the game being watched rather
than played, so the only things it drops are the ones that ask you to act. It used to show
three panels against the play page's eight: no facilities, no blockers, no cards, no yards,
so a replay could not answer "why is nothing moving?" — the question a replay exists for. -->
<div class="vgrid">
<div>
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
<section><h2>The Division — west to east</h2><div class="scroll" id="vdivision"></div></section>
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
<div id="vdistrictsummary" class="dim"></div>
<div class="scroll" id="vgrid"></div></section>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
<div>
<section><h2>Cards Available</h2>
<h3>Cards in Hand</h3><div id="vhand" class="cardrow">&mdash;</div>
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">&mdash;</div>
</section>
<section><h2>The Yards</h2>
<h3>Division Yard <span class="dim" id="vdivtot"></span></h3>
<div id="vdivyard" class="yard"></div>
<h3>Classification Yard <span class="dim" id="vclstot"></span></h3>
<div id="vclsyard" class="yard"></div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="vblocked"></ul></section>
<section><h2>Facilities</h2><div id="vfacs"></div></section>
</div>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
</div>
</main>
+33 -3
View File
@@ -12,6 +12,8 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, handHtml, pilesHtml, yardHtml } from './panels.ts';
import { cuesFor } from '../sim/narrate.ts';
import { playCue } from './sound.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
@@ -109,8 +111,13 @@ function show(i: number): void {
// whistles, and stepping back would sound a Stage ending that is being un-done.
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('vphase').textContent = f.phase;
// The live game's turn chart, from the shared renderer: same five phases, same violet "you are
// here". A replay used to report the Day and phase as two plain strings, so the one thing a player
// glances at most often looked nothing like it does in play.
// A replay carries the seat names in the frame's own lines rather than a player table, so the
// solitaire seat is named directly; a multi-player replay reports the index it has.
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
$('turnchart').innerHTML = turnChartHtml(f, actorName);
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
@@ -139,6 +146,17 @@ function show(i: number): void {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
// The same side panels the playable page shows, from the same renderers. A replay is the game
// being watched, so it drops only what asks you to act.
$('vhand').innerHTML = handHtml(f);
$('vdepts').innerHTML = pilesHtml(f);
$('vdivyard').innerHTML = yardHtml(f.yards.division);
$('vclsyard').innerHTML = yardHtml(f.yards.classification);
$('vdivtot').textContent = `${f.yards.divisionTotal} cars`;
$('vclstot').textContent = `${f.yards.classificationTotal} cars`;
$('vblocked').innerHTML = blockedHtml(f);
$('vfacs').innerHTML = facilitiesHtml(f);
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
@@ -252,6 +270,18 @@ function wire(): void {
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
/**
* A SPEED CHANGE HAS TO TAKE EFFECT WHILE IT IS PLAYING.
*
* The interval is created with whatever the select held at the moment play started, and nothing
* re-read it — so choosing "extra slow" mid-replay did nothing at all and the pace looked stuck.
* The standalone replay has always restarted its timer on change; this viewer was missed.
*/
($('vspeed') as HTMLSelectElement).onchange = () => {
if (!timer) return;
$('vplay').click();
$('vplay').click();
};
$('vdistricttoggle').onclick = () => {
const f = steps[at]?.frame;
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
@@ -283,7 +313,7 @@ function wire(): void {
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS;
style.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(style);
installTooltips();
wire();
+23
View File
@@ -93,6 +93,29 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'couple') {
/**
* KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it.
*
* Coupling a cut was reported as invisible — the car simply left the board and only history said
* where it went. It is the single most consequential thing a switching move does, so it gets the
* one sound a player will learn to listen for.
*/
for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) {
tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82);
}
tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58);
return;
}
if (cue === 'drop') {
// Setting out is the same act in reverse and quieter: one clank, no slack behind it.
for (const fg of [[104, 0.14], [156, 0.08]]) {
tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85);
}
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {
+24 -1
View File
@@ -24,7 +24,25 @@ function ensure(): HTMLElement {
function place(el: HTMLElement, text: string): void {
const t = ensure();
t.textContent = text;
/**
* A PICTURE WHERE ONE HELPS, otherwise text.
*
* `data-tip-html` carries pre-built markup — a drawing of the track piece a placement would lay,
* rendered by the board's own renderer so it cannot show a shape the placement will not produce.
* Only ever set from markup this app built; nothing here interpolates anything a player typed.
*/
const html = el.getAttribute('data-tip-html');
if (html) {
t.innerHTML = `<div class="tip-fig">${html}</div>`;
if (text) {
const p = document.createElement('div');
p.className = 'tip-txt';
p.textContent = text;
t.appendChild(p);
}
} else {
t.textContent = text;
}
t.style.display = 'block';
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
@@ -87,4 +105,9 @@ export const TOOLTIP_CSS = `
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
[data-tip]{cursor:help}
button[data-tip],a[data-tip]{cursor:pointer}
/* A drawn piece inside a tooltip. The figure sits above its caption, and the caption keeps the
tooltip's own text treatment — the drawing is the answer, the words are the confirmation. */
#tip .tip-fig{display:flex;gap:6px;justify-content:center;margin:1px 0 6px}
#tip .tip-fig svg{width:132px;height:auto;display:block}
#tip .tip-txt{white-space:pre-wrap}
`;