openrtc-netcode 3.0.1 → 3.0.3

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,135 @@
1
+ import { MAX_FRAME_BYTES } from './validation.js';
2
+ /** Per-peer replacement, expiry and budgets. flush() is driven by the game tick. */
3
+ export class ReplicationScheduler {
4
+ constructor(transmit, options = {}) {
5
+ this.transmit = transmit;
6
+ this.peers = new Map();
7
+ this.options = {
8
+ bytesPerSecond: 128 * 1024, burstBytes: 64 * 1024, maxPendingBytes: 256 * 1024,
9
+ maxPendingObjects: 1024, maxStateAgeMs: 250, ...options,
10
+ };
11
+ if (Object.values(this.options).some((value) => !Number.isFinite(value) || value <= 0)
12
+ || this.options.burstBytes <= 1024 || this.options.burstBytes > MAX_FRAME_BYTES / 2
13
+ || !Number.isInteger(this.options.maxPendingObjects) || this.options.maxPendingObjects > 10000)
14
+ throw new Error('Invalid replication budgets.');
15
+ }
16
+ enqueue(peerId, key, value, bytes, priority = 0) {
17
+ const queue = this.peer(peerId);
18
+ const old = queue.entries.get(key);
19
+ if (queue.closed || !Number.isFinite(priority) || !Number.isSafeInteger(bytes) || bytes + 1024 > this.options.burstBytes || bytes < 1
20
+ || queue.stats.queuedBytes - (old?.bytes ?? 0) + bytes > this.options.maxPendingBytes
21
+ || (!old && queue.entries.size >= this.options.maxPendingObjects)) {
22
+ queue.stats.rejected++;
23
+ return Promise.reject(new Error('Replication budget exhausted.'));
24
+ }
25
+ if (old) {
26
+ old.resolve();
27
+ queue.stats.superseded++;
28
+ queue.stats.queuedBytes -= old.bytes;
29
+ }
30
+ const promise = new Promise((resolve, reject) => {
31
+ queue.entries.set(key, { value, bytes, priority, time: performance.now(), resolve, reject });
32
+ });
33
+ queue.stats.queuedBytes += bytes;
34
+ // Batch all updates from this simulation turn, without a network timer.
35
+ queueMicrotask(() => this.pump(peerId, queue, performance.now()));
36
+ return promise;
37
+ }
38
+ flush(now = performance.now()) {
39
+ for (const [peerId, queue] of this.peers)
40
+ this.pump(peerId, queue, now);
41
+ }
42
+ cancel(peerId, key) {
43
+ const queue = this.peers.get(peerId), entry = queue?.entries.get(key);
44
+ if (!entry || !queue)
45
+ return;
46
+ queue.entries.delete(key);
47
+ queue.stats.queuedBytes -= entry.bytes;
48
+ entry.resolve();
49
+ }
50
+ closePeer(peerId) {
51
+ const queue = this.peers.get(peerId);
52
+ if (!queue)
53
+ return;
54
+ queue.closed = true;
55
+ for (const entry of queue.active)
56
+ entry.reject(new Error('Replication match retired.'));
57
+ queue.active = [];
58
+ for (const entry of queue.entries.values())
59
+ entry.reject(new Error('Replication match retired.'));
60
+ queue.entries.clear();
61
+ queue.stats.queuedBytes = 0;
62
+ this.peers.delete(peerId);
63
+ }
64
+ close() { for (const peerId of this.peers.keys())
65
+ this.closePeer(peerId); }
66
+ diagnostics(peerId) {
67
+ const queue = this.peers.get(peerId);
68
+ if (!queue)
69
+ return { queuedBytes: 0, oldestStateAgeMs: 0, sentBytes: 0, sentBatches: 0, superseded: 0, expired: 0, rejected: 0 };
70
+ const oldest = Math.min(performance.now(), ...Array.from(queue.entries.values(), (entry) => entry.time));
71
+ return { ...queue.stats, oldestStateAgeMs: performance.now() - oldest };
72
+ }
73
+ peer(peerId) {
74
+ let queue = this.peers.get(peerId);
75
+ if (!queue) {
76
+ queue = {
77
+ entries: new Map(), active: [], running: false, closed: false, tokens: this.options.burstBytes, last: performance.now(),
78
+ stats: { queuedBytes: 0, oldestStateAgeMs: 0, sentBytes: 0, sentBatches: 0, superseded: 0, expired: 0, rejected: 0 },
79
+ };
80
+ this.peers.set(peerId, queue);
81
+ }
82
+ return queue;
83
+ }
84
+ pump(peerId, queue, now) {
85
+ if (queue.closed || queue.running)
86
+ return;
87
+ queue.tokens = Math.min(this.options.burstBytes, queue.tokens + Math.max(0, now - queue.last) * this.options.bytesPerSecond / 1000);
88
+ queue.last = now;
89
+ for (const [key, entry] of queue.entries) {
90
+ if (now - entry.time > this.options.maxStateAgeMs) {
91
+ queue.entries.delete(key);
92
+ queue.stats.queuedBytes -= entry.bytes;
93
+ queue.stats.expired++;
94
+ entry.resolve();
95
+ }
96
+ }
97
+ const selected = [];
98
+ let bytes = 1024;
99
+ for (const [key, entry] of [...queue.entries].sort((a, b) => b[1].priority - a[1].priority || a[1].time - b[1].time)) {
100
+ if (selected.length >= 1024)
101
+ break;
102
+ if (bytes + entry.bytes > Math.min(queue.tokens, this.options.burstBytes))
103
+ continue;
104
+ selected.push(entry);
105
+ bytes += entry.bytes;
106
+ queue.entries.delete(key);
107
+ queue.stats.queuedBytes -= entry.bytes;
108
+ }
109
+ if (!selected.length)
110
+ return;
111
+ queue.tokens -= bytes;
112
+ queue.running = true;
113
+ queue.active = selected;
114
+ void Promise.resolve().then(() => {
115
+ if (queue.closed)
116
+ throw new Error('Replication match retired.');
117
+ return this.transmit(peerId, selected.map((entry) => entry.value));
118
+ }).then((actualBytes) => {
119
+ if (queue.closed)
120
+ throw new Error('Replication match retired.');
121
+ const charged = typeof actualBytes === 'number' ? actualBytes : bytes;
122
+ queue.tokens -= Math.max(0, charged - bytes);
123
+ queue.stats.sentBytes += charged;
124
+ queue.stats.sentBatches++;
125
+ for (const entry of selected)
126
+ entry.resolve();
127
+ }).catch((error) => { for (const entry of selected)
128
+ entry.reject(error); }).finally(() => {
129
+ queue.active = [];
130
+ queue.running = false;
131
+ if (!queue.closed)
132
+ this.pump(peerId, queue, performance.now());
133
+ });
134
+ }
135
+ }
@@ -6,16 +6,6 @@ function assertRoom(room, requested) {
6
6
  throw new Error(`Activated OpenRTC room '${room.id}' cannot join '${requested}'.`);
7
7
  }
