diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5e74af..910ba10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,59 @@ The target is 20 Revenue over 5 Days.
## Unreleased
+### The replay behaves like the game it is replaying
+
+Sound and the district auto-hide were built for the playable page and the replay had neither, which
+made watching a game back a poorer experience than playing it. Both are there now, and both are the
+SAME implementation rather than a second copy:
+
+- `cuesFor` decides what happened — a Stage ended, a Day turned, a train was built — and is imported
+ by the live game and called by the replay recorder, so the two cannot disagree about when a Stage
+ ended. Frames carry their cues.
+- `playCue` decides what that sounds like. It was three module-level functions with a shared
+ `AudioContext`; it is now one self-contained function, embedded into the replay by `toString()`
+ exactly as the board renderers are. The context is parked on `window` because the embedded copy
+ has no module scope to keep it in.
+
+**Sounds fire only when stepping FORWARD one frame.** Scrubbing across a hundred frames would
+otherwise fire a hundred whistles at once, and stepping backwards would sound a Stage ending that is
+being un-done.
+
+Both default to the quiet, tidy setting: muted, and auto-hide on.
+
+The replay's DOM stub had no `classList`, so the page threw while rendering rather than folding a
+panel. It has one now — the same gap the playable page's stub had, and the same fix.
+
+### Playtest fixes
+
+**The end-of-line labels were cut to three letters.** "trains enter" and "trains leave" hang off the
+ends of the route, and the canvas padding was sized for the buffer stops alone — so the west end read
+"TER" and the east "tra". Padding now allows for the words.
+
+**"End my turn" became "End Local Operations".** It ends the phase, and the old wording invited the
+reading that it ended something smaller.
+
+**The hand limit is a limit, not a toll on drawing.** Drawing a card set "you must now play one",
+so a player who had already played two cards and drew back to three was still forced to spend one.
+§6.2 is a hand LIMIT — "reduce his hand to no more than three cards", four with a Red Flag — and
+that is now the only thing that blocks the end of a turn. It is derived from the hand at each
+render, and a test asserts the page and the engine never disagree about whether the turn may end.
+The engine removes `draw.end` outright when the hand is over the limit, so the page draws a disabled
+button naming the reason rather than silently offering no way out.
+
+**A train now says what its card calls for.** Reported on seed 22222: Extra X22 offered a caboose
+and nothing else with no reason given. The rules were right — "Pee-Dee" is a per-diem train whose
+consist is one caboose and no cars, and §8.2 forbids the wrong cars however few are carried — but
+the screen never said so, which reads as a broken game. The New Train header now names the train and
+its consist: *Making up Extra X22 "Pee-Dee": its card calls for 1 caboose — Per-diem train. May only
+pick up MTs.*
+
+**The auto-hide control said what the panel was doing, not what pressing it would do.** "auto ·
+folded" reads as a status line and was missed; it now reads "auto-hide: on — click to keep open",
+and looks like a control rather than a caption.
+
+**The whistle is twice as long,** and sound now defaults to OFF.
+
### A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts
index 79c8a72..3b30bad 100644
--- a/src/sim/board-svg.ts
+++ b/src/sim/board-svg.ts
@@ -51,7 +51,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
const CW = { dp: 118, ml: 152, run: 78 };
const CH = 58;
const GAP = 6;
- const PAD = 26;
+ /**
+ * Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
+ * leave" off the east, so a padding sized for the stop alone clipped both to their middle three
+ * letters — the west end read "TER" and the east "tra".
+ */
+ const PAD = 92;
const SIDE_GAP = 34;
const esc = (t: string): string =>
diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts
index f0bfff1..c688d79 100644
--- a/src/sim/narrate.ts
+++ b/src/sim/narrate.ts
@@ -536,3 +536,25 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where
}
export { coordKey };
+
+
+/**
+ * What a batch of events should SOUND like.
+ *
+ * Shared between the live game and the replay so the two cannot disagree about when a Stage ended.
+ * The model names what happened; the page decides what it sounds like.
+ */
+export function cuesFor(events: readonly GameEvent[]): string[] {
+ const out: string[] = [];
+ for (const e of events) {
+ if (e.type === 'trainMadeUp') out.push('train');
+ 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,
+ // and the bell is the bigger event.
+ if (e.day > 1 && e.stage === 1) out.push('day');
+ else if (e.day > 1 || e.stage > 1) out.push('stage');
+ }
+ }
+ return out;
+}
diff --git a/src/sim/replay.ts b/src/sim/replay.ts
index baa2678..9cb54f9 100644
--- a/src/sim/replay.ts
+++ b/src/sim/replay.ts
@@ -28,7 +28,7 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
-import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
+import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
// The view-model lives in its own module so the browser build can import it without dragging in
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
@@ -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 { playCue } from '../web/sound.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
@@ -104,7 +105,13 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
d !== null &&
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
- frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
+ const frame = snapshot(s, lines, where ?? null, from, d, wasted);
+ // Cues come from the RAW events, not the visible ones: what a frame sounds like is a different
+ // question from what it prints, and `cuesFor` is shared with the live game so the replay cannot
+ // sound a Stage the game did not.
+ const cues = cuesFor(events);
+ if (cues.length > 0) frame.cues = cues;
+ frames.push(frame);
};
frames.push(
@@ -283,6 +290,14 @@ margin-top:5px;font-size:11px;line-height:1.35}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto}
+/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
+ panel that disappears entirely 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}
+#district.folded .grid{display:none}
+#district.folded #districtsummary{display:block;padding:2px 0}
+#districtsummary{display:none}
.line{padding:2px 0;border-bottom:1px solid #23272e}
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
@@ -341,7 +356,9 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
`,
@@ -417,6 +421,17 @@ function renderActions(menu: ReturnType): void {
}
html += ``;
}
+ if (
+ game.state.clock.phase === 'localOps' &&
+ game.state.turn.option === 'draw' &&
+ !menu.options.some((i) => i.type === 'draw.end')
+ ) {
+ const hand = (game.state.decks.hands.get(0) ?? []).length;
+ html +=
+ ``;
+ }
+
el.innerHTML = html;
for (const b of Array.from(el.querySelectorAll('button.act'))) {
@@ -502,7 +517,7 @@ if (soundBtn) {
paint();
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
// doubles as the user gesture the browser needs before any audio may start.
- if (soundOn) play('stage');
+ if (soundOn) playCue('stage');
};
paint();
}
diff --git a/src/web/play.html b/src/web/play.html
index 6e3302e..7a55d1b 100644
--- a/src/web/play.html
+++ b/src/web/play.html
@@ -102,8 +102,8 @@ button:hover{background:#39424e;border-color:#4d6fa8}
button.act{display:inline-block}
/* The district collapses when nothing in it is changing. A summary line stays, because a panel
that vanishes entirely reads as broken rather than as folded away. */
-button.ghost{background:transparent;border:1px solid var(--line);color:var(--dim);font-size:10px;
- padding:1px 7px;margin-left:8px;text-transform:none;letter-spacing:0;vertical-align:middle}
+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:var(--fg)}
#district.folded #grid{display:none}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
@@ -198,7 +198,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
Your Office Area
— hover any card for the full explanation
-
+
diff --git a/src/web/sound.ts b/src/web/sound.ts
index da9e004..29e1548 100644
--- a/src/web/sound.ts
+++ b/src/web/sound.ts
@@ -4,133 +4,108 @@
* SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static
* folder that fetches nothing — there is a test asserting no page reaches an external host — so an
* audio file would have to be committed to the repo, and a plausible-sounding whistle is not
- * something to invent. The Web Audio API can build these from oscillators at no download cost, and
- * what it produces is honestly synthetic rather than a fake recording.
+ * something to invent. See TODO for sourcing real clips; these stand in until then, which is why
+ * sound defaults to off.
*
- * A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers
- * slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials
- * and a band of noise is most of the way there.
+ * SELF-CONTAINED ON PURPOSE, like the two board renderers. The playable page imports this; the
+ * replay is a single HTML file with an inline script and cannot import anything, so it embeds the
+ * function via `Function.toString()`. One implementation either way — a second copy would drift,
+ * and a replay that sounds different from the game is worse than a replay with no sound.
*
- * "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
- * into the browser, needs no asset and works offline, so it says the words — and where the platform
- * has no voice installed, a two-note call takes its place rather than nothing happening.
+ * The AudioContext is parked on `window` rather than in a module variable for the same reason: the
+ * embedded copy has no module scope to keep it in, and browsers permit only a handful per page.
*/
+export function playCue(cue: string): void {
+ const w = window as unknown as { __smAudio?: AudioContext; AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
-let ctx: AudioContext | null = null;
-
-/**
- * Browsers refuse to start audio until the player has interacted with the page, so the context is
- * created on first use — by which time they have clicked something — and resumed if it was
- * suspended in the meantime.
- */
-function audio(): AudioContext | null {
- try {
- const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
- if (!Ctor) return null;
- ctx ??= new Ctor();
- if (ctx.state === 'suspended') void ctx.resume();
- return ctx;
- } catch {
- return null;
- }
-}
-
-/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */
-function tone(
- c: AudioContext,
- at: number,
- freq: number,
- dur: number,
- gain: number,
- type: OscillatorType = 'sine',
- bendTo?: number,
-): void {
- const osc = c.createOscillator();
- const amp = c.createGain();
- osc.type = type;
- osc.frequency.setValueAtTime(freq, at);
- if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur);
- amp.gain.setValueAtTime(0, at);
- amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4));
- amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
- osc.connect(amp).connect(c.destination);
- osc.start(at);
- osc.stop(at + dur + 0.02);
-}
-
-/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */
-function breath(c: AudioContext, at: number, dur: number, gain: number): void {
- const frames = Math.floor(c.sampleRate * dur);
- 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, at);
- amp.gain.linearRampToValueAtTime(gain, at + 0.05);
- amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
- src.connect(band).connect(amp).connect(c.destination);
- src.start(at);
- src.stop(at + dur);
-}
-
-/** End of a Stage. A three-chime whistle, one long call. */
-export function whistle(): void {
- const c = audio();
- if (!c) return;
- const t = c.currentTime + 0.02;
- const d = 0.9;
- // A minor-third-ish cluster, deliberately not in tune with itself — that beating IS the sound.
- for (const [f, g] of [[392, 0.16], [466, 0.13], [587, 0.10], [784, 0.05]] as const) {
- tone(c, t, f, d, g, 'sawtooth', f * 0.985);
- }
- breath(c, t, d, 0.05);
-}
-
-/** End of a Day. The grade-crossing bell: two strikes, metallic and inharmonic. */
-export function clang(): void {
- const c = audio();
- if (!c) return;
- const t = c.currentTime + 0.02;
- for (const at of [t, t + 0.22]) {
- // Inharmonic partials are what separate a bell from a beep.
- for (const [f, g] of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]] as const) {
- tone(c, at, f, 0.4, g, 'square');
+ // "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
+ // into the browser, needs no asset and works offline — but it is whatever system voice the player
+ // has, so a real clip is the first thing to replace here.
+ if (cue === 'train') {
+ try {
+ if (window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') {
+ const u = new SpeechSynthesisUtterance('All aboard!');
+ u.rate = 0.95;
+ u.pitch = 0.85;
+ u.volume = 0.9;
+ window.speechSynthesis.speak(u);
+ return;
+ }
+ } catch {
+ // no voice available — fall through to the two-note call below
}
}
-}
-/** A train is added to the board. */
-export function allAboard(): void {
+ let c: AudioContext;
try {
- const s = window.speechSynthesis;
- if (s && typeof SpeechSynthesisUtterance === 'function') {
- const u = new SpeechSynthesisUtterance('All aboard!');
- u.rate = 0.95;
- u.pitch = 0.85;
- u.volume = 0.9;
- s.speak(u);
- return;
- }
+ const Ctor = w.AudioContext ?? w.webkitAudioContext;
+ if (!Ctor) return;
+ // Browsers refuse to start audio until the player has interacted with the page, so the context
+ // is made on first use — by which time they have clicked something — and resumed if suspended.
+ w.__smAudio ??= new Ctor();
+ c = w.__smAudio;
+ if (c.state === 'suspended') void c.resume();
} catch {
- // fall through to the call below
+ return;
}
- // No voice on this platform: a two-note conductor's call rather than silence.
- const c = audio();
- if (!c) return;
- const t = c.currentTime + 0.02;
- tone(c, t, 523, 0.22, 0.14, 'triangle');
- tone(c, t + 0.2, 784, 0.34, 0.14, 'triangle');
-}
-/** Cue names the model emits; the view decides what they sound like. */
-export function play(cue: string): void {
- if (cue === 'stage') whistle();
- else if (cue === 'day') clang();
- else if (cue === 'train') allAboard();
+ /** A tone with an envelope, so nothing starts or stops with a click. */
+ const tone = (at: number, freq: number, dur: number, gain: number, type: OscillatorType, bendTo?: number): void => {
+ const osc = c.createOscillator();
+ const amp = c.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, at);
+ if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur);
+ amp.gain.setValueAtTime(0, at);
+ amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4));
+ amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
+ osc.connect(amp).connect(c.destination);
+ osc.start(at);
+ osc.stop(at + dur + 0.02);
+ };
+
+ const t = c.currentTime + 0.02;
+
+ if (cue === 'stage') {
+ // A real steam whistle is a CHORD of several chambers slightly out of tune with each other,
+ // plus the breath of the steam. The beating between the partials IS the sound.
+ const d = 1.8;
+ 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);
+ 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]) {
+ for (const fg of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]]) {
+ tone(at, fg[0]!, 0.4, fg[1]!, 'square');
+ }
+ }
+ return;
+ }
+
+ if (cue === 'train') {
+ // Reached only when the platform has no voice: a two-note conductor's call rather than silence.
+ tone(t, 523, 0.22, 0.14, 'triangle');
+ tone(t + 0.2, 784, 0.34, 0.14, 'triangle');
+ }
}
diff --git a/test/replay.test.ts b/test/replay.test.ts
index bfa003f..fb3c022 100644
--- a/test/replay.test.ts
+++ b/test/replay.test.ts
@@ -231,7 +231,21 @@ describe('replay HTML', () => {
const els = new Map>();
const stub = {
getElementById: (id: string) => {
- if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
+ if (!els.has(id)) {
+ // A faithful-enough element. `classList` matters: the page folds the district panel by
+ // toggling a class, and a stub without one throws — which is a page that never renders,
+ // not a cosmetic gap.
+ const classes = new Set();
+ els.set(id, {
+ textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null,
+ classList: {
+ add: (c: string) => void classes.add(c),
+ remove: (c: string) => void classes.delete(c),
+ contains: (c: string) => classes.has(c),
+ },
+ querySelector: () => null,
+ });
+ }
return els.get(id);
},
};
@@ -287,7 +301,21 @@ describe('replay HTML', () => {
const els = new Map>();
const stub = {
getElementById: (id: string) => {
- if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
+ if (!els.has(id)) {
+ // A faithful-enough element. `classList` matters: the page folds the district panel by
+ // toggling a class, and a stub without one throws — which is a page that never renders,
+ // not a cosmetic gap.
+ const classes = new Set();
+ els.set(id, {
+ textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null,
+ classList: {
+ add: (c: string) => void classes.add(c),
+ remove: (c: string) => void classes.delete(c),
+ contains: (c: string) => classes.has(c),
+ },
+ querySelector: () => null,
+ });
+ }
return els.get(id);
},
};
@@ -350,3 +378,41 @@ describe('the replay wire format is lossless (regression)', () => {
);
});
});
+
+
+describe('the replay behaves like the game it is replaying', () => {
+ it('carries the sounds each frame earned, and the one implementation that plays them', () => {
+ // A replay is for watching a game back, so it should sound and fold like the game. Both use the
+ // SAME functions — `cuesFor` decides what happened, `playCue` decides what it sounds like —
+ // embedded by toString() exactly as the board renderers are. A second copy would drift, and a
+ // replay that sounds different from the game is worse than a replay with no sound.
+ const rec = record(880009, 'standard', 4000);
+ const withCues = rec.frames.filter((f) => (f.cues?.length ?? 0) > 0);
+ assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
+
+ const kinds = new Set(withCues.flatMap((f) => f.cues ?? []));
+ assert.ok(kinds.has('stage'), 'no Stage ever sounded');
+ assert.ok(kinds.has('day'), 'no Day ever sounded');
+ assert.ok(kinds.has('train'), 'no train was ever announced');
+
+ // One cue per Stage boundary, the bell replacing the whistle at a Day — the same accounting the
+ // live game is held to.
+ let boundaries = 0;
+ for (let k = 1; k < rec.frames.length; k++) {
+ const a = rec.frames[k - 1]!;
+ const b = rec.frames[k]!;
+ if (a.day !== b.day || a.stage !== b.stage) boundaries++;
+ }
+ const clock = withCues.flatMap((f) => f.cues ?? []).filter((c) => c !== 'train').length;
+ assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
+ });
+
+ it('serves the controls for both, and embeds the player', () => {
+ const html = renderHtml(record(880009, 'standard', 900));
+ for (const id of ['sound', 'districttoggle', 'districtsummary']) {
+ assert.ok(html.includes(`id="${id}"`), `the replay has no #${id}`);
+ }
+ assert.match(html, /const playCue = function/, 'the sound player is referenced but not embedded');
+ assert.match(html, /#district\.folded/, 'nothing folds the district away');
+ });
+});
diff --git a/test/web.test.ts b/test/web.test.ts
index 5d4e9fe..6c14d38 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -16,6 +16,7 @@ import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
+ overHandLimit,
actionGroups,
actionMenu,
handPlayable,
@@ -1122,3 +1123,71 @@ describe('the sounds fire on the events they name', () => {
assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had');
});
});
+
+describe('the hand limit is a limit, not a toll on drawing (regression)', () => {
+ it('only blocks the end of a turn when more than three cards are held', () => {
+ // REGRESSION. The page set "must play a card" on ANY draw, so a player who had already played
+ // two cards and drew back to three was still forced to spend one. §6.2 is a hand LIMIT —
+ // "reduce his hand to no more than three cards" — and the engine says the same: `draw.end` is
+ // refused with HAND_LIMIT and otherwise allowed.
+ const game = newGame(555);
+ const hand = game.state.decks.hands.get(0)!;
+
+ hand.length = 0;
+ assert.equal(overHandLimit(game), false, 'an empty hand blocked the turn from ending');
+
+ const ids = [...game.state.cards.keys()].slice(0, 5);
+ hand.push(...ids.slice(0, 3));
+ assert.equal(overHandLimit(game), false, 'three cards is the limit, not over it');
+
+ hand.push(ids[3]!);
+ assert.equal(overHandLimit(game), true, 'four cards must be played down');
+
+ // A Red Flag raises the limit by one (§6.2).
+ game.state.decks.redFlags.set(0, true);
+ assert.equal(overHandLimit(game), false, 'a Red Flag allows a fourth card');
+ hand.push(ids[4]!);
+ assert.equal(overHandLimit(game), true, 'five cards is over even with a Red Flag');
+ });
+
+ it('agrees with the engine about when the turn may end', () => {
+ // Two statements of one rule is how they drift. This asserts they cannot.
+ const game = newGame(909);
+ for (let i = 0; i < 400; i++) {
+ if (currentActor(game) === null) break;
+ const { options, groups } = actionGroups(game);
+ if (groups.length === 0 || options.length === 0) break;
+ if (game.state.clock.phase === 'localOps' && game.state.turn.option === 'draw') {
+ const engineAllows = options.some((o) => o.type === 'draw.end');
+ assert.equal(
+ engineAllows,
+ !overHandLimit(game),
+ 'the page and the engine disagree about whether the turn may end',
+ );
+ }
+ submit(game, options[groups[0]!.actions[0]!.index]!);
+ }
+ });
+});
+
+describe('a train says what its card calls for', () => {
+ it('explains a consist that offers only one kind of car', () => {
+ // Reported from playtesting on seed 22222: Extra X22 offered a caboose and nothing else, with
+ // no reason given. The rules are right — "Pee-Dee" is a per-diem train whose consist is one
+ // caboose and no cars — but the screen never said so, which reads as a broken game.
+ const game = newGame(22222);
+ const titles = new Set();
+ for (let i = 0; i < 3000; i++) {
+ if (currentActor(game) === null) break;
+ const { options, groups } = actionGroups(game);
+ if (groups.length === 0 || options.length === 0) break;
+ for (const g of groups) if (g.kind === 'newTrain.') titles.add(g.title);
+ submit(game, options[groups[0]!.actions[0]!.index]!);
+ }
+ assert.ok(titles.size > 0, 'no train was ever made up');
+ for (const t of titles) {
+ assert.match(t, /its card calls for/, `a train was made up with no consist explained: ${t}`);
+ assert.ok(/“[^”]+”/.test(t), `the train is not named: ${t}`);
+ }
+ });
+});