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

Division — west to east

-

Office Area

+

Office Area

+
+

Facilities

Timetable — which train is due out at each Stage

@@ -372,6 +389,7 @@ 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}`); + } + }); +});