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