Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c10f52791e |
@@ -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
|
||||
|
||||
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour;
|
||||
|
||||
@@ -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:
|
||||
>
|
||||
> - **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`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||
> `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`,
|
||||
> `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.
|
||||
> - **`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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.1",
|
||||
"version": "0.8.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
@@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
|
||||
s.clock.day += 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;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
|
||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
|
||||
@@ -1119,6 +1119,20 @@ export type GameState = {
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
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`. */
|
||||
collisionsTotal: number;
|
||||
/**
|
||||
|
||||
@@ -148,6 +148,18 @@ export function kindOf(cause: StepCause): StepKind {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -437,6 +437,8 @@ export type Frame = {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||
collisionsPrevDay: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsPrevDay: s.collisionsPrevDay,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
|
||||
@@ -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');
|
||||
|
||||
+20
-5
@@ -180,7 +180,7 @@ export function dayEndHtml(f: Frame): string {
|
||||
ahead +
|
||||
standingsHtml(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
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</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="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>
|
||||
|
||||
@@ -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
|
||||
* 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');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user