@hopple/netcode 0.0.0-stage → 0.1.1-ci.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -2
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/lockstep.d.ts +252 -0
- package/lockstep.js +672 -0
- package/package.json +47 -4
- package/session.d.ts +226 -0
- package/session.js +361 -0
package/lockstep.js
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { ByteReader, ByteWriter } from "@hopple/bytes";
|
|
2
|
+
//#region src/lockstep.ts
|
|
3
|
+
/**
|
|
4
|
+
* One way to run a match in lockstep: every device runs the same game, step by
|
|
5
|
+
* step, on every player's input for that step. Optional: a game is welcome to
|
|
6
|
+
* keep its devices in step its own way.
|
|
7
|
+
*
|
|
8
|
+
* **The match is a log.** Every device in a match — the sender included —
|
|
9
|
+
* hears every message in the relay's one order, and every decision below is
|
|
10
|
+
* made from that log alone. Two devices
|
|
11
|
+
* that heard the same log run the same steps with the same inputs, whenever
|
|
12
|
+
* the messages happened to reach each of them.
|
|
13
|
+
*
|
|
14
|
+
* **Input delay, as long as the slowest needs.** Each device's input for step t
|
|
15
|
+
* is read some steps earlier, the delay, and sent, so it has time to cross the
|
|
16
|
+
* relay before anybody needs it. A step runs once every seat's input for it has
|
|
17
|
+
* come back through the log, this device's own included; until then the game
|
|
18
|
+
* waits, and catches up after. Every device times its own messages' round trip
|
|
19
|
+
* and says how many steps it needs, and every device reads with the most any
|
|
20
|
+
* playing seat needs, within the game's bounds: the same delay for everybody,
|
|
21
|
+
* so nobody's controls answer later than another's. It grows as soon as a seat
|
|
22
|
+
* needs more, and shrinks once they have all needed less for a few seconds.
|
|
23
|
+
* Where each device reads from changes nothing in the log's order, so the
|
|
24
|
+
* delay can change without the devices agreeing on when.
|
|
25
|
+
*
|
|
26
|
+
* **Seats are handed to the game, and back.** A seat whose person left the
|
|
27
|
+
* match, or went quiet for `timeoutMs` while the others waited, is dropped:
|
|
28
|
+
* from a step every device works out from the log, its input arrives as `null`,
|
|
29
|
+
* and the game plays it (its AI, or nobody). Its device, once caught up, asks
|
|
30
|
+
* for it back, and gets it from a step that, again, the log decides. A drop
|
|
31
|
+
* takes effect from the first step the seat's input is missing for in the log
|
|
32
|
+
* before the drop, and a return after the furthest step any device could have
|
|
33
|
+
* run, so no device has ever run a step the others will run differently.
|
|
34
|
+
*
|
|
35
|
+
* **Desyncs and lost devices are reported.** Every `hashEvery` steps each device
|
|
36
|
+
* that plays sends a fingerprint of its state, and a difference is reported with
|
|
37
|
+
* the step it was found at. A device whose connection dropped for a while
|
|
38
|
+
* hears what it missed once it is back, and carries on: what it sent as the
|
|
39
|
+
* connection went, and never heard come back before a later message of its
|
|
40
|
+
* own, it sends again. One that missed more than the relay still held — a gap
|
|
41
|
+
* in the relay's numbering — cannot follow the log any longer, and says so.
|
|
42
|
+
*
|
|
43
|
+
* **Rollback, optionally.** With `rollback`, a device does not wait for the
|
|
44
|
+
* others' inputs: it runs on, up to `rollback.steps` steps ahead of the log,
|
|
45
|
+
* guessing what they press from what they pressed last, and shows that. When an
|
|
46
|
+
* input comes in that differs from the guess, it puts the game back where it was
|
|
47
|
+
* before that step (`restore`) and runs the steps again, so the picture catches
|
|
48
|
+
* up with what was pressed. The delay then has only to cover the round trip
|
|
49
|
+
* beyond what guessing covers, and a player's own controls answer after a step
|
|
50
|
+
* or two whatever the connection. Only the log confirms a step, this device's
|
|
51
|
+
* own input included, so what every device agrees on, fingerprints, drops and
|
|
52
|
+
* returns, is decided as it is without rollback; a guess past a drop or a return
|
|
53
|
+
* is simply run again. Somebody who only watches runs confirmed steps alone.
|
|
54
|
+
*
|
|
55
|
+
* Messages are small: varints, inputs as the game's own bytes, `sendEvery`
|
|
56
|
+
* steps a message. A match's other messages (a game's lobby, say) share the
|
|
57
|
+
* log: tell them apart with `isLockstepMessage`.
|
|
58
|
+
*
|
|
59
|
+
* @module lockstep
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The first byte of every message this helper sends. A game sending messages
|
|
63
|
+
* of its own in the same match starts them with any other byte.
|
|
64
|
+
*
|
|
65
|
+
* @group Lockstep
|
|
66
|
+
*/
|
|
67
|
+
const LOCKSTEP_TAG = 245;
|
|
68
|
+
/**
|
|
69
|
+
* Whether a match message is one of a lockstep run's, for a game's own
|
|
70
|
+
* listener to skip.
|
|
71
|
+
*
|
|
72
|
+
* @group Lockstep
|
|
73
|
+
*/
|
|
74
|
+
function isLockstepMessage(data) {
|
|
75
|
+
return data[0] === 245;
|
|
76
|
+
}
|
|
77
|
+
const NOTHING = /* @__PURE__ */ new Uint8Array(0);
|
|
78
|
+
const FLAG_HASH = 1;
|
|
79
|
+
const FLAG_DROP = 2;
|
|
80
|
+
const FLAG_RETURN = 4;
|
|
81
|
+
/** How far back round trips count towards the delay this device needs. */
|
|
82
|
+
const TRIPS_MS = 2e3;
|
|
83
|
+
/** How long a device needs less before it says so. */
|
|
84
|
+
const SHRINK_AFTER_MS = 5e3;
|
|
85
|
+
/** Steps between two steps that read nothing, while the delay shrinks. */
|
|
86
|
+
const SHRINK_EVERY = 15;
|
|
87
|
+
/** Steps of guessing left to absorb jitter, with rollback, before the delay grows. */
|
|
88
|
+
const SLACK = 2;
|
|
89
|
+
/** The most inputs in a message sending again what never came back. */
|
|
90
|
+
const RESEND_MOST = 60;
|
|
91
|
+
/**
|
|
92
|
+
* Run a match in lockstep. See the module for how.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* const run = lockstep({
|
|
96
|
+
* match, seq, run: 1, seats, me: match.me!.id,
|
|
97
|
+
* sample: () => Uint8Array.of(pad.bits()),
|
|
98
|
+
* step: (inputs) => world.step(inputs),
|
|
99
|
+
* hash: () => hash(world.summary()),
|
|
100
|
+
* });
|
|
101
|
+
* sdk.loop.tick((dt) => { run.advance(dt * 1000); draw(); });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* With rollback, the game also says how to go back:
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* rollback: { steps: 8, save: () => world.save(), restore: (saved) => world.restore(saved) },
|
|
108
|
+
* step: (inputs, tick, again) => world.step(inputs, { quiet: again }),
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @group Lockstep
|
|
112
|
+
*/
|
|
113
|
+
function lockstep(options) {
|
|
114
|
+
return runLockstep(options.locals === void 0 ? options : perPlayer(options, options.locals));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A run of one input per seat standing in for one input per player: each
|
|
118
|
+
* device's players' inputs travel as one, a count and then each in turn, and
|
|
119
|
+
* are taken apart again for the game.
|
|
120
|
+
*/
|
|
121
|
+
function perPlayer(options, locals) {
|
|
122
|
+
const counts = options.seats.map((_, seat) => Math.max(1, Math.floor(locals[seat] ?? 1)));
|
|
123
|
+
const firsts = counts.map((_, seat) => counts.slice(0, seat).reduce((sum, one) => sum + one, 0));
|
|
124
|
+
const mine = counts[options.seats.indexOf(options.me)] ?? 0;
|
|
125
|
+
const join = (inputs) => {
|
|
126
|
+
const out = new ByteWriter().uint(inputs.length);
|
|
127
|
+
for (const one of inputs) out.bytes(one);
|
|
128
|
+
return out.finish();
|
|
129
|
+
};
|
|
130
|
+
/** One seat's input as its players' inputs: none read is nothing pressed, and a seat handed to the game is each of its players handed over. */
|
|
131
|
+
const split = (input, seat) => {
|
|
132
|
+
const count = counts[seat] ?? 1;
|
|
133
|
+
if (input === null) return Array.from({ length: count }, () => null);
|
|
134
|
+
const players = Array.from({ length: count }, () => NOTHING);
|
|
135
|
+
if (input.length === 0) return players;
|
|
136
|
+
try {
|
|
137
|
+
const reader = new ByteReader(input);
|
|
138
|
+
const sent = reader.uint();
|
|
139
|
+
for (let at = 0; at < Math.min(sent, count); at++) players[at] = reader.bytes();
|
|
140
|
+
} catch {}
|
|
141
|
+
return players;
|
|
142
|
+
};
|
|
143
|
+
const own = options.rollback;
|
|
144
|
+
const predict = own?.predict;
|
|
145
|
+
const { locals: _, ...rest } = options;
|
|
146
|
+
return {
|
|
147
|
+
...rest,
|
|
148
|
+
sample(tick) {
|
|
149
|
+
const read = options.sample(tick);
|
|
150
|
+
const inputs = read instanceof Uint8Array ? [read] : [...read];
|
|
151
|
+
return join(inputs.slice(0, mine));
|
|
152
|
+
},
|
|
153
|
+
step: (inputs, tick, again) => options.step(inputs.flatMap((one, seat) => split(one, seat)), tick, again),
|
|
154
|
+
...own === void 0 ? {} : { rollback: {
|
|
155
|
+
...own,
|
|
156
|
+
...predict === void 0 ? {} : { predict: (last, seat) => join(split(last, seat).map((one, at) => predict(one ?? NOTHING, (firsts[seat] ?? 0) + at))) }
|
|
157
|
+
} }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function runLockstep(options) {
|
|
161
|
+
const { match } = options;
|
|
162
|
+
const minDelay = Math.max(1, Math.floor(options.delay?.min ?? 2));
|
|
163
|
+
const maxDelay = Math.max(minDelay, Math.floor(options.delay?.max ?? 20));
|
|
164
|
+
const rollback = options.rollback;
|
|
165
|
+
/** Steps guessing covers, the same on every device: the delay covers the rest. */
|
|
166
|
+
const covered = rollback ? Math.max(0, Math.floor(rollback.steps) - SLACK) : 0;
|
|
167
|
+
/** The most delay a device says it needs: what the bounds allow and guessing covers. */
|
|
168
|
+
const mostNeed = maxDelay + covered;
|
|
169
|
+
/** The delay a device needs before it has timed a round trip. */
|
|
170
|
+
const startNeed = Math.min(mostNeed, Math.max(minDelay, Math.floor(options.delay?.start ?? 6)));
|
|
171
|
+
/** The first step any seat reads its input for: every step before it is pressed by nobody. */
|
|
172
|
+
const start = Math.min(maxDelay, Math.max(minDelay, startNeed - covered));
|
|
173
|
+
const hashEvery = Math.max(1, Math.floor(options.hashEvery ?? 60));
|
|
174
|
+
const tickMs = options.tickMs ?? 1e3 / 60;
|
|
175
|
+
const sendEvery = Math.max(1, Math.floor(options.sendEvery ?? 2));
|
|
176
|
+
const timeoutMs = options.timeoutMs ?? 3e3;
|
|
177
|
+
const catchUp = Math.max(1, Math.floor(options.catchUp ?? 30));
|
|
178
|
+
const now = options.now ?? (() => globalThis.performance?.now() ?? Date.now());
|
|
179
|
+
const run = options.run;
|
|
180
|
+
const predict = rollback?.predict ?? ((last) => last);
|
|
181
|
+
const seats = options.seats.map((id) => ({
|
|
182
|
+
id,
|
|
183
|
+
inputs: /* @__PURE__ */ new Map(),
|
|
184
|
+
last: start - 1,
|
|
185
|
+
changes: [[0, true]],
|
|
186
|
+
heard: now(),
|
|
187
|
+
askedDrop: -Infinity,
|
|
188
|
+
need: startNeed,
|
|
189
|
+
prev: NOTHING
|
|
190
|
+
}));
|
|
191
|
+
const mine = seats.findIndex((seat) => seat.id === options.me);
|
|
192
|
+
/** How far the game may run ahead of the log: somebody who only watches waits for it. */
|
|
193
|
+
const window = rollback && mine >= 0 ? Math.max(0, Math.floor(rollback.steps)) : 0;
|
|
194
|
+
/** Steps the log has confirmed: the next step to confirm. */
|
|
195
|
+
let tick = 0;
|
|
196
|
+
/** Steps the game has run, on guesses past `tick`. */
|
|
197
|
+
let head = 0;
|
|
198
|
+
/** Past `tick`: what each step was run with, and the game as it was before it. */
|
|
199
|
+
const guessed = /* @__PURE__ */ new Map();
|
|
200
|
+
const saves = /* @__PURE__ */ new Map();
|
|
201
|
+
/** This device's own inputs as read, before they come back through the log. */
|
|
202
|
+
const local = /* @__PURE__ */ new Map();
|
|
203
|
+
let status = "running";
|
|
204
|
+
let lastSeq = options.seq;
|
|
205
|
+
let clock = 0;
|
|
206
|
+
let heardAny = now();
|
|
207
|
+
let rtt = null;
|
|
208
|
+
/** This device's recent round trips: [when heard, ms]. */
|
|
209
|
+
const trips = [];
|
|
210
|
+
/** The delay this device says it needs. */
|
|
211
|
+
let need = startNeed;
|
|
212
|
+
/** Since when it has needed less than it says, and the most it needed meanwhile. */
|
|
213
|
+
let lowSince = null;
|
|
214
|
+
let lowMost = 0;
|
|
215
|
+
/** The last step that read nothing, to shrink the delay. */
|
|
216
|
+
let skipped = -Infinity;
|
|
217
|
+
/** This device's messages still on their way through the relay, in the order sent: when, and the inputs they carry from which step. */
|
|
218
|
+
const inFlight = [];
|
|
219
|
+
/** This device's inputs that never came back through the log, to send again. */
|
|
220
|
+
const unheard = [];
|
|
221
|
+
/** Fingerprints by step: this device's, and the others'. */
|
|
222
|
+
const prints = /* @__PURE__ */ new Map();
|
|
223
|
+
let desynced = false;
|
|
224
|
+
let waiting = [];
|
|
225
|
+
/**
|
|
226
|
+
* The next step this device reads its input for — the step being run plus
|
|
227
|
+
* the delay, once it has grown or shrunk to it — or `null` while it sends
|
|
228
|
+
* none: it watches, or its seat was handed to the game and it has not asked
|
|
229
|
+
* for it back.
|
|
230
|
+
*/
|
|
231
|
+
let reading = mine >= 0 ? start : null;
|
|
232
|
+
let returning = false;
|
|
233
|
+
let askedBack = -Infinity;
|
|
234
|
+
const out = {
|
|
235
|
+
first: start,
|
|
236
|
+
inputs: [],
|
|
237
|
+
hash: null,
|
|
238
|
+
drops: [],
|
|
239
|
+
back: null
|
|
240
|
+
};
|
|
241
|
+
let sentAt = -Infinity;
|
|
242
|
+
function latest(seat) {
|
|
243
|
+
return seat.changes[seat.changes.length - 1][1];
|
|
244
|
+
}
|
|
245
|
+
function playedAt(seat, at) {
|
|
246
|
+
for (let i = seat.changes.length - 1; i >= 0; i--) if (seat.changes[i][0] <= at) return seat.changes[i][1];
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
/** The furthest step any device could have run, from what the log holds now. */
|
|
250
|
+
function furthest() {
|
|
251
|
+
let most = tick - 1;
|
|
252
|
+
for (const seat of seats) {
|
|
253
|
+
if (seat.last > most) most = seat.last;
|
|
254
|
+
for (const [at, played] of seat.changes) if (played && at - 1 > most) most = at - 1;
|
|
255
|
+
}
|
|
256
|
+
return most;
|
|
257
|
+
}
|
|
258
|
+
/** The delay to read with: the most any seat that plays says it needs, less what guessing covers, within the bounds. */
|
|
259
|
+
function delay() {
|
|
260
|
+
let most = 0;
|
|
261
|
+
seats.forEach((seat, index) => {
|
|
262
|
+
if (latest(seat)) most = Math.max(most, index === mine ? need : seat.need);
|
|
263
|
+
});
|
|
264
|
+
return Math.min(maxDelay, Math.max(minDelay, most - covered));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A round trip of this device's messages: the delay it needs covers nearly
|
|
268
|
+
* every recent one, and the steps an input waits to be sent. More is said at
|
|
269
|
+
* once; less only once it has lasted.
|
|
270
|
+
*/
|
|
271
|
+
function timed(trip) {
|
|
272
|
+
const at = now();
|
|
273
|
+
trips.push([at, trip]);
|
|
274
|
+
while (trips[0][0] < at - TRIPS_MS) trips.shift();
|
|
275
|
+
const sorted = trips.map(([, ms]) => ms).sort((a, b) => a - b);
|
|
276
|
+
const high = sorted[Math.floor((sorted.length - 1) * .9)];
|
|
277
|
+
const wanted = Math.min(mostNeed, Math.max(minDelay, Math.ceil(high / tickMs) + sendEvery));
|
|
278
|
+
if (wanted >= need) {
|
|
279
|
+
need = wanted;
|
|
280
|
+
lowSince = null;
|
|
281
|
+
} else if (lowSince === null) {
|
|
282
|
+
lowSince = at;
|
|
283
|
+
lowMost = wanted;
|
|
284
|
+
} else {
|
|
285
|
+
lowMost = Math.max(lowMost, wanted);
|
|
286
|
+
if (at - lowSince >= SHRINK_AFTER_MS) {
|
|
287
|
+
need = lowMost;
|
|
288
|
+
lowSince = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* One of this device's messages, back through the log: a round trip. The
|
|
294
|
+
* relay passes on a device's messages in the order sent, so one sent before
|
|
295
|
+
* it and not heard never will be — it went with a dropped connection, or the
|
|
296
|
+
* relay refused it — and its inputs are sent again: every device would wait
|
|
297
|
+
* for them for good.
|
|
298
|
+
*/
|
|
299
|
+
function echoed(first, count) {
|
|
300
|
+
const index = inFlight.findIndex((one) => one.first === first && one.inputs.length === count);
|
|
301
|
+
if (index < 0) return;
|
|
302
|
+
for (const one of inFlight.splice(0, index)) if (one.inputs.length > 0) unheard.push(one);
|
|
303
|
+
rtt = now() - inFlight.shift().at;
|
|
304
|
+
timed(rtt);
|
|
305
|
+
}
|
|
306
|
+
/** The furthest step any seat's input has reached. */
|
|
307
|
+
function frontier() {
|
|
308
|
+
let most = -1;
|
|
309
|
+
for (const seat of seats) if (seat.last > most) most = seat.last;
|
|
310
|
+
return most;
|
|
311
|
+
}
|
|
312
|
+
function lost() {
|
|
313
|
+
if (status !== "running") return;
|
|
314
|
+
status = "lost";
|
|
315
|
+
stopListening();
|
|
316
|
+
stopWatching();
|
|
317
|
+
options.onLost?.();
|
|
318
|
+
}
|
|
319
|
+
function compare(at) {
|
|
320
|
+
const entry = prints.get(at);
|
|
321
|
+
if (entry === void 0 || entry.mine === null || desynced) return;
|
|
322
|
+
if (entry.theirs.some((one) => one !== entry.mine)) {
|
|
323
|
+
desynced = true;
|
|
324
|
+
options.onDesync?.(at);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** A message of this run, from `seat`, in the log's order. */
|
|
328
|
+
function hear(seat, index, read) {
|
|
329
|
+
seat.heard = now();
|
|
330
|
+
const first = read.uint();
|
|
331
|
+
const count = read.uint();
|
|
332
|
+
for (let i = 0; i < count; i++) {
|
|
333
|
+
const at = first + i;
|
|
334
|
+
const input = read.bytes();
|
|
335
|
+
if (at >= tick) seat.inputs.set(at, input);
|
|
336
|
+
if (at > seat.last) seat.last = at;
|
|
337
|
+
}
|
|
338
|
+
if (index === mine) echoed(first, count);
|
|
339
|
+
seat.need = read.uint();
|
|
340
|
+
const flags = read.uint();
|
|
341
|
+
if (flags & FLAG_HASH) {
|
|
342
|
+
const at = read.uint();
|
|
343
|
+
const print = read.string();
|
|
344
|
+
if (index !== mine && at >= tick - hashEvery * 4) {
|
|
345
|
+
const entry = prints.get(at) ?? {
|
|
346
|
+
mine: null,
|
|
347
|
+
theirs: []
|
|
348
|
+
};
|
|
349
|
+
entry.theirs.push(print);
|
|
350
|
+
prints.set(at, entry);
|
|
351
|
+
compare(at);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (flags & FLAG_DROP) {
|
|
355
|
+
const n = read.uint();
|
|
356
|
+
for (let i = 0; i < n; i++) {
|
|
357
|
+
const target = seats[read.uint()];
|
|
358
|
+
if (target === void 0 || target === seat || !latest(seat) || !latest(target)) continue;
|
|
359
|
+
let from = Math.max(target.changes[target.changes.length - 1][0], start, tick);
|
|
360
|
+
while (from <= target.last && target.inputs.has(from)) from++;
|
|
361
|
+
target.changes.push([from, false]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (flags & FLAG_RETURN) {
|
|
365
|
+
const asked = read.uint();
|
|
366
|
+
if (!latest(seat)) seat.changes.push([Math.max(asked, furthest() + 1), true]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const stopListening = match.onMessage((data, from, seq) => {
|
|
370
|
+
if (status !== "running" || seq <= lastSeq) return;
|
|
371
|
+
if (seq !== lastSeq + 1) {
|
|
372
|
+
lost();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
lastSeq = seq;
|
|
376
|
+
heardAny = now();
|
|
377
|
+
if (data[0] !== 245) return;
|
|
378
|
+
const index = seats.findIndex((seat) => seat.id === from.id);
|
|
379
|
+
if (index < 0) return;
|
|
380
|
+
const read = new ByteReader(data.subarray(1));
|
|
381
|
+
try {
|
|
382
|
+
if (read.uint() !== run) return;
|
|
383
|
+
hear(seats[index], index, read);
|
|
384
|
+
} catch {}
|
|
385
|
+
});
|
|
386
|
+
const stopWatching = match.on(() => watch());
|
|
387
|
+
/** The last input a seat is known to have pressed before `at`. */
|
|
388
|
+
function lastBefore(seat, index, at) {
|
|
389
|
+
for (let k = at - 1; k >= tick; k--) {
|
|
390
|
+
const one = seat.inputs.get(k) ?? (index === mine ? local.get(k) : void 0);
|
|
391
|
+
if (one !== void 0) return one;
|
|
392
|
+
}
|
|
393
|
+
return seat.prev;
|
|
394
|
+
}
|
|
395
|
+
/** The inputs to run `at` with: every input heard for it, and guesses for the rest. */
|
|
396
|
+
function best(at) {
|
|
397
|
+
let real = true;
|
|
398
|
+
return {
|
|
399
|
+
inputs: seats.map((seat, index) => {
|
|
400
|
+
if (!playedAt(seat, at)) return null;
|
|
401
|
+
if (at < start) return NOTHING;
|
|
402
|
+
const heard = seat.inputs.get(at);
|
|
403
|
+
if (heard !== void 0) return heard;
|
|
404
|
+
real = false;
|
|
405
|
+
return (index === mine ? local.get(at) : void 0) ?? predict(lastBefore(seat, index, at), index);
|
|
406
|
+
}),
|
|
407
|
+
real
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** Every playing seat's input for `at`, or `null`, with `waiting` saying who is missing. */
|
|
411
|
+
function inputsFor(at) {
|
|
412
|
+
const inputs = [];
|
|
413
|
+
const missing = [];
|
|
414
|
+
for (const seat of seats) if (!playedAt(seat, at)) inputs.push(null);
|
|
415
|
+
else if (at < start) inputs.push(NOTHING);
|
|
416
|
+
else {
|
|
417
|
+
const input = seat.inputs.get(at);
|
|
418
|
+
if (input === void 0) missing.push(seat.id);
|
|
419
|
+
else inputs.push(input);
|
|
420
|
+
}
|
|
421
|
+
waiting = missing;
|
|
422
|
+
return missing.length === 0 ? inputs : null;
|
|
423
|
+
}
|
|
424
|
+
function restartReading() {
|
|
425
|
+
reading = head + delay();
|
|
426
|
+
out.first = reading;
|
|
427
|
+
out.inputs = [];
|
|
428
|
+
}
|
|
429
|
+
/** Follow this device's own seat: stop reading once it is handed over, and ask for it back once caught up. */
|
|
430
|
+
function settle(caughtUp) {
|
|
431
|
+
if (mine < 0) return;
|
|
432
|
+
if (latest(seats[mine])) {
|
|
433
|
+
returning = false;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!returning) {
|
|
437
|
+
if (reading !== null) {
|
|
438
|
+
reading = null;
|
|
439
|
+
out.inputs = [];
|
|
440
|
+
out.hash = null;
|
|
441
|
+
local.clear();
|
|
442
|
+
unheard.length = 0;
|
|
443
|
+
}
|
|
444
|
+
if (!caughtUp) return;
|
|
445
|
+
returning = true;
|
|
446
|
+
restartReading();
|
|
447
|
+
} else if (now() - askedBack < 3e3) return;
|
|
448
|
+
askedBack = now();
|
|
449
|
+
out.back = reading;
|
|
450
|
+
}
|
|
451
|
+
/** A message of inputs from step `first`, with what else `extra` holds. */
|
|
452
|
+
function post(first, inputs, extra = null) {
|
|
453
|
+
const at = now();
|
|
454
|
+
const write = new ByteWriter().byte(245).uint(run).uint(first).uint(inputs.length);
|
|
455
|
+
for (const input of inputs) write.bytes(input);
|
|
456
|
+
write.uint(need);
|
|
457
|
+
if (extra === null) write.uint(0);
|
|
458
|
+
else {
|
|
459
|
+
write.uint((extra.hash ? FLAG_HASH : 0) | (extra.drops.length > 0 ? FLAG_DROP : 0) | (extra.back !== null ? FLAG_RETURN : 0));
|
|
460
|
+
if (extra.hash) write.uint(extra.hash[0]).string(extra.hash[1]);
|
|
461
|
+
if (extra.drops.length > 0) {
|
|
462
|
+
write.uint(extra.drops.length);
|
|
463
|
+
for (const seat of extra.drops) write.uint(seat);
|
|
464
|
+
}
|
|
465
|
+
if (extra.back !== null) write.uint(extra.back);
|
|
466
|
+
}
|
|
467
|
+
match.send(write.finish());
|
|
468
|
+
inFlight.push({
|
|
469
|
+
at,
|
|
470
|
+
first,
|
|
471
|
+
inputs
|
|
472
|
+
});
|
|
473
|
+
if (inFlight.length > 512) inFlight.shift();
|
|
474
|
+
sentAt = at;
|
|
475
|
+
}
|
|
476
|
+
function send(stalled, flush = false) {
|
|
477
|
+
if (reading === null) unheard.length = 0;
|
|
478
|
+
let again = null;
|
|
479
|
+
for (const one of unheard.splice(0)) {
|
|
480
|
+
if (again !== null && again.first + again.inputs.length === one.first && again.inputs.length + one.inputs.length <= RESEND_MOST) {
|
|
481
|
+
again.inputs.push(...one.inputs);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (again !== null) post(again.first, again.inputs);
|
|
485
|
+
again = {
|
|
486
|
+
first: one.first,
|
|
487
|
+
inputs: [...one.inputs]
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (again !== null) post(again.first, again.inputs);
|
|
491
|
+
if (reading === null && out.drops.length === 0) return;
|
|
492
|
+
const at = now();
|
|
493
|
+
const urgent = out.drops.length > 0 || out.back !== null || flush && (out.inputs.length > 0 || out.hash !== null);
|
|
494
|
+
const ready = out.inputs.length >= sendEvery || out.hash !== null || stalled && out.inputs.length > 0;
|
|
495
|
+
const keepAlive = stalled && at - sentAt > 500;
|
|
496
|
+
if (!urgent && !ready && !keepAlive) return;
|
|
497
|
+
if (!urgent && at - sentAt < tickMs * sendEvery * .75) return;
|
|
498
|
+
post(out.first, out.inputs, out);
|
|
499
|
+
out.first += out.inputs.length;
|
|
500
|
+
out.inputs = [];
|
|
501
|
+
out.hash = null;
|
|
502
|
+
out.drops = [];
|
|
503
|
+
out.back = null;
|
|
504
|
+
}
|
|
505
|
+
/** Ask for the seats whose person left, or that kept everybody waiting too long, to be handed to the game. */
|
|
506
|
+
function watch() {
|
|
507
|
+
if (status !== "running" || mine < 0 || !latest(seats[mine])) return;
|
|
508
|
+
const at = now();
|
|
509
|
+
const present = new Set(match.players.map((one) => one.id));
|
|
510
|
+
const connected = at - heardAny < 1500;
|
|
511
|
+
seats.forEach((seat, index) => {
|
|
512
|
+
if (index === mine || !latest(seat) || at - seat.askedDrop < Math.min(timeoutMs, 5e3)) return;
|
|
513
|
+
const left = !present.has(seat.id);
|
|
514
|
+
const silent = connected && waiting.includes(seat.id) && at - seat.heard > timeoutMs;
|
|
515
|
+
if (left || silent) {
|
|
516
|
+
seat.askedDrop = at;
|
|
517
|
+
out.drops.push(index);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/** This device's input for the next step it reads, sent with the next message. */
|
|
522
|
+
function read() {
|
|
523
|
+
const sampled = options.sample(reading);
|
|
524
|
+
const input = sampled instanceof Uint8Array ? sampled : sampled[0] ?? NOTHING;
|
|
525
|
+
out.inputs.push(input);
|
|
526
|
+
if (window > 0) local.set(reading, input);
|
|
527
|
+
reading++;
|
|
528
|
+
}
|
|
529
|
+
function same(a, b) {
|
|
530
|
+
return a.every((one, i) => {
|
|
531
|
+
const other = b[i];
|
|
532
|
+
if (one === null || other === null) return one === other;
|
|
533
|
+
return one.length === other.length && one.every((byte, j) => byte === other[j]);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/** The log has every input for step `at`, the game ran it with them, and it is this device's to forget. */
|
|
537
|
+
function confirm(at, print) {
|
|
538
|
+
for (const seat of seats) {
|
|
539
|
+
const input = seat.inputs.get(at);
|
|
540
|
+
if (input !== void 0) seat.prev = input;
|
|
541
|
+
seat.inputs.delete(at);
|
|
542
|
+
while (seat.changes.length > 1 && seat.changes[1][0] <= at) seat.changes.shift();
|
|
543
|
+
}
|
|
544
|
+
local.delete(at);
|
|
545
|
+
saves.delete(at);
|
|
546
|
+
guessed.delete(at);
|
|
547
|
+
if (print !== null) {
|
|
548
|
+
const entry = prints.get(at) ?? {
|
|
549
|
+
mine: null,
|
|
550
|
+
theirs: []
|
|
551
|
+
};
|
|
552
|
+
entry.mine = print;
|
|
553
|
+
prints.set(at, entry);
|
|
554
|
+
compare(at);
|
|
555
|
+
if (reading !== null) out.hash = [at, print];
|
|
556
|
+
}
|
|
557
|
+
prints.delete(at - hashEvery * 4);
|
|
558
|
+
tick = at + 1;
|
|
559
|
+
}
|
|
560
|
+
/** Run step `at`: confirmed at once when it has every input and follows the last confirmed step, kept to go back to otherwise. */
|
|
561
|
+
function runStep(at, inputs, real, again) {
|
|
562
|
+
const settles = real && at === tick;
|
|
563
|
+
if (!settles) saves.set(at, rollback.save());
|
|
564
|
+
options.step(inputs, at, again);
|
|
565
|
+
const print = options.hash && at > 0 && at % hashEvery === 0 ? options.hash() : null;
|
|
566
|
+
if (settles) confirm(at, print);
|
|
567
|
+
else guessed.set(at, {
|
|
568
|
+
inputs,
|
|
569
|
+
real,
|
|
570
|
+
print
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/** Confirm the guessed steps every input has come in for, as guessed; go back to the first guessed wrong, and run from there again. */
|
|
574
|
+
function correct() {
|
|
575
|
+
let wrong = -1;
|
|
576
|
+
for (let at = tick; at < head; at++) {
|
|
577
|
+
const was = guessed.get(at);
|
|
578
|
+
const now = best(at);
|
|
579
|
+
if (!same(was.inputs, now.inputs)) {
|
|
580
|
+
wrong = at;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
if (now.real) was.real = true;
|
|
584
|
+
}
|
|
585
|
+
while (tick < head && tick !== wrong && guessed.get(tick).real) confirm(tick, guessed.get(tick).print);
|
|
586
|
+
if (wrong < 0) return;
|
|
587
|
+
rollback.restore(saves.get(wrong));
|
|
588
|
+
for (let at = wrong; at < head && status === "running"; at++) {
|
|
589
|
+
const { inputs, real } = best(at);
|
|
590
|
+
runStep(at, inputs, real, true);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
advance(ms) {
|
|
595
|
+
if (status !== "running") return 0;
|
|
596
|
+
settle(false);
|
|
597
|
+
const lead = delay();
|
|
598
|
+
while (reading !== null && reading - head < lead) read();
|
|
599
|
+
if (window > 0) correct();
|
|
600
|
+
clock = Math.min(clock + Math.max(0, ms), tickMs * 3);
|
|
601
|
+
let budget = Math.floor(clock / tickMs);
|
|
602
|
+
const behind = frontier() - lead - head;
|
|
603
|
+
if (behind > 2) budget = Math.max(budget, Math.min(catchUp, behind));
|
|
604
|
+
let ran = 0;
|
|
605
|
+
let stalled = false;
|
|
606
|
+
while (ran < budget && status === "running") {
|
|
607
|
+
let inputs;
|
|
608
|
+
let real = true;
|
|
609
|
+
if (window === 0) inputs = inputsFor(head);
|
|
610
|
+
else {
|
|
611
|
+
({inputs, real} = best(head));
|
|
612
|
+
if (!real && head - tick >= window) {
|
|
613
|
+
inputsFor(tick);
|
|
614
|
+
inputs = null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (inputs === null) {
|
|
618
|
+
stalled = true;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
if (reading !== null) {
|
|
622
|
+
if (reading - head > lead && head - skipped >= SHRINK_EVERY) skipped = head;
|
|
623
|
+
else read();
|
|
624
|
+
}
|
|
625
|
+
if (window === 0) {
|
|
626
|
+
options.step(inputs, head, false);
|
|
627
|
+
confirm(head, options.hash && head > 0 && head % hashEvery === 0 ? options.hash() : null);
|
|
628
|
+
} else runStep(head, inputs, real, false);
|
|
629
|
+
head++;
|
|
630
|
+
ran++;
|
|
631
|
+
}
|
|
632
|
+
if (!stalled) waiting = [];
|
|
633
|
+
clock = Math.max(0, clock - ran * tickMs);
|
|
634
|
+
settle(stalled || frontier() - lead - head <= 2);
|
|
635
|
+
watch();
|
|
636
|
+
send(stalled);
|
|
637
|
+
return ran;
|
|
638
|
+
},
|
|
639
|
+
get tick() {
|
|
640
|
+
return head;
|
|
641
|
+
},
|
|
642
|
+
get confirmed() {
|
|
643
|
+
return tick;
|
|
644
|
+
},
|
|
645
|
+
get waiting() {
|
|
646
|
+
return waiting;
|
|
647
|
+
},
|
|
648
|
+
get status() {
|
|
649
|
+
return status;
|
|
650
|
+
},
|
|
651
|
+
playing(seat) {
|
|
652
|
+
const one = seats[seat];
|
|
653
|
+
return one !== void 0 && playedAt(one, head);
|
|
654
|
+
},
|
|
655
|
+
get rtt() {
|
|
656
|
+
return rtt;
|
|
657
|
+
},
|
|
658
|
+
get delay() {
|
|
659
|
+
return delay();
|
|
660
|
+
},
|
|
661
|
+
stop() {
|
|
662
|
+
if (status === "running") {
|
|
663
|
+
send(false, true);
|
|
664
|
+
status = "stopped";
|
|
665
|
+
}
|
|
666
|
+
stopListening();
|
|
667
|
+
stopWatching();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
//#endregion
|
|
672
|
+
export { LOCKSTEP_TAG, isLockstepMessage, lockstep };
|