Fixed division end of track labels and other minor bugs. Trains now show their information. Play cards now required to get to 3 or less, no other restrictions. auto hide now more prominent. it and sounds now are in replay.

This commit is contained in:
Jesse
2026-08-04 13:43:28 -04:00
parent c3c5c59a45
commit 4bba991f41
11 changed files with 466 additions and 161 deletions
+53
View File
@@ -10,6 +10,59 @@ The target is 20 Revenue over 5 Days.
## Unreleased ## 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 ### A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on Reported from playtesting: laying a straight offered three places in the list and highlighted one on
+6 -1
View File
@@ -51,7 +51,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
const CW = { dp: 118, ml: 152, run: 78 }; const CW = { dp: 118, ml: 152, run: 78 };
const CH = 58; const CH = 58;
const GAP = 6; 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 SIDE_GAP = 34;
const esc = (t: string): string => const esc = (t: string): string =>
+22
View File
@@ -536,3 +536,25 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where
} }
export { coordKey }; 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;
}
+65 -4
View File
@@ -28,7 +28,7 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts'; import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts'; import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.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 // 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. // 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'; 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 type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts'; import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
import { playCue } from '../web/sound.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws // 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 !== null &&
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') && (d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged'); 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( 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.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.empty{background:transparent;color:var(--dim)} .box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto} .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} .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-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)} .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
<div class="wrap"> <div class="wrap">
<div> <div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section> <section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
<section><h2>Office Area</h2><div class="grid" id="grid"></div></section> <section id="district"><h2>Office Area <button id="districttoggle" class="ghost">auto-hide: on</button></h2>
<div id="districtsummary" class="dim"></div>
<div class="grid" id="grid"></div></section>
<section><h2>Facilities</h2><div id="facs"></div></section> <section><h2>Facilities</h2><div id="facs"></div></section>
<section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section> <section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
</div> </div>
@@ -372,6 +389,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<footer> <footer>
<button id="first">⏮ start</button> <button id="first">⏮ start</button>
<button id="sound">🔇 muted</button>
<button id="back">◀ step</button> <button id="back">◀ step</button>
<button id="play">▶ play</button> <button id="play">▶ play</button>
<button id="fwd">step ▶</button> <button id="fwd">step ▶</button>
@@ -396,6 +414,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. */ from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()}; const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()}; const officeSvg = ${officeSvg.toString()};
const playCue = ${playCue.toString()};
const PACKED = ${JSON.stringify(compress(rec.frames))}; const PACKED = ${JSON.stringify(compress(rec.frames))};
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats; const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
@@ -409,6 +428,11 @@ function carry(i, key) {
const rehydrateCells = ${rehydrateCells.toString()}; const rehydrateCells = ${rehydrateCells.toString()};
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); } function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
let i = 0, timer = null; let i = 0, timer = null;
/* Sound and auto-hide, the same two the playable page has — a replay is for watching a game back,
so it should behave like one. Both default to the quiet, tidy setting. */
let soundOn = false;
let districtMode = 'auto';
const FOCUS_PHASES = { localOps: 1, loadUnload: 1 };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@@ -459,6 +483,20 @@ function render() {
$('division').innerHTML = divisionSvg(DIV); $('division').innerHTML = divisionSvg(DIV);
// The same office renderer the playable app uses, so replay and game draw one board. // The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow); $('grid').innerHTML = officeSvg(CELLS, f.runningRow);
/* Auto-hide: the district is worth its space during the phases that change it, and folded
otherwise. A summary stays, because a panel that vanishes reads as broken. */
const districtOpen = districtMode === 'auto' ? !!FOCUS_PHASES[f.phaseKey] : districtMode === 'open';
const sec = $('district');
if (districtOpen) sec.classList.remove('folded'); else sec.classList.add('folded');
let carsStanding = 0;
for (const c of CELLS) carsStanding += c.cars.length;
$('districtsummary').textContent =
CELLS.length + ' cards · ' + FACS.length + ' facilities · ' + carsStanding + ' cars standing';
$('districttoggle').textContent =
districtMode === 'auto'
? (districtOpen ? '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';
if (f.where) { if (f.where) {
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]'); const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
if (hit) hit.classList.add('bs-focus'); if (hit) hit.classList.add('bs-focus');
@@ -521,9 +559,32 @@ function render() {
$('log').scrollTop = $('log').scrollHeight; $('log').scrollTop = $('log').scrollHeight;
} }
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); } /* Sounds fire only when moving FORWARD one frame at a time — stepping or playing. 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. */
function go(n) {
const from = i;
i = Math.max(0, Math.min(FRAMES.length - 1, n));
if (soundOn && i === from + 1) {
const cues = FRAMES[i].cues || [];
for (const c of cues) playCue(c);
}
render();
}
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); } function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
$('districttoggle').onclick = () => {
const open = districtMode === 'auto' ? !!FOCUS_PHASES[FRAMES[i].phaseKey] : districtMode === 'open';
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
render();
};
$('sound').onclick = () => {
soundOn = !soundOn;
$('sound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
/* Confirm audibly, and give the browser the user gesture it needs before audio may start. */
if (soundOn) playCue('stage');
};
$('ftot').textContent = FRAMES.length - 1; $('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1; $('scrub').max = FRAMES.length - 1;
$('scrub').oninput = (e) => go(+e.target.value); $('scrub').oninput = (e) => go(+e.target.value);
+7 -2
View File
@@ -155,6 +155,11 @@ export type Frame = {
phase: string; phase: string;
/** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */ /** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
phaseKey: string; phaseKey: string;
/**
* Sounds this frame earned — a Stage ending, a Day turning, a train being built. Filled by the
* replay recorder, which sees the events; the live game keeps its own on the Game object.
*/
cues?: string[];
actor: number | null; actor: number | null;
superintendent: number; superintendent: number;
revenue: number; revenue: number;
@@ -376,9 +381,9 @@ export function describeIntent(s: GameState, i: Intent): string {
case 'draw.fromHomeOffice': case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`; return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end': case 'draw.end':
return 'End my turn'; return 'End Local Operations';
case 'switch.end': case 'switch.end':
return 'End my switching turn'; return 'End Local Operations';
case 'loadUnload.end': case 'loadUnload.end':
return 'End my Cargo phase'; return 'End my Cargo phase';
case 'newTrain.passCar': case 'newTrain.passCar':
+56 -22
View File
@@ -29,7 +29,7 @@ import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts'; import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts'; import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { narrate } from '../sim/narrate.ts'; import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle. // which would pull node:fs into a browser bundle.
import { import {
@@ -40,6 +40,7 @@ import {
trainName, trainName,
variantLabel, variantLabel,
} from '../sim/view.ts'; } from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import type { TrackGeometry } from '../engine/content.ts'; import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts'; import { variantsFor } from '../engine/track.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
@@ -71,12 +72,12 @@ export type Game = {
/** Narrated lines, newest last. */ /** Narrated lines, newest last. */
log: { text: string; tone: string }[]; log: { text: string; tone: string }[];
/** /**
* Set when a card has been drawn this turn and not yet played or discarded. * True when the hand is OVER the limit, so the turn cannot end until it is played down.
* *
* §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible, * §6.2 is a hand limit — "reduce his hand to no more than three cards" — not a rule that a draw
* as a disabled button rather than a rejection after the click. UI state deliberately — putting * must be spent. An earlier version set this on any draw, which forced a play even when two cards
* it in the engine would change what the bot may do, and with it every balance figure measured * had already been played and the hand held three or fewer. Derived from the hand each render, so
* against the current rules. * it cannot drift out of step with what is actually held.
*/ */
mustPlayCard: boolean; mustPlayCard: boolean;
/** /**
@@ -173,7 +174,14 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
used.add(k); used.add(k);
return byKind.get(k) ?? []; return byKind.get(k) ?? [];
}); });
if (actions.length > 0) groups.push({ kind: prefix, title, actions }); if (actions.length > 0) {
// The New Train group names the TRAIN and what its card calls for. Without it, Extra X22
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
// single button to add a caboose and no reason why, which reads as a broken game rather than
// as the card doing exactly what it prints.
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
groups.push({ kind: prefix, title: headed, actions });
}
} }
// Anything the table above does not name still has to be offered — silently dropping a legal // Anything the table above does not name still has to be offered — silently dropping a legal
// action would make the game unplayable in a way that is very hard to notice. // action would make the game unplayable in a way that is very hard to notice.
@@ -283,6 +291,45 @@ function rotationNote(geometry: TrackGeometry | null, variant: number | undefine
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : ''; return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
} }
/**
* "Making up Extra X22 — Pee-Dee: 1 caboose (per-diem train, may only pick up MTs)".
*
* §8.2 lets a train depart with FEWER cars than its card lists but never with the wrong ones, so
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
* card the player can read.
*/
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
if (!first || !('trayId' in first)) return null;
const tray = game.state.trays.get(first.trayId);
if (!tray) return null;
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!p) return null;
const parts: string[] = [];
if (p.consist.freight > 0) {
const types = p.consist.freightTypes?.join('/') ?? 'freight';
parts.push(`${p.consist.freight} ${types}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
}
if (p.consist.coach > 0) parts.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`);
if (p.consist.caboose > 0) parts.push(`${p.consist.caboose} caboose`);
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
const note = p.rules.note ? ` — ${p.rules.note}` : '';
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`;
return `Making up ${name}: its card calls for ${calls}${note}`;
}
/**
* §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is
* held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the
* button with a reason instead of hiding a move that has simply become illegal.
*/
export function overHandLimit(game: Game): boolean {
const hand = game.state.decks.hands.get(0) ?? [];
const limit = game.state.decks.redFlags.get(0) ? HAND_LIMIT + 1 : HAND_LIMIT;
return hand.length > limit;
}
/** Submit an action. Returns false and changes nothing if the engine rejects it. */ /** Submit an action. Returns false and changes nothing if the engine rejects it. */
export function submit(game: Game, intent: Intent): boolean { export function submit(game: Game, intent: Intent): boolean {
const actor = currentActor(game); const actor = currentActor(game);
@@ -294,14 +341,8 @@ export function submit(game: Game, intent: Intent): boolean {
return false; return false;
} }
game.history.push(intent); game.history.push(intent);
if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
game.mustPlayCard = true;
} else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
// Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
// so nothing can be left outstanding from them.
game.mustPlayCard = false;
}
record(game, result.events, actor); record(game, result.events, actor);
game.mustPlayCard = overHandLimit(game);
drain(game); drain(game);
return true; return true;
} }
@@ -345,15 +386,8 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text; const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
game.log.push({ text, tone: mine ? 'act' : n.tone }); game.log.push({ text, tone: mine ? 'act' : n.tone });
if (e.type === 'trainMadeUp') game.cues.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 gets the bell only: sounding both there would
// collide, and the bell is the bigger event.
if (e.day > 1 && e.stage === 1) game.cues.push('day');
else if (e.day > 1 || e.stage > 1) game.cues.push('stage');
}
} }
game.cues.push(...cuesFor(events));
// Keep the log bounded; the full history lives in `history` and can be replayed. // Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400); if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
} }
+24 -9
View File
@@ -7,7 +7,7 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { play } from './sound.ts'; import { playCue } from './sound.ts';
import type { Game } from './game.ts'; import type { Game } from './game.ts';
import { import {
actionMenu, actionMenu,
@@ -292,7 +292,7 @@ function render(): void {
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off. // not then play a backlog of everything that happened while it was off.
const cues = game.cues.splice(0, game.cues.length); const cues = game.cues.splice(0, game.cues.length);
if (soundOn) for (const c of cues) play(c); if (soundOn) for (const c of cues) playCue(c);
save(); save();
} }
@@ -309,10 +309,17 @@ function renderDistrict(f: ReturnType<typeof view>): void {
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` + `${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : ''); (crew > 0 ? ` · ${crew} crew on the board` : '');
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
// status line and was missed entirely; "always show" is an instruction.
const btn = $('districttoggle'); const btn = $('districttoggle');
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode; btn.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';
btn.onclick = () => { btn.onclick = () => {
// auto -> the opposite of what it is doing now -> back to auto. // auto -> pin it to the opposite of what auto is doing -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto'; districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
render(); render();
}; };
@@ -382,10 +389,7 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on // §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
// the INTENT, not the label: matching button text would break the moment the wording // the INTENT, not the label: matching button text would break the moment the wording
// changed, and would have caught `switch.end` too. // changed, and would have caught `switch.end` too.
const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard; return actionButton(a.label, a.index);
return blocked
? `<button class="act blocked" disabled data-tip="You have drawn a card. Play it or discard it to a Department slot before ending your turn — §6.2 holds you to three cards.">${esc(a.label)} — play or discard first</button>`
: actionButton(a.label, a.index);
}) })
.join('') + .join('') +
`</div>`, `</div>`,
@@ -417,6 +421,17 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
} }
html += `</div>`; html += `</div>`;
} }
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 +=
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
}
el.innerHTML = html; el.innerHTML = html;
for (const b of Array.from(el.querySelectorAll('button.act'))) { for (const b of Array.from(el.querySelectorAll('button.act'))) {
@@ -502,7 +517,7 @@ if (soundBtn) {
paint(); paint();
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it // 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. // doubles as the user gesture the browser needs before any audio may start.
if (soundOn) play('stage'); if (soundOn) playCue('stage');
}; };
paint(); paint();
} }
+3 -3
View File
@@ -102,8 +102,8 @@ button:hover{background:#39424e;border-color:#4d6fa8}
button.act{display:inline-block} button.act{display:inline-block}
/* The district collapses when nothing in it is changing. A summary line stays, because a panel /* 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. */ 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; button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
padding:1px 7px;margin-left:8px;text-transform:none;letter-spacing:0;vertical-align:middle} padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)} button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#district.folded #grid{display:none} #district.folded #grid{display:none}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px} #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
<section id="district"> <section id="district">
<h2>Your Office Area <h2>Your Office Area
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span> <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<button id="districttoggle" class="ghost" title="Auto-focus opens the district during the phases that change it">auto</button> <button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
</h2> </h2>
<div id="districtsummary" class="dim"></div> <div id="districtsummary" class="dim"></div>
<div id="grid"></div> <div id="grid"></div>
+70 -95
View File
@@ -4,47 +4,53 @@
* SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static * 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 * 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 * 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 * something to invent. See TODO for sourcing real clips; these stand in until then, which is why
* what it produces is honestly synthetic rather than a fake recording. * sound defaults to off.
* *
* A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers * SELF-CONTAINED ON PURPOSE, like the two board renderers. The playable page imports this; the
* slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials * replay is a single HTML file with an inline script and cannot import anything, so it embeds the
* and a band of noise is most of the way there. * 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 * The AudioContext is parked on `window` rather than in a module variable for the same reason: the
* into the browser, needs no asset and works offline, so it says the words — and where the platform * embedded copy has no module scope to keep it in, and browsers permit only a handful per page.
* has no voice installed, a two-note call takes its place rather than nothing happening.
*/ */
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; // "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.
* Browsers refuse to start audio until the player has interacted with the page, so the context is if (cue === 'train') {
* 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 { try {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') {
if (!Ctor) return null; const u = new SpeechSynthesisUtterance('All aboard!');
ctx ??= new Ctor(); u.rate = 0.95;
if (ctx.state === 'suspended') void ctx.resume(); u.pitch = 0.85;
return ctx; u.volume = 0.9;
window.speechSynthesis.speak(u);
return;
}
} catch { } catch {
return null; // no voice available — fall through to the two-note call below
}
} }
}
/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */ let c: AudioContext;
function tone( try {
c: AudioContext, const Ctor = w.AudioContext ?? w.webkitAudioContext;
at: number, if (!Ctor) return;
freq: number, // Browsers refuse to start audio until the player has interacted with the page, so the context
dur: number, // is made on first use — by which time they have clicked something — and resumed if suspended.
gain: number, w.__smAudio ??= new Ctor();
type: OscillatorType = 'sine', c = w.__smAudio;
bendTo?: number, if (c.state === 'suspended') void c.resume();
): void { } catch {
return;
}
/** 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 osc = c.createOscillator();
const amp = c.createGain(); const amp = c.createGain();
osc.type = type; osc.type = type;
@@ -56,11 +62,18 @@ function tone(
osc.connect(amp).connect(c.destination); osc.connect(amp).connect(c.destination);
osc.start(at); osc.start(at);
osc.stop(at + dur + 0.02); osc.stop(at + dur + 0.02);
} };
/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */ const t = c.currentTime + 0.02;
function breath(c: AudioContext, at: number, dur: number, gain: number): void {
const frames = Math.floor(c.sampleRate * dur); 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 buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
const data = buf.getChannelData(0); const data = buf.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1; for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
@@ -71,66 +84,28 @@ function breath(c: AudioContext, at: number, dur: number, gain: number): void {
band.frequency.value = 1400; band.frequency.value = 1400;
band.Q.value = 0.8; band.Q.value = 0.8;
const amp = c.createGain(); const amp = c.createGain();
amp.gain.setValueAtTime(0, at); amp.gain.setValueAtTime(0, t);
amp.gain.linearRampToValueAtTime(gain, at + 0.05); amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur); amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
src.connect(band).connect(amp).connect(c.destination); src.connect(band).connect(amp).connect(c.destination);
src.start(at); src.start(t);
src.stop(at + dur); src.stop(t + d);
}
/** 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');
}
}
}
/** A train is added to the board. */
export function allAboard(): void {
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; return;
} }
} catch {
// fall through to the call below
}
// 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. */ if (cue === 'day') {
export function play(cue: string): void { // The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
if (cue === 'stage') whistle(); for (const at of [t, t + 0.22]) {
else if (cue === 'day') clang(); for (const fg of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]]) {
else if (cue === 'train') allAboard(); 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');
}
} }
+68 -2
View File
@@ -231,7 +231,21 @@ describe('replay HTML', () => {
const els = new Map<string, Record<string, unknown>>(); const els = new Map<string, Record<string, unknown>>();
const stub = { const stub = {
getElementById: (id: string) => { 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<string>();
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); return els.get(id);
}, },
}; };
@@ -287,7 +301,21 @@ describe('replay HTML', () => {
const els = new Map<string, Record<string, unknown>>(); const els = new Map<string, Record<string, unknown>>();
const stub = { const stub = {
getElementById: (id: string) => { 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<string>();
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); 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');
});
});
+69
View File
@@ -16,6 +16,7 @@ import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { snapshot } from '../src/sim/view.ts'; import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts'; import { createGame as createEngineGame } from '../src/engine/setup.ts';
import { import {
overHandLimit,
actionGroups, actionGroups,
actionMenu, actionMenu,
handPlayable, 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'); 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<string>();
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}`);
}
});
});