Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfeb4c496 |
@@ -19,6 +19,60 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+43
-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,26 @@ 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-1
@@ -165,7 +165,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.
|
||||||
|
|||||||
+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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-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, 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);
|
||||||
@@ -111,14 +141,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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
Reference in New Issue
Block a user