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
+68 -2
View File
@@ -231,7 +231,21 @@ describe('replay HTML', () => {
const els = new Map<string, Record<string, unknown>>();
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<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);
},
};
@@ -287,7 +301,21 @@ describe('replay HTML', () => {
const els = new Map<string, Record<string, unknown>>();
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<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);
},
};
@@ -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 { 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<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}`);
}
});
});