@pylonsync/realtime 0.14.0

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.
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { replicationPayload, type TestFrame } from "../test/frames";
4
+ import { EntityInterpolator, type InterpolationOptions } from "./interpolation";
5
+ import { EntityTable } from "./replication";
6
+
7
+ /** Feeds frames to a table and an interpolator, tick by tick. */
8
+ function harness(options?: InterpolationOptions) {
9
+ const table = new EntityTable();
10
+ const interp = new EntityInterpolator(options);
11
+ return {
12
+ interp,
13
+ frame(tick: number, f: TestFrame) {
14
+ interp.record(table, table.apply(replicationPayload(f)), tick);
15
+ },
16
+ at(renderTick: number) {
17
+ interp.update(renderTick);
18
+ return interp.entities;
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("EntityInterpolator", () => {
24
+ test("draws an entity between the samples around the render tick", () => {
25
+ const h = harness();
26
+ h.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
27
+ h.frame(2, { update: [{ id: 1, from: [0, 0, 0], pos: [10, 0, 0] }] });
28
+ h.frame(3, { update: [{ id: 1, from: [10, 0, 0], pos: [20, -4, 0] }] });
29
+ expect(h.at(1.5).get(1)?.x).toBeCloseTo(5);
30
+ const e = h.at(2.25).get(1)!;
31
+ expect(e.x).toBeCloseTo(12.5);
32
+ expect(e.y).toBeCloseTo(-1);
33
+ expect([e.from.tick, e.to.tick, e.t]).toEqual([2, 3, 0.25]);
34
+ // Past the newest sample it stays there.
35
+ expect(h.at(7).get(1)?.x).toBeCloseTo(20);
36
+ });
37
+
38
+ test("an entity appears at its spawn tick and leaves at its despawn tick", () => {
39
+ const h = harness();
40
+ h.frame(1, { full: true });
41
+ h.frame(5, { spawn: [{ id: 3, pos: [1, 1, 1] }] });
42
+ h.frame(8, { despawn: [3] });
43
+ expect(h.at(4.9).has(3)).toBe(false);
44
+ h.at(5);
45
+ expect(h.interp.entered).toEqual([3]);
46
+ expect(h.at(7.9).has(3)).toBe(true);
47
+ h.at(8);
48
+ expect(h.interp.left).toEqual([3]);
49
+ expect(h.interp.entities.size).toBe(0);
50
+ });
51
+
52
+ test("a reused id is a new entity: the old one leaves where it was", () => {
53
+ const h = harness();
54
+ h.frame(1, { full: true, spawn: [{ id: 7, pos: [0, 0, 0], components: { 1: [1] } }] });
55
+ h.frame(3, { despawn: [7], spawn: [{ id: 7, pos: [50, 0, 0] }] });
56
+ const old = h.at(2.5).get(7)!;
57
+ expect(old.x).toBeCloseTo(0);
58
+ expect(old.components.get(1)).toEqual(new Uint8Array([1]));
59
+ const fresh = h.at(3).get(7)!;
60
+ expect(h.interp.left).toEqual([7]);
61
+ expect(h.interp.entered).toEqual([7]);
62
+ expect(fresh).not.toBe(old);
63
+ expect(fresh.x).toBeCloseTo(50);
64
+ expect(fresh.components.size).toBe(0);
65
+ });
66
+
67
+ test("a quiet entity stands still until the tick before its next move", () => {
68
+ const h = harness();
69
+ h.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
70
+ h.frame(10, { update: [{ id: 1, from: [0, 0, 0], pos: [10, 0, 0] }] });
71
+ expect(h.at(5).get(1)?.x).toBeCloseTo(0);
72
+ expect(h.at(9.5).get(1)?.x).toBeCloseTo(5);
73
+
74
+ const spread = harness({ holdWhenQuiet: false });
75
+ spread.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
76
+ spread.frame(11, { update: [{ id: 1, from: [0, 0, 0], pos: [10, 0, 0] }] });
77
+ expect(spread.at(6).get(1)?.x).toBeCloseTo(5);
78
+ });
79
+
80
+ test("a move longer than snapDistance jumps instead of sliding", () => {
81
+ const h = harness({ snapDistance: 5 });
82
+ h.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
83
+ h.frame(2, { update: [{ id: 1, from: [0, 0, 0], pos: [100, 0, 0] }] });
84
+ h.frame(3, { update: [{ id: 1, from: [100, 0, 0], pos: [102, 0, 0] }] });
85
+ expect(h.at(1.9).get(1)?.x).toBeCloseTo(0);
86
+ expect(h.at(2).get(1)?.x).toBeCloseTo(100);
87
+ expect(h.at(2.5).get(1)?.x).toBeCloseTo(101);
88
+ });
89
+
90
+ test("components change at the tick they changed, with the positions", () => {
91
+ const h = harness();
92
+ h.frame(1, { full: true, spawn: [{ id: 1, components: { 1: [100] } }] });
93
+ h.frame(3, { update: [{ id: 1, components: { 1: [40], 2: [9] } }] });
94
+ h.frame(4, { update: [{ id: 1, components: { 2: null } }] });
95
+ expect(h.at(2.9).get(1)?.components.get(1)).toEqual(new Uint8Array([100]));
96
+ const e = h.at(3.5).get(1)!;
97
+ expect(e.components.get(1)).toEqual(new Uint8Array([40]));
98
+ expect(e.to.components.has(2)).toBe(false);
99
+ expect(h.at(4).get(1)?.components.has(2)).toBe(false);
100
+ });
101
+
102
+ test("a full frame ends entities it lacks and continues the rest", () => {
103
+ const h = harness();
104
+ h.frame(1, { full: true, spawn: [{ id: 1 }, { id: 2, pos: [0, 0, 0] }] });
105
+ h.at(1);
106
+ h.frame(5, { full: true, spawn: [{ id: 2, pos: [4, 0, 0] }] });
107
+ expect(h.at(4.5).has(1)).toBe(true);
108
+ const e = h.at(5).get(2)!;
109
+ expect(h.interp.left).toEqual([1]);
110
+ expect(h.interp.entered).toEqual([]);
111
+ expect(e.x).toBeCloseTo(4);
112
+ });
113
+
114
+ test("ticks that go back (a restarted shard) start over", () => {
115
+ const h = harness();
116
+ h.frame(100, { full: true, spawn: [{ id: 1, pos: [5, 0, 0] }] });
117
+ h.at(100);
118
+ h.frame(1, { full: true, spawn: [{ id: 9 }] });
119
+ const now = h.at(1);
120
+ expect([...now.keys()]).toEqual([9]);
121
+ expect(h.interp.left).toEqual([1]);
122
+ });
123
+
124
+ test("keeps a bounded history", () => {
125
+ const h = harness({ maxSamples: 4 });
126
+ h.frame(0, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
127
+ for (let t = 1; t <= 50; t++) {
128
+ h.frame(t, { update: [{ id: 1, from: [t - 1, 0, 0], pos: [t, 0, 0] }] });
129
+ }
130
+ // Only the newest samples remain; an old render tick clamps to them.
131
+ expect(h.at(10).get(1)?.x).toBeCloseTo(47);
132
+ expect(h.at(49.5).get(1)?.x).toBeCloseTo(49.5);
133
+ });
134
+
135
+ test("a render tick never goes back below one already drawn", () => {
136
+ const h = harness();
137
+ h.frame(10, { full: true, spawn: [{ id: 5 }] });
138
+ h.frame(12, { update: [{ id: 5, from: [0, 0, 0], pos: [2, 0, 0] }] });
139
+ h.at(11);
140
+ expect(h.interp.entered).toEqual([5]);
141
+ h.at(9);
142
+ expect(h.interp.left).toEqual([]);
143
+ expect(h.interp.entities.get(5)?.x).toBeCloseTo(0);
144
+ // It stood still until tick 11, then moved 2 by tick 12.
145
+ h.at(11.5);
146
+ expect(h.interp.entered).toEqual([]);
147
+ expect(h.interp.entities.get(5)?.x).toBeCloseTo(1);
148
+ });
149
+
150
+ test("ended entities do not pile up when update does not run", () => {
151
+ const h = harness();
152
+ h.frame(0, { full: true });
153
+ let tick = 1;
154
+ for (let id = 1; id <= 20_000; id++) {
155
+ h.frame(tick++, { spawn: [{ id }] });
156
+ h.frame(tick++, { despawn: [id] });
157
+ }
158
+ const lives = (h.interp as unknown as { lives: Map<number, unknown> }).lives;
159
+ expect(lives.size).toBeLessThan(200);
160
+ // A render tick past them all draws nothing.
161
+ expect(h.at(tick).size).toBe(0);
162
+ });
163
+ });
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Entity interpolation for render loops.
3
+ *
4
+ * Record every replication frame with the tick it was built on; each render
5
+ * frame, call `update` with a tick a little behind the server's (see
6
+ * `ShardClock`). Each entity is then drawn between the two samples around
7
+ * that tick, so motion is smooth even though frames arrive at the tick rate
8
+ * and with jitter. Spawns, despawns, and component changes appear at the
9
+ * tick they happened, in step with the positions.
10
+ */
11
+
12
+ import type { EntityTable, ReplicatedEntity, ReplicationSummary } from "./replication";
13
+
14
+ export interface InterpolationOptions {
15
+ /**
16
+ * A move longer than this (world units) between two samples is a
17
+ * teleport: the entity jumps instead of sliding. Default: no limit.
18
+ */
19
+ snapDistance?: number;
20
+ /**
21
+ * Treat an entity that sent no update as not moving, so its next move
22
+ * starts from the tick before it arrives. Default true. Set false for a
23
+ * shard with a byte budget (`max_bytes_per_tick`), where updates can
24
+ * wait for later ticks.
25
+ */
26
+ holdWhenQuiet?: boolean;
27
+ /** Samples kept per entity. Default 32. */
28
+ maxSamples?: number;
29
+ }
30
+
31
+ /** An entity's state at one tick. */
32
+ export interface EntitySample {
33
+ readonly tick: number;
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly z: number;
37
+ /** Component id to bytes. Shared between samples; do not change it. */
38
+ readonly components: ReadonlyMap<number, Uint8Array>;
39
+ }
40
+
41
+ /** One entity as drawn at the render tick. The object is reused. */
42
+ export interface InterpolatedEntity {
43
+ readonly id: number;
44
+ x: number;
45
+ y: number;
46
+ z: number;
47
+ /** Components as of the sample at or before the render tick. */
48
+ components: ReadonlyMap<number, Uint8Array>;
49
+ /**
50
+ * The samples around the render tick and the fraction between them, to
51
+ * interpolate game data kept in components (a heading, a health bar).
52
+ * `from` and `to` are the same sample when there is no later one.
53
+ */
54
+ from: EntitySample;
55
+ to: EntitySample;
56
+ t: number;
57
+ }
58
+
59
+ /** One entity from spawn to despawn. An id can have a later life. */
60
+ interface Life {
61
+ spawnTick: number;
62
+ /** The tick it despawned on, or null while it exists. */
63
+ despawnTick: number | null;
64
+ samples: EntitySample[];
65
+ }
66
+
67
+ const EMPTY: ReadonlyMap<number, Uint8Array> = new Map();
68
+ /** Ticks an ended life is kept for a render tick that lags behind. */
69
+ const ENDED_TICKS = 256;
70
+
71
+ export class EntityInterpolator {
72
+ /** Entities that exist at the last `update`'s render tick. */
73
+ readonly entities = new Map<number, InterpolatedEntity>();
74
+ /**
75
+ * Ids the last `update` added to `entities`. An id in both `left` and
76
+ * `entered` names a new entity that reused the id: rebuild what you
77
+ * drew for it.
78
+ */
79
+ readonly entered: number[] = [];
80
+ /** Ids the last `update` removed from `entities`. */
81
+ readonly left: number[] = [];
82
+
83
+ private readonly lives = new Map<number, Life[]>();
84
+ /** The life each entry in `entities` shows. */
85
+ private readonly shown = new Map<number, Life>();
86
+ private readonly snapDistance: number;
87
+ private readonly holdWhenQuiet: boolean;
88
+ private readonly maxSamples: number;
89
+ private lastTick = -1;
90
+ /** The newest tick the last `update` drew; a render tick never goes
91
+ * below it. */
92
+ private floor = -Infinity;
93
+ /** Lives that ended, oldest first, so `record` can drop them when
94
+ * `update` does not run (a hidden tab keeps receiving frames). */
95
+ private ended: Array<{ id: number; life: Life }> = [];
96
+
97
+ constructor(options: InterpolationOptions = {}) {
98
+ this.snapDistance = options.snapDistance ?? Infinity;
99
+ this.holdWhenQuiet = options.holdWhenQuiet ?? true;
100
+ this.maxSamples = Math.max(2, options.maxSamples ?? 32);
101
+ }
102
+
103
+ /** The newest tick recorded, or -1. */
104
+ get latestTick(): number {
105
+ return this.lastTick;
106
+ }
107
+
108
+ /** Forget everything. The next `update` removes every entity. */
109
+ clear(): void {
110
+ this.lives.clear();
111
+ this.ended = [];
112
+ this.lastTick = -1;
113
+ this.floor = -Infinity;
114
+ }
115
+
116
+ /**
117
+ * Record the table after a replication frame for `tick` applied, with the
118
+ * summary `EntityTable.apply` returned.
119
+ */
120
+ record(table: EntityTable, summary: ReplicationSummary, tick: number): void {
121
+ if (tick < this.lastTick) {
122
+ // Ticks went back: a restarted or different shard.
123
+ this.clear();
124
+ }
125
+ this.lastTick = tick;
126
+ this.dropEnded(tick);
127
+
128
+ if (summary.full) {
129
+ // The table was rebuilt: what it lacks now is gone.
130
+ const present = new Set(summary.spawned);
131
+ for (const [id, lives] of this.lives) {
132
+ const open = lives[lives.length - 1];
133
+ if (open.despawnTick === null && !present.has(id)) this.end(id, open, tick);
134
+ }
135
+ for (const id of summary.spawned) {
136
+ const e = table.get(id);
137
+ if (!e) continue;
138
+ const open = this.openLife(id);
139
+ if (open) this.push(open, e, tick);
140
+ else this.spawn(id, e, tick);
141
+ }
142
+ return;
143
+ }
144
+
145
+ for (const id of summary.despawned) {
146
+ const open = this.openLife(id);
147
+ if (open) this.end(id, open, tick);
148
+ }
149
+ for (const id of summary.spawned) {
150
+ const e = table.get(id);
151
+ if (e) this.spawn(id, e, tick);
152
+ }
153
+ for (const id of summary.updated) {
154
+ const e = table.get(id);
155
+ const open = this.openLife(id);
156
+ if (e && open) this.push(open, e, tick);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Place every entity at `renderTick` (fractional). A render tick below
162
+ * one already drawn is raised to it, so nothing drawn goes back in time.
163
+ */
164
+ update(renderTick: number): void {
165
+ this.entered.length = 0;
166
+ this.left.length = 0;
167
+ renderTick = Math.max(renderTick, this.floor);
168
+ // What this update draws is at most the newest tick recorded.
169
+ this.floor = Math.min(renderTick, this.lastTick);
170
+ for (const [id, lives] of this.lives) {
171
+ // Lives that ended by the render tick are over.
172
+ while (lives.length > 0 && lives[0].despawnTick !== null && lives[0].despawnTick <= renderTick) {
173
+ lives.shift();
174
+ }
175
+ if (lives.length === 0) {
176
+ this.lives.delete(id);
177
+ this.remove(id);
178
+ continue;
179
+ }
180
+ const life = lives[0];
181
+ if (life.spawnTick > renderTick) {
182
+ // Not spawned yet at the render tick.
183
+ this.remove(id);
184
+ continue;
185
+ }
186
+ this.place(id, life, renderTick);
187
+ }
188
+ for (const id of this.entities.keys()) {
189
+ if (!this.lives.has(id)) this.remove(id);
190
+ }
191
+ }
192
+
193
+ private end(id: number, life: Life, tick: number): void {
194
+ life.despawnTick = tick;
195
+ this.ended.push({ id, life });
196
+ }
197
+
198
+ /**
199
+ * Forget lives that ended long before `tick`. `update` removes them
200
+ * when it runs; this bounds them when it does not.
201
+ */
202
+ private dropEnded(tick: number): void {
203
+ let n = 0;
204
+ while (n < this.ended.length && (this.ended[n].life.despawnTick as number) < tick - ENDED_TICKS) {
205
+ const { id, life } = this.ended[n];
206
+ const lives = this.lives.get(id);
207
+ if (lives) {
208
+ const i = lives.indexOf(life);
209
+ if (i >= 0) lives.splice(i, 1);
210
+ if (lives.length === 0) this.lives.delete(id);
211
+ }
212
+ n++;
213
+ }
214
+ if (n > 0) this.ended.splice(0, n);
215
+ }
216
+
217
+ private openLife(id: number): Life | null {
218
+ const lives = this.lives.get(id);
219
+ const last = lives?.[lives.length - 1];
220
+ return last && last.despawnTick === null ? last : null;
221
+ }
222
+
223
+ private spawn(id: number, e: ReplicatedEntity, tick: number): void {
224
+ let lives = this.lives.get(id);
225
+ if (!lives) {
226
+ lives = [];
227
+ this.lives.set(id, lives);
228
+ }
229
+ const open = lives[lives.length - 1];
230
+ if (open && open.despawnTick === null) this.end(id, open, tick);
231
+ lives.push({
232
+ spawnTick: tick,
233
+ despawnTick: null,
234
+ samples: [{ tick, x: e.x, y: e.y, z: e.z, components: new Map(e.components) }],
235
+ });
236
+ }
237
+
238
+ private push(life: Life, e: ReplicatedEntity, tick: number): void {
239
+ const samples = life.samples;
240
+ const last = samples[samples.length - 1];
241
+ const components = sameComponents(last.components, e.components)
242
+ ? last.components
243
+ : new Map(e.components);
244
+ const moved = last.x !== e.x || last.y !== e.y || last.z !== e.z;
245
+ if (!moved && components === last.components) return;
246
+ if (last.tick === tick) {
247
+ samples[samples.length - 1] = { tick, x: e.x, y: e.y, z: e.z, components };
248
+ return;
249
+ }
250
+ if (this.holdWhenQuiet && moved && last.tick < tick - 1) {
251
+ // No update since `last`: it stood still until the tick before.
252
+ samples.push({ ...last, tick: tick - 1 });
253
+ }
254
+ samples.push({ tick, x: e.x, y: e.y, z: e.z, components });
255
+ while (samples.length > this.maxSamples) samples.shift();
256
+ }
257
+
258
+ private place(id: number, life: Life, renderTick: number): void {
259
+ const samples = life.samples;
260
+ // The last sample at or before the render tick. Samples before the
261
+ // spawn tick do not exist, and the first sample is the spawn.
262
+ let i = samples.length - 1;
263
+ while (i > 0 && samples[i].tick > renderTick) i--;
264
+ const from = samples[i];
265
+ const to = samples[i + 1] ?? from;
266
+ let t = to === from ? 0 : (renderTick - from.tick) / (to.tick - from.tick);
267
+ t = Math.min(1, Math.max(0, t));
268
+ const dx = to.x - from.x;
269
+ const dy = to.y - from.y;
270
+ const dz = to.z - from.z;
271
+ const teleport = dx * dx + dy * dy + dz * dz > this.snapDistance * this.snapDistance;
272
+ const k = teleport ? 0 : t;
273
+
274
+ let view = this.entities.get(id);
275
+ if (view && this.shown.get(id) !== life) {
276
+ // The id names a new entity now: the old one left.
277
+ this.remove(id);
278
+ view = undefined;
279
+ }
280
+ if (!view) {
281
+ view = { id, x: 0, y: 0, z: 0, components: EMPTY, from, to, t };
282
+ this.entities.set(id, view);
283
+ this.shown.set(id, life);
284
+ this.entered.push(id);
285
+ }
286
+ view.x = from.x + dx * k;
287
+ view.y = from.y + dy * k;
288
+ view.z = from.z + dz * k;
289
+ view.components = from.components;
290
+ view.from = from;
291
+ view.to = to;
292
+ view.t = t;
293
+
294
+ // Samples two behind the one in use are no longer needed.
295
+ if (i > 1) samples.splice(0, i - 1);
296
+ }
297
+
298
+ private remove(id: number): void {
299
+ this.shown.delete(id);
300
+ if (this.entities.delete(id)) this.left.push(id);
301
+ }
302
+ }
303
+
304
+ /** True when `a` and `b` hold the same components with the same bytes. */
305
+ function sameComponents(
306
+ a: ReadonlyMap<number, Uint8Array>,
307
+ b: ReadonlyMap<number, Uint8Array>,
308
+ ): boolean {
309
+ if (a.size !== b.size) return false;
310
+ for (const [k, v] of b) {
311
+ const w = a.get(k);
312
+ if (w === v) continue;
313
+ if (!w || w.length !== v.length) return false;
314
+ for (let i = 0; i < v.length; i++) if (w[i] !== v[i]) return false;
315
+ }
316
+ return true;
317
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { Predictor } from "./prediction";
4
+
5
+ type Move = { dx: number };
6
+ const step = (x: number, m: Move) => x + m.dx;
7
+
8
+ describe("Predictor", () => {
9
+ test("replays the inputs the shard has not processed on its state", () => {
10
+ const p = new Predictor(step);
11
+ p.push(1, { dx: 1 });
12
+ p.push(2, { dx: 2 });
13
+ p.push(3, { dx: 4 });
14
+ // The shard applied 1 (x = 1) as of this frame.
15
+ expect(p.reconcile(1, 1)).toBe(7);
16
+ expect(p.size).toBe(2);
17
+ // It clamped input 2 (x = 1.5 instead of 3): the prediction follows.
18
+ expect(p.reconcile(1.5, 2)).toBe(5.5);
19
+ expect(p.reconcile(5.5, 3)).toBe(5.5);
20
+ expect(p.size).toBe(0);
21
+ });
22
+
23
+ test("forgets refused inputs and inputs that were not sent", () => {
24
+ const p = new Predictor(step);
25
+ p.push(0, { dx: 100 });
26
+ p.push(4, { dx: 1 });
27
+ p.push(5, { dx: 2 });
28
+ p.reject(4);
29
+ p.reject(null);
30
+ expect(p.reconcile(0, 3)).toBe(2);
31
+ });
32
+
33
+ test("reset drops everything, and pending inputs are bounded", () => {
34
+ const p = new Predictor(step, { maxPending: 3 });
35
+ for (let seq = 1; seq <= 5; seq++) p.push(seq, { dx: 1 });
36
+ expect(p.size).toBe(3);
37
+ expect(p.reconcile(0, 0)).toBe(3);
38
+ p.reset();
39
+ expect(p.reconcile(10, 0)).toBe(10);
40
+ });
41
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Client-side prediction for the local player.
3
+ *
4
+ * Apply each input locally as it is sent, and keep it until the shard
5
+ * acknowledges it. When a frame arrives with the server's state and its
6
+ * `ack` (the highest input sequence number the shard has processed), start
7
+ * from the server's state and apply the inputs it has not processed yet.
8
+ * The result is where the local player is after every input, corrected by
9
+ * whatever the server decided.
10
+ *
11
+ * `step` must be deterministic and must match what the shard does with an
12
+ * input closely enough that the replayed state agrees with the server's.
13
+ * It must not change `state` in place; return a new one.
14
+ */
15
+
16
+ export interface PredictorOptions {
17
+ /**
18
+ * Inputs kept while waiting for an ack. Past this the oldest are
19
+ * dropped (the prediction then misses them). Default 256.
20
+ */
21
+ maxPending?: number;
22
+ }
23
+
24
+ interface Pending<I> {
25
+ seq: number;
26
+ input: I;
27
+ }
28
+
29
+ export class Predictor<S, I> {
30
+ private pending: Array<Pending<I>> = [];
31
+ private readonly maxPending: number;
32
+
33
+ constructor(
34
+ private readonly step: (state: S, input: I) => S,
35
+ options: PredictorOptions = {},
36
+ ) {
37
+ this.maxPending = Math.max(1, options.maxPending ?? 256);
38
+ }
39
+
40
+ /** Inputs sent and not yet acknowledged. */
41
+ get size(): number {
42
+ return this.pending.length;
43
+ }
44
+
45
+ /** Record an input sent with sequence number `seq`. A `seq` of 0 (not
46
+ * sent) is ignored. */
47
+ push(seq: number, input: I): void {
48
+ if (seq <= 0) return;
49
+ this.pending.push({ seq, input });
50
+ if (this.pending.length > this.maxPending) this.pending.shift();
51
+ }
52
+
53
+ /** The shard refused input `seq`: it will never apply. */
54
+ reject(seq: number | null): void {
55
+ if (seq === null) return;
56
+ this.pending = this.pending.filter((p) => p.seq !== seq);
57
+ }
58
+
59
+ /**
60
+ * The predicted state: `server` (the shard's state as of a frame) with
61
+ * every input after `ack` applied. Inputs up to `ack` are dropped.
62
+ */
63
+ reconcile(server: S, ack: number): S {
64
+ let drop = 0;
65
+ while (drop < this.pending.length && this.pending[drop].seq <= ack) drop++;
66
+ if (drop > 0) this.pending.splice(0, drop);
67
+ let state = server;
68
+ for (const p of this.pending) state = this.step(state, p.input);
69
+ return state;
70
+ }
71
+
72
+ /**
73
+ * Drop every pending input. Call it when the connection reopens: inputs
74
+ * sent on the old connection either applied before it closed (and are
75
+ * in the server's state) or never will.
76
+ */
77
+ reset(): void {
78
+ this.pending = [];
79
+ }
80
+ }