adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+34
View File
@@ -261,6 +261,8 @@ function render(): void {
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<span class="dim">none</span>';
renderYards(f);
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
$('supply').innerHTML =
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
@@ -297,6 +299,38 @@ function render(): void {
save();
}
/**
* The two yards, and how close the Division Yard is to running out.
*
* §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the
* Division Yard is BARE. So the interesting number is not how much has been used but how little is
* 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);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
// The one thing worth calling out: the yard about to turn over.
const bare = f.yards.divisionTotal === 0;
$('divyard').classList.toggle('bare', bare);
$('yardnote').textContent = bare
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
function renderDistrict(f: ReturnType<typeof view>): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
+16
View File
@@ -33,6 +33,15 @@ header button:hover{border-color:#4d6fa8}
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}
@@ -213,6 +222,13 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
</section>
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
<section><h2 class="big">The Yards</h2>
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
<div id="divyard" class="yard"></div>
<h3>Classification Yard <span class="dim" id="clstot"></span></h3>
<div id="clsyard" class="yard"></div>
<div class="dim" id="yardnote"></div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replays</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
a{color:#5aa9e6}
header{background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;
display:flex;gap:18px;align-items:baseline;flex-wrap:wrap;position:sticky;top:0;z-index:5}
header b{font-size:16px}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
.build{margin-left:auto;font-size:11px;opacity:.7}
main{padding:18px;max-width:1300px;margin:0 auto}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);margin:0 0 8px}
section{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:14px}
.dim{color:var(--dim)}
.sm{font-size:11px}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:6px 11px;margin:2px 4px 2px 0;cursor:pointer;font:inherit;font-size:13px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
button.entry{display:block;width:100%;margin-bottom:6px}
#perr{color:#e58080;min-height:18px;font-size:12px}
input[type=file]{font:inherit;font-size:12px;color:var(--dim)}
/* viewer */
#viewer{display:none}
.vbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.vgrid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:14px;align-items:start}
@media(max-width:1100px){.vgrid{grid-template-columns:1fr}}
.scroll{overflow-x:auto}
#vlog{max-height:420px;overflow:auto;font-size:12px}
.line{padding:1px 0}
/* Auto-hide, matching the playable page and the standalone replay. Folded leaves a summary rather
than nothing, because a panel that disappears reads as broken instead of folded away. */
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
button.ghost:hover{border-color:#4d6fa8;color:#fff}
#vdistrict.folded .scroll{display:none}
#vdistrict.folded #vdistrictsummary{display:block;padding:2px 0}
#vdistrictsummary{display:none}
.line.now{background:rgba(90,169,230,.10)}
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
select{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px;font:inherit;font-size:12px}
input[type=range]{flex:1;min-width:180px}
</style>
</head>
<body>
<header>
<b><a class="home" href="./index.html">Station Master</a></b>
<span class="dim">replays</span>
<span class="build">build __BUILD__</span>
</header>
<main>
<div id="picker">
<section>
<h2>Saved on this site</h2>
<div id="hosted"><p class="dim">loading&hellip;</p></div>
</section>
<section>
<h2>Open a save file</h2>
<p class="dim">A Station Master save is a small <code>.json</code> file &mdash; a seed and the list of
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
Save one from inside a game with <b>Save replay</b>.</p>
<input type="file" id="file" accept=".json,application/json">
<div id="perr"></div>
</section>
</div>
<div id="viewer">
<section>
<div class="vbar">
<button id="vclose">&larr; all replays</button>
<b id="vtitle">&mdash;</b>
<span class="dim" id="vnote"></span>
</div>
<div class="vbar">
<button id="vfirst">&#9198; start</button>
<button id="vback">&#9664; step</button>
<button id="vplay">&#9654; play</button>
<button id="vfwd">step &#9654;</button>
<button id="vstage">next Stage &#9197;</button>
<button id="vlast">end &#9197;</button>
<select id="vspeed">
<option value="3000">extra slow</option>
<option value="1200">slow</option>
<option value="700" selected>normal</option>
<option value="280">fast</option>
<option value="90">very fast</option>
</select>
<button id="vsound">&#128263; muted</button>
<input type="range" id="vscrub" min="0" max="0" value="0">
<span class="dim" id="vpos">0 / 0</span>
</div>
<div class="vbar">
<span id="vclock">&mdash;</span>
<span>phase: <b id="vphase">&mdash;</b></span>
<span>Revenue <b id="vrev">0</b></span>
<span class="dim">&larr; &rarr; to step, space to play</span>
</div>
</section>
<div class="vgrid">
<div>
<section><h2>The Division</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>
</div>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
</div>
</main>
<script type="module" src="./web/replays.js"></script>
</body>
</html>
+51 -2
View File
@@ -12,6 +12,8 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { cuesFor } from '../sim/narrate.ts';
import { playCue } from './sound.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { narrate } from '../sim/narrate.ts';
import { cardName, trainName } from '../sim/view.ts';
@@ -35,7 +37,12 @@ const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/** Every position the save passes through, with the lines narrated along the way. */
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
type Step = {
frame: Frame;
lines: { text: string; tone: string }[];
/** What this step should sound like — from `cuesFor`, the same function the live game uses. */
cues: string[];
};
/**
* Rebuild a game from a save, keeping a snapshot at each step.
@@ -54,7 +61,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const n = narrate(e, ctx);
return { text: n.text, tone: n.tone };
});
steps.push({ frame: snapshot(s, [], null), lines });
steps.push({ frame: snapshot(s, [], null), lines, cues: cuesFor(events) });
};
push(pump(s));
@@ -80,13 +87,28 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
let steps: Step[] = [];
let at = 0;
let timer: ReturnType<typeof setInterval> | null = null;
/**
* Sound and auto-hide, matching the playable page and the standalone replay.
*
* This viewer is a third place a game gets drawn, and it had drifted: three speeds where the
* standalone had five, and neither of these. Anything a player learns on one screen should hold on
* the others.
*/
let soundOn = false;
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
function show(i: number): void {
const from = at;
at = Math.max(0, Math.min(steps.length - 1, i));
const step = steps[at];
if (!step) return;
const f = step.frame;
// Only a single step FORWARD sounds. Scrubbing across a hundred steps would fire a hundred
// 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;
$('vrev').textContent = String(f.revenue);
@@ -95,6 +117,21 @@ function show(i: number): void {
$('vdivision').innerHTML = divisionSvg(f.division);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
// Auto-hide: the district is worth its space during the phases that change it.
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('vdistrict');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
$('vdistrictsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing`;
$('vdistricttoggle').textContent =
districtMode === 'auto'
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
: districtMode === 'open'
? 'always showing — click for auto-hide'
: 'always hidden — click for auto-hide';
// A window of history rather than only this step, so a jump lands in context.
let log = '';
for (let k = Math.max(0, at - 18); k <= at; k++) {
@@ -215,6 +252,18 @@ function wire(): void {
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
$('vdistricttoggle').onclick = () => {
const f = steps[at]?.frame;
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
show(at);
};
$('vsound').onclick = () => {
soundOn = !soundOn;
$('vsound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
// Confirms the change, and gives the browser the gesture it needs before audio may start.
if (soundOn) playCue('stage');
};
$('vclose').onclick = () => {
if (timer) clearInterval(timer);
timer = null;