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:
+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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user