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
|
||||
|
||||
**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",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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
|
||||
* (`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
|
||||
* 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
|
||||
* `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> = {
|
||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||
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.
|
||||
*
|
||||
@@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind {
|
||||
case 'redFlag.play':
|
||||
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':
|
||||
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 'draw.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 {
|
||||
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
|
||||
* 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.
|
||||
|
||||
+39
-7
@@ -47,14 +47,32 @@ export type StepQueue = {
|
||||
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 last: DisplayStep | null = null;
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
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. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
@@ -76,7 +94,10 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
||||
|
||||
advance(now) {
|
||||
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;
|
||||
}
|
||||
// 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) {
|
||||
const first = pending.shift()!;
|
||||
show(first);
|
||||
dueAt = now + dwellForStep(first, pace());
|
||||
dueAt = now + dwell(first);
|
||||
return true;
|
||||
}
|
||||
let drew = false;
|
||||
@@ -97,7 +118,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
||||
while (pending.length > 0 && now >= dueAt) {
|
||||
const next = pending.shift()!;
|
||||
show(next);
|
||||
dueAt = dueAt + dwellForStep(next, pace());
|
||||
dueAt = dueAt + dwell(next);
|
||||
drew = true;
|
||||
}
|
||||
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||
@@ -113,8 +134,19 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
|
||||
},
|
||||
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
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 { 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 { Intent } from '../src/engine/intents.ts';
|
||||
|
||||
@@ -50,7 +50,13 @@ describe('pacing — dwell by kind', () => {
|
||||
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
||||
assert.equal(kindOf('loadUnload.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.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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.equal(dwellFor('switch.move', 1), 1000);
|
||||
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', () => {
|
||||
// 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'][] = [
|
||||
'localOps.choose',
|
||||
...Array<Intent['type']>(6).fill('switch.move'),
|
||||
'switch.end',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.equal(total, 6000);
|
||||
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
|
||||
assert.equal(total, DWELL.action + 6 * DWELL.switching);
|
||||
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));
|
||||
|
||||
// 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');
|
||||
q.push(bookkeeping);
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
/**
|
||||
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
||||
|
||||
Reference in New Issue
Block a user