8
8
  }
9
- function peerMember(peer) {
10
- const peerId = String(peer.nodeId ?? peer.id ?? peer.deviceId ?? '');
11
- return {
12
- nodeId: peerId,
13
- userId: typeof peer.userId === 'string' ? peer.userId : undefined,
14
- ticket: typeof peer.ticket === 'string' ? peer.ticket : undefined,
15
- joinedAt: typeof peer.connectedAt === 'number' ? peer.connectedAt : Date.now(),
16
- lastSeenAt: Date.now(),
17
- };
18
- }
19
9
  /**
20
10
  * Adapts one OpenRTC 2.0 capability handle to netcode's transport-only runtime
21
11
  * contract. It never creates a second room, user scope, or base space.
@@ -24,7 +14,10 @@ export function runtimeFromRoom(room) {
24
14
  // Diagnostics expose raw core connections; only the avenue's public
25
15
  // onConnection projection owns the stable media-enabled logical view.
26
16
  const adaptedByPeer = new Map();
17
+ const closeObservers = new Map();
27
18
  const listeners = new Set();
19
+ let localNodeId = '';
20
+ const membersWithLocal = (peers) => room.closed || !localNodeId ? peers : [{ nodeId: localNodeId }, ...peers.filter((peer) => peer.nodeId !== localNodeId)];
28
21
  let stopConnectionObservation = null;
29
22
  const rawConnections = () => room.diagnostics.connections();
30
23
  const peerIdFor = (connection) => connection.peerId ?? connection.remoteNodeId ?? connection.deviceId ?? connection.id ?? '';
@@ -36,6 +29,8 @@ export function runtimeFromRoom(room) {
36
29
  let adapted;
37
30
  const removeFromProjection = () => {
38
31
  closed = true;
32
+ closeObservers.get(adapted)?.();
33
+ closeObservers.delete(adapted);
39
34
  if (adaptedByPeer.get(peerId) === adapted)
40
35
  adaptedByPeer.delete(peerId);
41
36
  };
@@ -58,12 +53,15 @@ export function runtimeFromRoom(room) {
58
53
  callback();
59
54
  }),
60
55
  };
61
- connection.onClose(removeFromProjection);
56
+ closeObservers.set(adapted, connection.onClose(removeFromProjection));
62
57
  return adapted;
63
58
  };
64
59
  const publish = (connection) => {
65
60
  const peerId = peerIdFor(connection);
66
- if (!peerId || adaptedByPeer.has(peerId))
61
+ if (!peerId)
62
+ return;
63
+ const previous = adaptedByPeer.get(peerId);
64
+ if (previous && previous.id === connection.id)
67
65
  return;
68
66
  const adapted = adaptConnection(connection);
69
67
  if (adapted.isClosed)
@@ -84,11 +82,15 @@ export function runtimeFromRoom(room) {
84
82
  return {
85
83
  lifecycle: true,
86
84
  channels: room.channels,
85
+ isRoomActive: () => !room.closed,
87
86
  initialize: async () => undefined,
88
87
  start: async () => undefined,
89
- stop: async () => {
88
+ dispose: async () => {
90
89
  stopConnectionObservation?.();
91
90
  stopConnectionObservation = null;
91
+ for (const stop of closeObservers.values())
92
+ stop();
93
+ closeObservers.clear();
92
94
  listeners.clear();
93
95
  adaptedByPeer.clear();
94
96
  },
@@ -97,6 +99,7 @@ export function runtimeFromRoom(room) {
97
99
  const nodeId = typeof status.localNodeId === 'string' ? status.localNodeId.trim() : '';
98
100
  if (!nodeId)
99
101
  throw new Error('The activated OpenRTC room has no local peer identity yet.');
102
+ localNodeId = nodeId;
100
103
  return nodeId;
101
104
  },
102
105
  createRoom: async (roomId = room.id) => {
@@ -113,12 +116,11 @@ export function runtimeFromRoom(room) {
113
116
  },
114
117
  watchRoom: (roomId, callback) => {
115
118
  assertRoom(room, roomId);
116
- return room.peers.watch((peers) => callback(peers.map((peer) => peerMember(peer))));
119
+ return room.members.watch((members) => callback(membersWithLocal(members.filter((member) => !!member.nodeId).map((member) => ({ nodeId: member.nodeId, userId: member.userId, joinedAt: member.joinedAt })))));
117
120
  },
118
121
  getRoomMembers: async (roomId) => {
119
122
  assertRoom(room, roomId);
120
- return room.peers.listConnected()
121
- .map((peer) => peerMember(peer));
123
+ return membersWithLocal((await room.members.list()).filter((member) => !!member.nodeId).map((member) => ({ nodeId: member.nodeId, userId: member.userId, joinedAt: member.joinedAt })));
122
124
  },
123
125
  onConnection: (callback) => {
124
126
  listeners.add(callback);
@@ -0,0 +1,80 @@
1
+ import type { NetcodeTransform, NetcodeVector3 } from './types.js';
2
+ /** Game-driven fixed simulation time. This owns no network or browser timer. */
3
+ export declare class SimulationClock {
4
+ readonly tickRate: number;
5
+ readonly maxCatchUpTicks: number;
6
+ tick: number;
7
+ droppedTicks: number;
8
+ private accumulator;
9
+ readonly deltaSeconds: number;
10
+ constructor(tickRate?: number, maxCatchUpTicks?: number);
11
+ advance(deltaSeconds: number, simulate: (tick: number, deltaSeconds: number) => void): number;
12
+ reset(tick?: number): void;
13
+ }
14
+ export interface TransformSnapshot {
15
+ tick: number;
16
+ revision: number;
17
+ epoch: string;
18
+ transform: NetcodeTransform;
19
+ linearVelocity?: NetcodeVector3;
20
+ teleport?: boolean;
21
+ }
22
+ export interface SnapshotBufferOptions {
23
+ tickRate?: number;
24
+ capacity?: number;
25
+ minDelaySeconds?: number;
26
+ maxDelaySeconds?: number;
27
+ maxExtrapolationSeconds?: number;
28
+ hermite?: boolean;
29
+ }
30
+ /** Authoritative-tick interpolation into caller-owned vectors/quaternions. */
31
+ export declare class SnapshotBuffer {
32
+ private samples;
33
+ private epoch;
34
+ private readonly retiredEpochs;
35
+ private newestRevision;
36
+ private offsetSeconds;
37
+ private jitterSeconds;
38
+ private lastArrival;
39
+ readonly options: Required<SnapshotBufferOptions>;
40
+ readonly stats: {
41
+ underruns: number;
42
+ duplicateDrops: number;
43
+ overflowDrops: number;
44
+ extrapolationSeconds: number;
45
+ };
46
+ constructor(options?: SnapshotBufferOptions);
47
+ get size(): number;
48
+ get delaySeconds(): number;
49
+ reset(): void;
50
+ private clearSamples;
51
+ push(snapshot: TransformSnapshot, receivedSeconds?: number): boolean;
52
+ sample(receivedSeconds: number, output: NetcodeTransform): boolean;
53
+ sampleTick(tick: number, output: NetcodeTransform): boolean;
54
+ }
55
+ /** Input replay is engine-neutral; the supplied step function owns game rules. */
56
+ export declare class InputPrediction<State, Input> {
57
+ private readonly step;
58
+ private readonly capacity;
59
+ private inputs;
60
+ private sequence;
61
+ private acknowledged;
62
+ private current;
63
+ constructor(initial: State, step: (state: State, input: Input, tick: number) => State, capacity?: number);
64
+ get state(): State;
65
+ get pendingInputs(): number;
66
+ predict(input: Input, tick: number): {
67
+ sequence: number;
68
+ state: State;
69
+ };
70
+ reconcile(sequence: number, authoritative: State): State;
71
+ reset(authoritative: State): void;
72
+ }
73
+ /** Bounded authoritative history for application-owned lag-compensated queries. */
74
+ export declare class LagHistory<State> {
75
+ private readonly capacity;
76
+ private readonly entries;
77
+ constructor(capacity?: number);
78
+ record(tick: number, state: State): void;
79
+ sample(tick: number, interpolate: (left: State, right: State, alpha: number) => State): State | undefined;
80
+ }
@@ -0,0 +1,269 @@
1
+ import { cloneJson, isCounter } from './validation.js';
2
+ /** Game-driven fixed simulation time. This owns no network or browser timer. */
3
+ export class SimulationClock {
4
+ constructor(tickRate = 60, maxCatchUpTicks = 8) {
5
+ this.tickRate = tickRate;
6
+ this.maxCatchUpTicks = maxCatchUpTicks;
7
+ this.tick = 0;
8
+ this.droppedTicks = 0;
9
+ this.accumulator = 0;
10
+ if (!Number.isFinite(tickRate) || tickRate < 1 || tickRate > 240 || !Number.isInteger(maxCatchUpTicks) || maxCatchUpTicks < 1) {
11
+ throw new Error('Invalid simulation clock configuration.');
12
+ }
13
+ this.deltaSeconds = 1 / tickRate;
14
+ }
15
+ advance(deltaSeconds, simulate) {
16
+ if (!Number.isFinite(deltaSeconds) || deltaSeconds < 0)
17
+ throw new Error('Invalid simulation delta.');
18
+ // Bound background-tab catch-up and arithmetic even for huge finite deltas.
19
+ deltaSeconds = Math.min(deltaSeconds, 3600);
20
+ this.accumulator += deltaSeconds;
21
+ const due = Math.floor((this.accumulator + 1e-10) / this.deltaSeconds);
22
+ const count = Math.min(due, this.maxCatchUpTicks);
23
+ this.droppedTicks += due - count;
24
+ this.accumulator -= due * this.deltaSeconds;
25
+ if (!isCounter(this.tick + count))
26
+ throw new Error('Simulation tick exhausted.');
27
+ for (let i = 0; i < count; i++)
28
+ simulate(++this.tick, this.deltaSeconds);
29
+ return count;
30
+ }
31
+ reset(tick = 0) {
32
+ if (!isCounter(tick))
33
+ throw new Error('Invalid simulation tick.');
34
+ this.tick = tick;
35
+ this.accumulator = 0;
36
+ this.droppedTicks = 0;
37
+ }
38
+ }
39
+ /** Authoritative-tick interpolation into caller-owned vectors/quaternions. */
40
+ export class SnapshotBuffer {
41
+ constructor(options = {}) {
42
+ this.samples = [];
43
+ this.epoch = null;
44
+ this.retiredEpochs = new Set();
45
+ this.newestRevision = -1;
46
+ this.offsetSeconds = Infinity;
47
+ this.jitterSeconds = 0;
48
+ this.lastArrival = null;
49
+ this.stats = { underruns: 0, duplicateDrops: 0, overflowDrops: 0, extrapolationSeconds: 0 };
50
+ this.options = {
51
+ tickRate: 60, capacity: 32, minDelaySeconds: 0.05, maxDelaySeconds: 0.2,
52
+ maxExtrapolationSeconds: 0.12, hermite: false, ...options,
53
+ };
54
+ const o = this.options;
55
+ if (!Number.isFinite(o.tickRate) || o.tickRate < 1 || o.tickRate > 240
56
+ || !Number.isInteger(o.capacity) || o.capacity < 2 || o.capacity > 256
57
+ || !Number.isFinite(o.minDelaySeconds) || o.minDelaySeconds < 0 || o.maxDelaySeconds < o.minDelaySeconds
58
+ || o.maxExtrapolationSeconds < 0 || !Number.isFinite(o.maxDelaySeconds + o.maxExtrapolationSeconds)) {
59
+ throw new Error('Invalid snapshot buffer configuration.');
60
+ }
61
+ }
62
+ get size() { return this.samples.length; }
63
+ get delaySeconds() {
64
+ return Math.min(this.options.maxDelaySeconds, this.options.minDelaySeconds + this.jitterSeconds * 2);
65
+ }
66
+ reset() {
67
+ this.retiredEpochs.clear();
68
+ this.clearSamples();
69
+ }
70
+ clearSamples() {
71
+ this.newestRevision = -1;
72
+ this.samples.length = 0;
73
+ this.epoch = null;
74
+ this.offsetSeconds = Infinity;
75
+ this.lastArrival = null;
76
+ this.jitterSeconds = 0;
77
+ }
78
+ push(snapshot, receivedSeconds = performance.now() / 1000) {
79
+ if (!isCounter(snapshot.tick) || !isCounter(snapshot.revision) || !snapshot.epoch || !Number.isFinite(receivedSeconds)) {
80
+ throw new Error('Invalid transform snapshot.');
81
+ }
82
+ const value = cloneJson(snapshot);
83
+ const p = value.transform?.position, q = value.transform?.rotation;
84
+ if (!p || ![p.x, p.y, p.z].every(Number.isFinite) || (q && ![q.x, q.y, q.z, q.w].every(Number.isFinite)))
85
+ throw new Error('Invalid snapshot transform.');
86
+ if (this.retiredEpochs.has(value.epoch) || (this.epoch === value.epoch && value.revision <= this.newestRevision)) {
87
+ this.stats.duplicateDrops++;
88
+ return false;
89
+ }
90
+ if (this.epoch !== value.epoch) {
91
+ if (this.epoch) {
92
+ if (this.retiredEpochs.size >= 256)
93
+ throw new Error('Snapshot epoch capacity exhausted; reset required.');
94
+ this.retiredEpochs.add(this.epoch);
95
+ }
96
+ this.clearSamples();
97
+ }
98
+ else if (value.teleport)
99
+ this.clearSamples();
100
+ this.epoch = value.epoch;
101
+ this.newestRevision = value.revision;
102
+ const existing = this.samples.findIndex((sample) => sample.tick === value.tick);
103
+ if (existing >= 0 && this.samples[existing].revision >= value.revision) {
104
+ this.stats.duplicateDrops++;
105
+ return false;
106
+ }
107
+ if (existing >= 0)
108
+ this.samples.splice(existing, 1);
109
+ this.samples.push(value);
110
+ this.samples.sort((a, b) => a.tick - b.tick);
111
+ while (this.samples.length > this.options.capacity) {
112
+ this.samples.shift();
113
+ this.stats.overflowDrops++;
114
+ }
115
+ const previous = this.lastArrival;
116
+ if (!previous || value.tick > previous.tick) {
117
+ if (previous) {
118
+ const deviation = Math.abs(receivedSeconds - previous.time - (value.tick - previous.tick) / this.options.tickRate);
119
+ this.jitterSeconds += (deviation - this.jitterSeconds) / 8;
120
+ }
121
+ this.lastArrival = { tick: value.tick, time: receivedSeconds };
122
+ this.offsetSeconds = Math.min(this.offsetSeconds + 0.001, receivedSeconds - value.tick / this.options.tickRate);
123
+ }
124
+ return true;
125
+ }
126
+ sample(receivedSeconds, output) {
127
+ return this.sampleTick((receivedSeconds - this.offsetSeconds - this.delaySeconds) * this.options.tickRate, output);
128
+ }
129
+ sampleTick(tick, output) {
130
+ if (!this.samples.length || !Number.isFinite(tick))
131
+ return false;
132
+ let left = this.samples[0];
133
+ let right = left;
134
+ for (const sample of this.samples) {
135
+ if (sample.tick <= tick)
136
+ left = sample;
137
+ if (sample.tick >= tick) {
138
+ right = sample;
139
+ break;
140
+ }
141
+ right = sample;
142
+ }
143
+ const span = right.tick - left.tick;
144
+ const alpha = span > 0 ? Math.max(0, Math.min(1, (tick - left.tick) / span)) : 0;
145
+ const a = left.transform.position;
146
+ const b = right.transform.position;
147
+ const dt = span / this.options.tickRate;
148
+ for (const axis of ['x', 'y', 'z']) {
149
+ if (this.options.hermite && left.linearVelocity && right.linearVelocity && span > 0) {
150
+ const t2 = alpha * alpha, t3 = t2 * alpha;
151
+ output.position[axis] = (2 * t3 - 3 * t2 + 1) * a[axis]
152
+ + (t3 - 2 * t2 + alpha) * dt * left.linearVelocity[axis]
153
+ + (-2 * t3 + 3 * t2) * b[axis] + (t3 - t2) * dt * right.linearVelocity[axis];
154
+ }
155
+ else
156
+ output.position[axis] = a[axis] + (b[axis] - a[axis]) * alpha;
157
+ }
158
+ if (output.rotation)
159
+ slerp(left.transform.rotation ?? IDENTITY, right.transform.rotation ?? IDENTITY, alpha, output.rotation);
160
+ if (output.scale) {
161
+ for (const axis of ['x', 'y', 'z']) {
162
+ const start = left.transform.scale?.[axis] ?? 1;
163
+ output.scale[axis] = start + ((right.transform.scale?.[axis] ?? 1) - start) * alpha;
164
+ }
165
+ }
166
+ const newest = this.samples[this.samples.length - 1];
167
+ this.stats.extrapolationSeconds = 0;
168
+ if (tick > newest.tick) {
169
+ this.stats.underruns++;
170
+ const age = newest.teleport ? 0 : Math.min(this.options.maxExtrapolationSeconds, (tick - newest.tick) / this.options.tickRate);
171
+ this.stats.extrapolationSeconds = age;
172
+ if (newest.linearVelocity)
173
+ for (const axis of ['x', 'y', 'z'])
174
+ output.position[axis] += newest.linearVelocity[axis] * age;
175
+ }
176
+ return true;
177
+ }
178
+ }
179
+ const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
180
+ function slerp(a, b, t, out) {
181
+ const an = Math.hypot(a.x, a.y, a.z, a.w) || 1, bn = Math.hypot(b.x, b.y, b.z, b.w) || 1;
182
+ let dot = (a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w) / (an * bn);
183
+ const sign = dot < 0 ? -1 : 1;
184
+ dot = Math.abs(Math.min(1, Math.max(-1, dot)));
185
+ const angle = Math.acos(dot), sin = Math.sin(angle);
186
+ const aw = sin < 1e-6 ? 1 - t : Math.sin((1 - t) * angle) / sin;
187
+ const bw = sin < 1e-6 ? t : Math.sin(t * angle) / sin;
188
+ for (const axis of ['x', 'y', 'z', 'w'])
189
+ out[axis] = aw * a[axis] / an + bw * sign * b[axis] / bn;
190
+ const norm = Math.hypot(out.x, out.y, out.z, out.w);
191
+ if (norm < 1e-8)
192
+ Object.assign(out, IDENTITY);
193
+ else
194
+ for (const axis of ['x', 'y', 'z', 'w'])
195
+ out[axis] /= norm;
196
+ }
197
+ /** Input replay is engine-neutral; the supplied step function owns game rules. */
198
+ export class InputPrediction {
199
+ constructor(initial, step, capacity = 256) {
200
+ this.step = step;
201
+ this.capacity = capacity;
202
+ this.inputs = [];
203
+ this.sequence = 0;
204
+ this.acknowledged = -1;
205
+ this.current = cloneJson(initial);
206
+ if (!Number.isInteger(capacity) || capacity < 1 || capacity > 4096)
207
+ throw new Error('Invalid prediction capacity.');
208
+ }
209
+ get state() { return cloneJson(this.current); }
210
+ get pendingInputs() { return this.inputs.length; }
211
+ predict(input, tick) {
212
+ if (!isCounter(tick) || (this.inputs.length && tick < this.inputs[this.inputs.length - 1].tick))
213
+ throw new Error('Prediction tick moved backwards.');
214
+ if (this.inputs.length >= this.capacity)
215
+ throw new Error('Prediction history exhausted; authoritative resync required.');
216
+ if (!isCounter(this.sequence + 1))
217
+ throw new Error('Input sequence exhausted.');
218
+ const sequence = ++this.sequence;
219
+ const value = cloneJson(input);
220
+ this.inputs.push({ sequence, tick, input: value });
221
+ this.current = this.step(cloneJson(this.current), value, tick);
222
+ return { sequence, state: this.state };
223
+ }
224
+ reconcile(sequence, authoritative) {
225
+ if (!isCounter(sequence) || sequence > this.sequence)
226
+ throw new Error('Invalid acknowledged input.');
227
+ if (sequence <= this.acknowledged)
228
+ return this.state;
229
+ this.acknowledged = sequence;
230
+ this.inputs = this.inputs.filter((entry) => entry.sequence > sequence);
231
+ this.current = cloneJson(authoritative);
232
+ for (const entry of this.inputs)
233
+ this.current = this.step(cloneJson(this.current), entry.input, entry.tick);
234
+ return this.state;
235
+ }
236
+ reset(authoritative) {
237
+ this.current = cloneJson(authoritative);
238
+ this.inputs.length = 0;
239
+ this.sequence = 0;
240
+ this.acknowledged = -1;
241
+ }
242
+ }
243
+ /** Bounded authoritative history for application-owned lag-compensated queries. */
244
+ export class LagHistory {
245
+ constructor(capacity = 64) {
246
+ this.capacity = capacity;
247
+ this.entries = [];
248
+ if (!Number.isInteger(capacity) || capacity < 2 || capacity > 4096)
249
+ throw new Error('Invalid history capacity.');
250
+ }
251
+ record(tick, state) {
252
+ if (!isCounter(tick) || (this.entries.length && tick <= this.entries[this.entries.length - 1].tick))
253
+ throw new Error('History ticks must advance.');
254
+ this.entries.push({ tick, state: cloneJson(state) });
255
+ if (this.entries.length > this.capacity)
256
+ this.entries.shift();
257
+ }
258
+ sample(tick, interpolate) {
259
+ if (!Number.isFinite(tick) || !this.entries.length || tick < this.entries[0].tick || tick > this.entries[this.entries.length - 1].tick)
260
+ return undefined;
261
+ let left = this.entries[0];
262
+ for (const right of this.entries) {
263
+ if (right.tick >= tick)
264
+ return interpolate(cloneJson(left.state), cloneJson(right.state), right.tick === left.tick ? 0 : (tick - left.tick) / (right.tick - left.tick));
265
+ left = right;
266
+ }
267
+ return undefined;
268
+ }
269
+ }
@@ -0,0 +1,12 @@
1
+ import type { ObjectSnapshotBody } from './protocol.js';
2
+ /** Bounded reliable pages. A view becomes visible only after every page arrives. */
3
+ export declare function snapshotPages(snapshot: ObjectSnapshotBody): ObjectSnapshotBody[];
4
+ export declare class SnapshotAssembly {
5
+ private revision;
6
+ private pages;
7
+ private expected;
8
+ private bytes;
9
+ private count;
10
+ private since;
11
+ push(body: ObjectSnapshotBody, now?: number): ObjectSnapshotBody | null;
12
+ }
@@ -0,0 +1,71 @@
1
+ import { boundedJson, isCounter } from './validation.js';
2
+ const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
3
+ const encoder = new TextEncoder();
4
+ /** Bounded reliable pages. A view becomes visible only after every page arrives. */
5
+ export function snapshotPages(snapshot) {
6
+ const pages = [];
7
+ let current = { objects: {}, removed: {}, viewRevision: snapshot.viewRevision };
8
+ let bytes = 0, total = 0, count = 0;
9
+ const flush = () => { pages.push(current); current = { objects: {}, removed: {}, viewRevision: snapshot.viewRevision }; bytes = count = 0; };
10
+ for (const category of ['objects', 'removed']) {
11
+ for (const [id, value] of Object.entries(snapshot[category] ?? {})) {
12
+ const size = encoder.encode(JSON.stringify([id, value])).byteLength;
13
+ total += size;
14
+ if (size > 220 * 1024 || total > MAX_SNAPSHOT_BYTES)
15
+ throw new Error('Object snapshot capacity exceeded.');
16
+ if (count && (bytes + size > 24 * 1024 || count >= 128))
17
+ flush();
18
+ current[category][id] = value;
19
+ bytes += size;
20
+ count++;
21
+ if (!boundedJson(current, 25000, 28))
22
+ throw new Error('Object snapshot complexity exceeded.');
23
+ }
24
+ }
25
+ if (count || !pages.length)
26
+ flush();
27
+ return pages.map((page, index) => ({ ...page, page: index, pages: pages.length }));
28
+ }
29
+ export class SnapshotAssembly {
30
+ constructor() {
31
+ this.revision = 0;
32
+ this.pages = new Map();
33
+ this.expected = 0;
34
+ this.bytes = 0;
35
+ this.count = 0;
36
+ this.since = 0;
37
+ }
38
+ push(body, now = performance.now()) {
39
+ const page = body.page ?? 0, total = body.pages ?? 1, revision = body.viewRevision;
40
+ if (!isCounter(revision) || revision < 1 || !isCounter(page) || !isCounter(total) || total < 1 || total > 2048 || page >= total)
41
+ return null;
42
+ if (revision < this.revision)
43
+ return null;
44
+ if (revision > this.revision || now - this.since > 15000) {
45
+ this.revision = revision;
46
+ this.pages.clear();
47
+ this.bytes = this.count = 0;
48
+ this.expected = total;
49
+ this.since = now;
50
+ }
51
+ if (total !== this.expected || this.pages.has(page))
52
+ return null;
53
+ this.bytes += encoder.encode(JSON.stringify(body)).byteLength;
54
+ this.count += Object.keys(body.objects).length + Object.keys(body.removed ?? {}).length;
55
+ if (this.bytes > MAX_SNAPSHOT_BYTES || this.count > 10000) {
56
+ this.pages.clear();
57
+ return null;
58
+ }
59
+ this.pages.set(page, body);
60
+ if (this.pages.size !== total)
61
+ return null;
62
+ const result = { objects: Object.create(null), removed: Object.create(null), viewRevision: revision };
63
+ for (let i = 0; i < total; i++) {
64
+ const part = this.pages.get(i);
65
+ Object.assign(result.objects, part.objects);
66
+ Object.assign(result.removed, part.removed);
67
+ }
68
+ this.pages.clear();
69
+ return result;
70
+ }
71
+ }