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
+26 -1
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
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 { 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');
});
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', () => {
/**
* 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.
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
'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',
// Names, seats, revenue and HAND SIZE — never hand contents.
'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}`);
});
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', () => {
const html = dayEndHtml(frameAt(6));
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');