v0.4.9 — X,Y coordinates, no-switching can still clear the mainline, Q3 corrected, a clearance bug, three sounds, and the box art

A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the
first real audio beyond a placeholder.

- Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order.
  Display-only.
- "No switching" now means may not add or drop cars, not "never touch it" — these trains can
  still be moved onto Secondary Track to clear the mainline.
- Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The
  forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left
  off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never
  use" as a side effect.
- evaluateClearance now checks every occupant on a Mainline card before offering a judgment
  call, instead of returning on whichever it found first — found while explaining a playtest
  report, fixed with a regression test.
- Three new synthesised sounds: arrive, depart, crash.
- The splash page shows the box art.

Full detail, measurements and reasoning in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
This commit is contained in:
Jesse
2026-08-19 23:51:35 -04:00
co-authored by Claude Sonnet 5
parent 37b1e5b969
commit 859817d173
25 changed files with 1048 additions and 739 deletions
+3 -2
View File
@@ -344,7 +344,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
const tray = game.state.trays.get(trayId);
const where =
tray?.position.at === 'grid'
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})`
? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})`
: '';
groups.push({
kind: prefix,
@@ -670,7 +670,8 @@ function subjectOf(
node?: number;
links: string[];
} | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
// X,Y — east/west then north/south, not the internal row/col storage order.
const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`;
/**
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
+43 -29
View File
@@ -11,6 +11,11 @@ body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
main{width:100%;max-width:760px}
.hero{display:flex;gap:24px;align-items:flex-start}
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line)}
.hero-image img{display:block;width:100%;height:auto}
.hero-content{flex:1;min-width:0}
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
.tag{color:var(--dim);margin:0;font-size:14px}
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
@@ -32,39 +37,48 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</head>
<body>
<main>
<h1>Station Master</h1>
<p class="tag">TimeTable and Train Order (TT&amp;TO) railroading, 1840&ndash;1950.</p>
<div class="hero">
<div class="hero-image">
<img src="./images/station-master-splash.jpg"
alt="Station Master — TimeTable &amp; Train Order Railroading, 1840&ndash;1950. Box art: two trains meet at a junction beside a train order office."
width="560" height="700">
</div>
<div class="hero-content">
<h1>Station Master</h1>
<p class="tag">TimeTable and Train Order (TT&amp;TO) railroading, 1840&ndash;1950.</p>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
<g id="ties"></g>
</svg>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
<g id="ties"></g>
</svg>
<p class="blurb">
As Station Master in your line-side office, you manage the town's railroad connection. You are
responsible for all track between your Limits, a district of industries hanging beneath it, and
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<p class="blurb">
As Station Master in your line-side office, you manage the town's railroad connection. You are
responsible for all track between your Limits, a district of industries hanging beneath it, and
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
Your game data is saved in your browser &mdash; if you close the tab and reopen this site
without clearing your cache, your game is preserved and you can continue automatically.
During the game you can also explicitly save your progress for later replay.</p>
<span class="go">Start a game &rarr;</span>
</a>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
Your game data is saved in your browser &mdash; if you close the tab and reopen this site
without clearing your cache, your game is preserved and you can continue automatically.
During the game you can also explicitly save your progress for later replay.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
</div>
</div>
<div class="rule"></div>
+2 -2
View File
@@ -819,8 +819,8 @@ function renderActions(
.map(
(m) =>
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
`${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
)
.join('') +
`</div>`;
+81 -17
View File
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
osc.stop(at + dur + 0.02);
};
/**
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
*/
const noiseBurst = (
at: number,
dur: number,
gain: number,
freq: number,
q: number,
type: BiquadFilterType = 'bandpass',
): void => {
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, frames, c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const filt = c.createBiquadFilter();
filt.type = type;
filt.frequency.value = freq;
filt.Q.value = q;
const amp = c.createGain();
amp.gain.setValueAtTime(0, at);
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(filt).connect(amp).connect(c.destination);
src.start(at);
src.stop(at + dur + 0.02);
};
const t = c.currentTime + 0.02;
if (cue === 'stage') {
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
}
const frames = Math.floor(c.sampleRate * d);
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const band = c.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.8;
const amp = c.createGain();
amp.gain.setValueAtTime(0, t);
amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
src.connect(band).connect(amp).connect(c.destination);
src.start(t);
src.stop(t + d);
noiseBurst(t, d, 0.05, 1400, 0.8);
return;
}
if (cue === 'arrive') {
/**
* A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
* road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
* `day` bell, not a repeat of it.
*/
let at = t;
const gaps = [0.13, 0.17, 0.22, 0.28];
for (let i = 0; i < gaps.length; i++) {
noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
at += gaps[i]!;
}
for (const fg of [[1046, 0.07], [1568, 0.04]]) {
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
}
return;
}
if (cue === 'depart') {
/**
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
* since this train is only leaving the Office, not clearing the whole Division.
*/
let at = t;
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
for (let i = 0; i < gaps.length; i++) {
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
at += gaps[i]!;
}
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
return;
}
if (cue === 'crash') {
/**
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
* weight under the screech.
*/
tone(t, 311, 0.35, 0.16, 'square', 70);
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
tone(t, 45, 0.3, 0.18, 'sine');
return;
}