v0.8.0.2 — the speed control that was only ever a URL parameter, and a Day-end

contradiction

Two things found by playing v0.8.0.1, neither in the mechanism itself.

?pace= never worked. index.html's doors are play.html?lobby and
play.html?solitaire, so arriving through the splash replaces the query string and
the play page only ever saw ?lobby — a whole game was played at 1x while believing
it was at 7x. v0.8.0 shipped that parameter as the only way to change speed and the
game's own front door destroyed it. There is a control on the play screen now,
beside zoom, persisted per viewer; the doors carry pace through as well, so the URL
lever is honest for handing two playtesters different speeds. PACE_LEVELS moved to
sim/pacing.ts with DWELL and MAX_PACE — the tuning surface in one file, and
testable. The committed default is unchanged: what it should be is a question for a
game played at a speed that took effect.

And the Day-end dialog said "0 today, 2 in all". advance.ts increments the Day and
then zeroes collisionsToday, and noteDayEnd() fires when the Day goes up — so the
dialog reporting the Day that just finished was drawn from the very frame in which
that Day's count was reset. Reproduced on four of five seeds before changing
anything. The count is captured at the rollover now; it is not derivable on the
client, because in multiplayer the push announcing the new Day is the same push
that carries the reset. And "today" was the wrong word regardless: it names the Day
instead — "Collisions: 2 on Day 1, 2 in all".

