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
+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}
`;