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:
+14
-1
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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' : '');
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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();
|
||||
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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
@@ -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">—</span>
|
||||
<span>phase: <b id="vphase">—</b></span>
|
||||
<span>Revenue <b id="vrev">0</b></span>
|
||||
<span class="dim">← → 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">—</div>
|
||||
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">—</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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user