Unrelated to v0.8.0 — that one has been wrong since the dialog was built for
Gitea#10, and needed somebody to play a Day with a collision in it and then read
the summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
Jesse.Markowitz
2026-09-09 20:08:39 -04:00
co-authored by Claude Opus 5
parent 0cfeb4c496
commit c10f52791e
15 changed files with 278 additions and 10 deletions
+72
View File
@@ -19,6 +19,78 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.0.2 — 2026-09-09
Two things found by playing v0.8.0.1 on `phoenix.local`, neither of them in the mechanism itself.
### `?pace=` never worked, and a whole game was played at the wrong speed
Jesse: *"I'm playing at pace = 7, and the bots are still moving too fast for me to follow."* At 7×
a switching move holds for seven seconds, so that could not be calibration — and it was not. **He was
at 1× the entire time.**
`index.html`'s two doors are `./play.html?lobby` and `./play.html?solitaire`. Arriving through the
splash therefore **replaces** the query string, and `location.search` on the play page is `?lobby` —
so `PACE_OVERRIDE` was null and it fell back to the stored setting of 1. v0.8.0 shipped `?pace=` as
the only way to change speed and the game's own front door destroyed it. Verified rather than
assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so
the mechanism was right and the value never arrived.
Fixed twice over, because one of them is the durable answer:
- **A speed control on the play screen**, beside zoom — `− 1× +`, persisted per viewer, reading
through to the queue on the very next move. `PACE_LEVELS` is `0, 0.5, 1, 2, 3, 5, 7, 10`: off is
the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the
ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a
full minute to watch.
- **The doors now carry `pace` through**, so the URL lever is honest for handing two playtesters
different speeds — the only thing it was ever for. When one is present the control says
`7× (URL)` and disables itself rather than showing buttons that do nothing.
`PACE_LEVELS` lives in `sim/pacing.ts` with `DWELL` and `MAX_PACE`, not in `main.ts` — the whole
tuning surface in one file, and testable, which a constant inside the page entry point is not.
**The committed default is unchanged at 1×.** What it should be is a question for a game played at a
speed that actually took effect.
### "0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with
two collisions in it: *"It shows a total of two collisions, but zero today. Since we just finished day
one, that does seem to be a contradiction."* Unrelated to v0.8.0 — this has been wrong since the
dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the
summary.
#### One line of ordering
`advance.ts`, at the rollover:
```ts
s.clock.day += 1;
s.collisionsToday = 0;
```
And `noteDayEnd()` fires when `f.day` goes UP — so the dialog reporting the Day that just finished is
drawn from the very frame in which that Day's count was zeroed. It printed the *new* Day's zero beside
a running total that could not possibly agree with it. Reproduced on four of five seeds before
touching anything: Day 1 ended with `today=3 total=3`, and the dialog read `today=0 total=3`.
**Not derivable on the client, which is why the fix is in the engine.** A Day turns over inside the
phases that run themselves, so in multiplayer the push announcing the new Day is the same push that
carries the reset — a client may never see the ended Day's final count to remember it. So
`collisionsPrevDay` is captured in state at the rollover, immediately before the reset, and rides on
the frame like the other two counts.
#### And "today" was the wrong word anyway
Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then
"today" is Day 2. It now names the Day: **"Collisions: 2 on Day 1, 2 in all."** The end-of-game
results screen passes no Day and keeps "today", where the Day has not turned over and the word is
accurate.
`test/redaction.test.ts`'s allow-list did its job on the way through: adding a public property failed
the suite until it was declared out loud.
---
## 0.8.0.1 — 2026-09-09 ## 0.8.0.1 — 2026-09-09
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour; **Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour;
+4 -3
View File
@@ -361,14 +361,15 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
> **Read those two, not this**, when building steps 2-7. The differences that matter: > **Read those two, not this**, when building steps 2-7. The differences that matter:
> >
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts` > - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
> object. Their contents sit at the top level. **All 37 properties, which is the same list as > object. Their contents sit at the top level. **All 38 properties, which is the same list as
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`, > `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`, > `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`, > `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`, > `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`, > `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
> `extensionVotes`,
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`, > `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added > `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
> by `PublicFrame` itself. > by `PublicFrame` itself.
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it > - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second, > in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.1", "version": "0.8.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+3
View File
@@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
s.clock.day += 1; s.clock.day += 1;
s.clock.stage = 1; s.clock.stage = 1;
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
s.collisionsPrevDay = s.collisionsToday;
s.collisionsToday = 0; s.collisionsToday = 0;
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
rotateSeats(s, events); rotateSeats(s, events);
+1
View File
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(), movedThisPhase: new Set(),
collisionsToday: 0, collisionsToday: 0,
collisionsPrevDay: 0,
collisionsTotal: 0, collisionsTotal: 0,
status: 'active', status: 'active',
outcome: null, outcome: null,
+14
View File
@@ -1119,6 +1119,20 @@ export type GameState = {
movedThisPhase: Set<TrayId>; movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */ /** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number; collisionsToday: number;
/**
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
* before the reset.
*
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
*
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
* may never see the ended Day's final count to remember it.
*/
collisionsPrevDay: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */ /** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number; collisionsTotal: number;
/** /**
+12
View File
@@ -148,6 +148,18 @@ export function kindOf(cause: StepCause): StepKind {
*/ */
export const MAX_PACE = 10; export const MAX_PACE = 10;
/**
* The speeds the on-screen control offers, slowest last.
*
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes.
*/
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
/** /**
* How long to show one step, in ms, at a given speed. * How long to show one step, in ms, at a given speed.
* *
+3
View File
@@ -437,6 +437,8 @@ export type Frame = {
maxCollisionsPerDay: number; maxCollisionsPerDay: number;
maxCollisionsTotal: number; maxCollisionsTotal: number;
collisionsToday: number; collisionsToday: number;
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
collisionsPrevDay: number;
collisionsTotal: number; collisionsTotal: number;
status: GameState['status']; status: GameState['status'];
outcome: GameState['outcome']; outcome: GameState['outcome'];
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
maxCollisionsPerDay: s.config.maxCollisionsPerDay, maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal, maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday, collisionsToday: s.collisionsToday,
collisionsPrevDay: s.collisionsPrevDay,
collisionsTotal: s.collisionsTotal, collisionsTotal: s.collisionsTotal,
status: s.status, status: s.status,
outcome: s.outcome, outcome: s.outcome,
+48
View File
@@ -26,6 +26,7 @@ import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts'; import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts'; import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts'; import { createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts'; import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts'; import type { LobbyReady } from './lobby.ts';
import { import {
@@ -53,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
/** /**
* Small persisted preferences, kept in a `localStorage` key of their own — separate from * Small persisted preferences, kept in a `localStorage` key of their own — separate from
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
@@ -2603,6 +2605,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
} }
/**
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
*
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
* very next move rather than the next game.
*/
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
const paceLabel = document.getElementById('pacelabel');
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
const nearestPace = (): number => {
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
const want = PACE_OVERRIDE ?? settings.pace;
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
};
const paintPace = (): void => {
const p = PACE_OVERRIDE ?? settings.pace;
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
paceFasterBtn.disabled = i <= 0;
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
if (PACE_OVERRIDE !== null) {
paceSlowerBtn.disabled = true;
paceFasterBtn.disabled = true;
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
}
};
const stepPace = (by: number): void => {
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
if (next === undefined) return;
saveSettings({ pace: next });
paintPace();
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
renderWatching();
};
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
// not to the number: a player pressing "slower" wants to watch for longer.
paceSlowerBtn.onclick = () => stepPace(1);
paceFasterBtn.onclick = () => stepPace(-1);
paintPace();
}
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null; const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null; const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel'); const zoomLabel = document.getElementById('zoomlabel');
+20 -5
View File
@@ -180,7 +180,7 @@ export function dayEndHtml(f: Frame): string {
ahead + ahead +
standingsHtml(f) + standingsHtml(f) +
targetHtml(f) + targetHtml(f) +
collisionsHtml(f) collisionsHtml(f, ended)
); );
} }
@@ -238,13 +238,28 @@ function targetHtml(f: Frame): string {
* its config and enforces neither, so reporting a collision budget there would put a rule on * its config and enforces neither, so reporting a collision budget there would put a rule on
* screen that this game does not have. * screen that this game does not have.
*/ */
function collisionsHtml(f: Frame): string { function collisionsHtml(f: Frame, endedDay?: number): string {
const scoredOnCollisions = const scoredOnCollisions =
(f.mode === 'competitive' || f.mode === 'coop') && (f.mode === 'competitive' || f.mode === 'coop') &&
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
return scoredOnCollisions if (!scoredOnCollisions) return '';
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>` /**
: ''; * "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
*
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
* today ... that does seem to be a contradiction."
*
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
* not turned over and the word is accurate.
*/
const [count, when] =
endedDay === undefined
? [f.collisionsToday, 'today']
: [f.collisionsPrevDay, `on Day ${endedDay}`];
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
} }
/** /**
+8
View File
@@ -872,6 +872,14 @@ ul.blocked li{padding:2px 0}
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size."> <span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button> <button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span> </span>
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
game believing he was at 7x when he was at 1x. -->
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button> <button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button> <button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button> <button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
+23
View File
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and * other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. * recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/ */
/**
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
*
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
* for.
*/
try {
const pace = new URLSearchParams(location.search).get('pace');
if (pace !== null) {
for (const door of Array.from(document.querySelectorAll('a.door'))) {
const href = door.getAttribute('href');
// Only the doors into the game, and only ones that have not been disabled above.
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
}
}
} catch {
// A door that keeps its own href is the status quo, not a broken page.
}
const mpDoor = document.getElementById('door-multiplayer'); const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) { if (mpDoor) {
const close = (): void => { const close = (): void => {
+26 -1
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { DWELL, MAX_PACE, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts'; import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts'; import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts'; import type { Intent } from '../src/engine/intents.ts';
@@ -120,6 +120,31 @@ describe('pacing — dwell by kind', () => {
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind'); assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
}); });
it('offers speeds a player actually reached for, and none the code would clamp', () => {
/**
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
* this ladder — which must reach the speeds people ask for and must not offer one that
* `dwellFor` would silently clamp.
*/
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
}
// Strictly increasing, so stepping the control always changes the speed.
for (let i = 1; i < PACE_LEVELS.length; i++) {
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
}
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
// the ladder is a full minute, which is the "watch them struggle" case.
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
});
it('a silent step beats only when the clock turns over — TODO #18', () => { it('a silent step beats only when the clock turns over — TODO #18', () => {
/** /**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past * Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
+3
View File
@@ -524,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
// The rules the game was dealt under, and the score. // The rules the game was dealt under, and the score.
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue', 'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal', 'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
// What the Day that just ended finished on. Public for the same reason the running counts are:
// a collision happens on the Mainline in front of everybody.
'collisionsPrevDay',
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally', 'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
// Names, seats, revenue and HAND SIZE — never hand contents. // Names, seats, revenue and HAND SIZE — never hand contents.
'players', 'players',
+40
View File
@@ -3614,6 +3614,46 @@ describe('the Day rolling over says so (Gitea#10)', () => {
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`); assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
}); });
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
/**
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
* collisions, but zero today. Since we just finished day one, that does seem to be a
* contradiction."*
*
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
*/
const s = createEngineGame({
id: 'collide',
seed: 5,
config: {
mode: 'competitive',
days: 5,
minCombinedRevenue: 60,
maxCollisionsPerDay: 3,
maxCollisionsTotal: 10,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
s.clock.day = 2;
s.collisionsPrevDay = 2;
s.collisionsToday = 0;
s.collisionsTotal = 2;
const html = dayEndHtml(snapshot(s, [], null));
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
});
it('counts the last Day as the last Day rather than promising more', () => { it('counts the last Day as the last Day rather than promising more', () => {
const html = dayEndHtml(frameAt(6)); const html = dayEndHtml(frameAt(6));
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed'); assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');