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
+48
View File
@@ -26,6 +26,7 @@ import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
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. */
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
* `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 ?? '');
}
/**
* 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 zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel');