Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c10f52791e | ||
|
|
0cfeb4c496 |
+126
@@ -19,6 +19,132 @@ 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;
|
||||||
|
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
|
||||||
|
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
|
||||||
|
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
|
||||||
|
|
||||||
|
### The bug: the last step of a burst never got its moment
|
||||||
|
|
||||||
|
`busy()` was `pending.length > 0`. So the instant the FINAL step of a burst was shown, the queue
|
||||||
|
reported itself idle — the animation loop stopped and, because the district panel follows `busy()`,
|
||||||
|
it snapped back to the viewer's own board without that step ever being looked at. The countdown row
|
||||||
|
went with it. `busy()` is now `pending.length > 0 || dueAt !== null`: there is more to come, **or**
|
||||||
|
what is on screen has not had its moment yet.
|
||||||
|
|
||||||
|
### The calibration: 250ms was invented, and it was wrong
|
||||||
|
|
||||||
|
Jesse's instruction had been "start at 1s and tune down". That was applied to switching and then a
|
||||||
|
250ms `action` tier was made up beside it, which held for the case the design was measured against —
|
||||||
|
a switching burst — and failed the common one. **Switching is not legal until there is track down**,
|
||||||
|
so an early-game bot turn contains none of it. Measured from a real 3-seat game, one bot turn was:
|
||||||
|
|
||||||
|
```
|
||||||
|
localOps.choose 0ms · draw.fromHomeOffice 250ms · card.play 250ms
|
||||||
|
draw.end 0ms · localOps.choose 0ms · freightAgent.stockOutbound 250ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**750ms for a whole turn.** `action` is now 700ms, which puts that same turn at 4.7s.
|
||||||
|
|
||||||
|
**And `localOps.choose` was the worst of it.** It was classed as bookkeeping, at zero — but it is the
|
||||||
|
line reading *"Player Bot 1 chose to SWITCH — six Moves to shunt cars around the yard"*: the heading
|
||||||
|
for everything that follows. A bot's turn began with no indication of what it was about to do. It is
|
||||||
|
an announcement, and it is now in `action`.
|
||||||
|
|
||||||
|
### The viewer's own moves cost nothing
|
||||||
|
|
||||||
|
Raising `action` exposed a waste: your own click was being held for 700ms before the bots' turn
|
||||||
|
started animating. A seated player's own board is drawn from their authoritative `Frame`, never from
|
||||||
|
the queue, so replaying their own move shows them nothing and delays the thing they wanted to watch.
|
||||||
|
Own steps are still applied — the delta chain runs through them — but at zero dwell. Automatic phases
|
||||||
|
have no player and are unaffected, which is what keeps #18 working in solitaire, where every intent
|
||||||
|
is the viewer's own.
|
||||||
|
|
||||||
|
### Faster and slower, without a rebuild
|
||||||
|
|
||||||
|
`pace` multipliers above 1 are supported and expected — Jesse asked for 2 and 3 — bounded by a new
|
||||||
|
`MAX_PACE` of 10 so that `?pace=300` from somebody meaning 3.00 cannot look like a frozen board.
|
||||||
|
Every tier scales by the same factor, so **a switching move outlasts an ordinary action at 0.5× and
|
||||||
|
at 3× alike**: the relative weighting is the design, and the multiplier is only how fast it runs.
|
||||||
|
|
||||||
|
Whole-game animation is now ~5.7 minutes across a 6-day game.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.8.0 — 2026-09-09
|
## 0.8.0 — 2026-09-09
|
||||||
|
|
||||||
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
|
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0",
|
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+55
-7
@@ -37,7 +37,9 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
|||||||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||||
* hundred times will want it off".
|
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||||
|
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||||
|
* their relative weighting survives.
|
||||||
*
|
*
|
||||||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||||
@@ -46,8 +48,17 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
|||||||
export const DWELL: Record<StepKind, number> = {
|
export const DWELL: Record<StepKind, number> = {
|
||||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||||
switching: 1000,
|
switching: 1000,
|
||||||
/** A card, a car or a load changing hands somewhere visible. */
|
/**
|
||||||
action: 250,
|
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||||||
|
* player is about to do.
|
||||||
|
*
|
||||||
|
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||||
|
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||||
|
* Jesse, from the first real play on `phoenix.local`: *"bot play was way too fast. I briefly saw
|
||||||
|
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||||
|
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||||
|
*/
|
||||||
|
action: 700,
|
||||||
/**
|
/**
|
||||||
* An automatic phase that DID something — TODO #18.
|
* An automatic phase that DID something — TODO #18.
|
||||||
*
|
*
|
||||||
@@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind {
|
|||||||
case 'redFlag.play':
|
case 'redFlag.play':
|
||||||
return 'action';
|
return 'action';
|
||||||
|
|
||||||
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching;
|
/**
|
||||||
// the declaration itself is not, and there are more of these than of anything else.
|
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||||
|
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||||
|
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||||
|
* it, so a bot's turn began with no indication of what it was about to do.
|
||||||
|
*/
|
||||||
case 'localOps.choose':
|
case 'localOps.choose':
|
||||||
|
return 'action';
|
||||||
|
|
||||||
|
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||||
|
// there are more of these than of anything else.
|
||||||
case 'loadUnload.end':
|
case 'loadUnload.end':
|
||||||
case 'draw.end':
|
case 'draw.end':
|
||||||
case 'switch.end':
|
case 'switch.end':
|
||||||
@@ -119,9 +138,38 @@ export function kindOf(cause: StepCause): StepKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */
|
/**
|
||||||
|
* The widest multiplier that is a speed rather than a mistake.
|
||||||
|
*
|
||||||
|
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||||
|
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||||
|
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
|
||||||
|
* 3 are the ones actually asked for) and well short of unusable.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||||
|
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||||
|
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||||
|
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||||
|
*/
|
||||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||||
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace));
|
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+54
-1
@@ -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
|
||||||
@@ -165,7 +167,12 @@ let session: Session;
|
|||||||
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
|
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
|
||||||
* only.
|
* only.
|
||||||
*/
|
*/
|
||||||
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace);
|
const stepQueue = createStepQueue(
|
||||||
|
() => PACE_OVERRIDE ?? settings.pace,
|
||||||
|
// Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when
|
||||||
|
// a game starts, long after this queue is built.
|
||||||
|
() => (session ? session.seat() : null),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
|
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
|
||||||
@@ -2598,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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
+39
-7
@@ -47,14 +47,32 @@ export type StepQueue = {
|
|||||||
busy(): boolean;
|
busy(): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */
|
/**
|
||||||
export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||||
|
*
|
||||||
|
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||||
|
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||||
|
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||||
|
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||||
|
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||||
|
*
|
||||||
|
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||||
|
* solitaire where every intent is the viewer's own.
|
||||||
|
*/
|
||||||
|
export function createStepQueue(
|
||||||
|
pace: () => number = () => 1,
|
||||||
|
viewer: () => number | null = () => null,
|
||||||
|
): StepQueue {
|
||||||
let shown: PublicFrame | null = null;
|
let shown: PublicFrame | null = null;
|
||||||
let last: DisplayStep | null = null;
|
let last: DisplayStep | null = null;
|
||||||
let pending: DisplayStep[] = [];
|
let pending: DisplayStep[] = [];
|
||||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||||
let dueAt: number | null = null;
|
let dueAt: number | null = null;
|
||||||
|
|
||||||
|
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||||
|
const dwell = (step: DisplayStep): number =>
|
||||||
|
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||||
|
|
||||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||||
const show = (step: DisplayStep): void => {
|
const show = (step: DisplayStep): void => {
|
||||||
shown = applyPublicDelta(shown, step.frame);
|
shown = applyPublicDelta(shown, step.frame);
|
||||||
@@ -76,7 +94,10 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
|||||||
|
|
||||||
advance(now) {
|
advance(now) {
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
dueAt = null;
|
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||||
|
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||||
|
// could look at it — see `busy()`.
|
||||||
|
if (dueAt !== null && now >= dueAt) dueAt = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
||||||
@@ -84,7 +105,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
|||||||
if (dueAt === null) {
|
if (dueAt === null) {
|
||||||
const first = pending.shift()!;
|
const first = pending.shift()!;
|
||||||
show(first);
|
show(first);
|
||||||
dueAt = now + dwellForStep(first, pace());
|
dueAt = now + dwell(first);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let drew = false;
|
let drew = false;
|
||||||
@@ -97,7 +118,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
|||||||
while (pending.length > 0 && now >= dueAt) {
|
while (pending.length > 0 && now >= dueAt) {
|
||||||
const next = pending.shift()!;
|
const next = pending.shift()!;
|
||||||
show(next);
|
show(next);
|
||||||
dueAt = dueAt + dwellForStep(next, pace());
|
dueAt = dueAt + dwell(next);
|
||||||
drew = true;
|
drew = true;
|
||||||
}
|
}
|
||||||
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||||
@@ -113,8 +134,19 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
|||||||
},
|
},
|
||||||
|
|
||||||
current: () => shown,
|
current: () => shown,
|
||||||
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length,
|
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||||
showing: () => last,
|
showing: () => last,
|
||||||
busy: () => pending.length > 0,
|
/**
|
||||||
|
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||||
|
*
|
||||||
|
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||||
|
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||||
|
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||||
|
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||||
|
*
|
||||||
|
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||||
|
* "there is more to come, or what is up has not had its moment yet".
|
||||||
|
*/
|
||||||
|
busy: () => pending.length > 0 || dueAt !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-5
@@ -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, 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';
|
||||||
|
|
||||||
@@ -50,7 +50,13 @@ describe('pacing — dwell by kind', () => {
|
|||||||
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
||||||
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
|
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
|
||||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||||
assert.equal(kindOf('localOps.choose'), 'bookkeeping');
|
/**
|
||||||
|
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||||
|
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||||
|
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||||
|
* to do.
|
||||||
|
*/
|
||||||
|
assert.equal(kindOf('localOps.choose'), 'action');
|
||||||
|
|
||||||
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
|
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
|
||||||
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
|
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
|
||||||
@@ -63,6 +69,30 @@ describe('pacing — dwell by kind', () => {
|
|||||||
assert.equal(dwellFor('switch.move'), 1000);
|
assert.equal(dwellFor('switch.move'), 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
|
||||||
|
/**
|
||||||
|
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
|
||||||
|
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
|
||||||
|
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
|
||||||
|
* relative weighting is the design and the multiplier is only how fast it runs.
|
||||||
|
*/
|
||||||
|
for (const pace of [0.5, 1, 1.5, 2, 3]) {
|
||||||
|
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
|
||||||
|
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
|
||||||
|
assert.ok(
|
||||||
|
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
|
||||||
|
`at ${pace}x a switching move no longer outlasts an ordinary action`,
|
||||||
|
);
|
||||||
|
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
|
||||||
|
}
|
||||||
|
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
|
||||||
|
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
|
||||||
|
|
||||||
|
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
|
||||||
|
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
|
||||||
|
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
|
||||||
|
});
|
||||||
|
|
||||||
it('scales with the viewer\'s pace, and 0 turns it off', () => {
|
it('scales with the viewer\'s pace, and 0 turns it off', () => {
|
||||||
assert.equal(dwellFor('switch.move', 1), 1000);
|
assert.equal(dwellFor('switch.move', 1), 1000);
|
||||||
assert.equal(dwellFor('switch.move', 0.5), 500);
|
assert.equal(dwellFor('switch.move', 0.5), 500);
|
||||||
@@ -90,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
|
||||||
@@ -111,14 +166,36 @@ describe('pacing — dwell by kind', () => {
|
|||||||
|
|
||||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||||
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||||
// case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise.
|
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||||
const turn: Intent['type'][] = [
|
const turn: Intent['type'][] = [
|
||||||
'localOps.choose',
|
'localOps.choose',
|
||||||
...Array<Intent['type']>(6).fill('switch.move'),
|
...Array<Intent['type']>(6).fill('switch.move'),
|
||||||
'switch.end',
|
'switch.end',
|
||||||
];
|
];
|
||||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||||
assert.equal(total, 6000);
|
assert.equal(total, DWELL.action + 6 * DWELL.switching);
|
||||||
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
|
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
|
||||||
|
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||||
|
/**
|
||||||
|
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on `phoenix.local`:
|
||||||
|
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||||
|
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||||
|
* switching is not legal until there is track down — and under the original values it came to
|
||||||
|
* 750ms for the whole thing.
|
||||||
|
*/
|
||||||
|
const turn: Intent['type'][] = [
|
||||||
|
'localOps.choose',
|
||||||
|
'draw.fromHomeOffice',
|
||||||
|
'card.play',
|
||||||
|
'draw.end',
|
||||||
|
'localOps.choose',
|
||||||
|
'freightAgent.stockOutbound',
|
||||||
|
];
|
||||||
|
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||||
|
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
|
||||||
|
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+58
-1
@@ -87,7 +87,8 @@ describe('the step queue', () => {
|
|||||||
q.reset(baseline(1917398));
|
q.reset(baseline(1917398));
|
||||||
|
|
||||||
// Only the bookkeeping: it must all collapse into a single advance.
|
// Only the bookkeeping: it must all collapse into a single advance.
|
||||||
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose');
|
// `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
|
||||||
|
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
|
||||||
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
|
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
|
||||||
q.push(bookkeeping);
|
q.push(bookkeeping);
|
||||||
q.advance(0);
|
q.advance(0);
|
||||||
@@ -173,6 +174,62 @@ describe('the step queue', () => {
|
|||||||
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
|
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
|
||||||
|
/**
|
||||||
|
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||||
|
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||||
|
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||||
|
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
|
||||||
|
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||||
|
*
|
||||||
|
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||||
|
* for as long as their move is being shown.
|
||||||
|
*/
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
|
||||||
|
assert.equal(one.length, 1);
|
||||||
|
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(one);
|
||||||
|
|
||||||
|
q.advance(0);
|
||||||
|
assert.equal(q.behind(), 0, 'nothing is queued behind it');
|
||||||
|
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
|
||||||
|
|
||||||
|
q.advance(DWELL.switching - 1);
|
||||||
|
assert.equal(q.busy(), true, 'still inside its dwell');
|
||||||
|
|
||||||
|
q.advance(DWELL.switching);
|
||||||
|
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spend time replaying the viewer's own moves", () => {
|
||||||
|
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
|
||||||
|
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
|
||||||
|
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
|
||||||
|
|
||||||
|
const asSeat0 = createStepQueue(() => 1, () => 0);
|
||||||
|
asSeat0.reset(baseline(1917398));
|
||||||
|
asSeat0.push(mine);
|
||||||
|
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
|
||||||
|
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
|
||||||
|
asSeat0.advance(0);
|
||||||
|
asSeat0.advance(0);
|
||||||
|
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
|
||||||
|
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
|
||||||
|
|
||||||
|
// The same steps seen by somebody else are worth watching.
|
||||||
|
const asSpectator = createStepQueue(() => 1, () => 1);
|
||||||
|
asSpectator.reset(baseline(1917398));
|
||||||
|
asSpectator.push(mine);
|
||||||
|
asSpectator.advance(0);
|
||||||
|
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
|
||||||
|
assert.equal(asSpectator.behind(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
|
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
|
||||||
/**
|
/**
|
||||||
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user