@forgeax/engine-net 0.1.4 → 0.1.7

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.
Files changed (41) hide show
  1. package/README.md +156 -105
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/endpoint/endpoint.d.ts +7 -0
  4. package/dist/endpoint/endpoint.d.ts.map +1 -1
  5. package/dist/endpoint/memory.d.ts +3 -1
  6. package/dist/endpoint/memory.d.ts.map +1 -1
  7. package/dist/index.d.ts +11 -7
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +862 -117
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/replication/authority.d.ts +8 -6
  12. package/dist/replication/authority.d.ts.map +1 -1
  13. package/dist/replication/codec.d.ts +4 -22
  14. package/dist/replication/codec.d.ts.map +1 -1
  15. package/dist/replication/constants.d.ts +4 -1
  16. package/dist/replication/constants.d.ts.map +1 -1
  17. package/dist/replication/errors.d.ts +24 -4
  18. package/dist/replication/errors.d.ts.map +1 -1
  19. package/dist/replication/protocol.d.ts +63 -0
  20. package/dist/replication/protocol.d.ts.map +1 -0
  21. package/dist/replication/replica.d.ts +9 -6
  22. package/dist/replication/replica.d.ts.map +1 -1
  23. package/dist/session/net-session.d.ts +40 -6
  24. package/dist/session/net-session.d.ts.map +1 -1
  25. package/dist/session/recovery.d.ts +93 -0
  26. package/dist/session/recovery.d.ts.map +1 -0
  27. package/dist/session/session-plugin.d.ts +8 -2
  28. package/dist/session/session-plugin.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/endpoint/endpoint.ts +8 -0
  31. package/src/endpoint/memory.ts +23 -1
  32. package/src/index.ts +56 -12
  33. package/src/replication/authority.ts +55 -23
  34. package/src/replication/codec.ts +167 -101
  35. package/src/replication/constants.ts +5 -1
  36. package/src/replication/errors.ts +22 -4
  37. package/src/replication/protocol.ts +86 -0
  38. package/src/replication/replica.ts +89 -28
  39. package/src/session/net-session.ts +617 -38
  40. package/src/session/recovery.ts +204 -0
  41. package/src/session/session-plugin.ts +21 -5
@@ -1,19 +1,20 @@
1
1
  import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
2
  import { projectComponentData } from '@forgeax/engine-ecs/externalization';
3
3
  import { err, ok, type Result } from '@forgeax/engine-types';
4
- import {
5
- encodeReplicationBatch,
6
- type ReplicationBatch,
7
- type ReplicationComponentRecord,
8
- type ReplicationEntityRecord,
9
- } from './codec';
4
+ import type { SessionId } from '../session/recovery';
5
+ import { encodeReplicationPacket } from './codec';
10
6
  import { REPLICATION_PROTOCOL_VERSION } from './constants';
11
7
  import type { NetError } from './errors';
12
8
  import { DEFAULT_REPLICATION_LIMITS, type ReplicationProfile } from './profile';
9
+ import type {
10
+ ReplicationComponentRecord,
11
+ ReplicationDataPacket,
12
+ ReplicationEntityRecord,
13
+ } from './protocol';
13
14
 
14
- export interface PublishedBatch extends ReplicationBatch {
15
+ export type PublishedPacket = ReplicationDataPacket & {
15
16
  readonly bytes: Uint8Array;
16
- }
17
+ };
17
18
  interface KnownEntity {
18
19
  readonly id: number;
19
20
  readonly components: Map<string, string>;
@@ -29,20 +30,27 @@ export class AuthorityCoordinator {
29
30
  readonly #known = new Map<EntityHandle, KnownEntity>();
30
31
  #nextId = 1;
31
32
  #tick = 0;
32
- constructor(world: World, profile: ReplicationProfile) {
33
+ #epoch = 0;
34
+ #sequence = 0;
35
+ readonly #sessionId: SessionId;
36
+ constructor(world: World, profile: ReplicationProfile, sessionId: SessionId = 1 as SessionId) {
33
37
  this.#world = world;
34
38
  this.#profile = profile;
39
+ this.#sessionId = sessionId;
35
40
  }
36
41
  idFor(entity: EntityHandle): number {
37
42
  return this.#ids.get(entity) ?? 0;
38
43
  }
39
- publish(): Result<PublishedBatch, NetError> {
44
+ publish(): Result<PublishedPacket, NetError> {
40
45
  return this.#publish(false);
41
46
  }
42
- publishFull(): Result<PublishedBatch, NetError> {
47
+ publishFull(): Result<PublishedPacket, NetError> {
43
48
  return this.#publish(true);
44
49
  }
45
- #publish(forceFull: boolean): Result<PublishedBatch, NetError> {
50
+ nextPublicationEpoch(forceFull = false): number {
51
+ return forceFull && this.#tick > 0 ? this.#epoch + 1 : this.#epoch;
52
+ }
53
+ #publish(forceFull: boolean): Result<PublishedPacket, NetError> {
46
54
  const candidateIds = new Map(this.#ids);
47
55
  let candidateNextId = this.#nextId;
48
56
  const current = new Map<
@@ -77,6 +85,14 @@ export class AuthorityCoordinator {
77
85
  }
78
86
 
79
87
  const full = forceFull || this.#tick === 0;
88
+ let nextEpoch = this.#epoch;
89
+ let nextSequence = this.#sequence;
90
+ if (forceFull && this.#tick > 0) {
91
+ nextEpoch += 1;
92
+ nextSequence = 0;
93
+ }
94
+ if (full && nextSequence === 0) nextSequence = 1;
95
+ else nextSequence += 1;
80
96
  const entities: ReplicationEntityRecord[] = [];
81
97
  for (const [entity, entry] of current) {
82
98
  const prior = this.#known.get(entity);
@@ -115,15 +131,29 @@ export class AuthorityCoordinator {
115
131
  if (!current.has(entity)) candidateIds.delete(entity);
116
132
  }
117
133
 
118
- const batch: ReplicationBatch = {
119
- version: REPLICATION_PROTOCOL_VERSION,
120
- fingerprint: this.#profile.fingerprint,
121
- tick: this.#tick + 1,
122
- full,
123
- entities,
124
- };
125
- const encoded = encodeReplicationBatch(
126
- batch,
134
+ const packet: ReplicationDataPacket = full
135
+ ? {
136
+ version: REPLICATION_PROTOCOL_VERSION,
137
+ kind: 'baseline',
138
+ sessionId: this.#sessionId,
139
+ epoch: nextEpoch,
140
+ sequence: nextSequence as 1,
141
+ fingerprint: this.#profile.fingerprint,
142
+ tick: this.#tick + 1,
143
+ entities,
144
+ }
145
+ : {
146
+ version: REPLICATION_PROTOCOL_VERSION,
147
+ kind: 'delta',
148
+ sessionId: this.#sessionId,
149
+ epoch: nextEpoch,
150
+ sequence: nextSequence,
151
+ fingerprint: this.#profile.fingerprint,
152
+ tick: this.#tick + 1,
153
+ entities,
154
+ };
155
+ const encoded = encodeReplicationPacket(
156
+ packet,
127
157
  this.#profile.limits ?? DEFAULT_REPLICATION_LIMITS,
128
158
  );
129
159
  if (!encoded.ok) return err(encoded.error);
@@ -133,8 +163,10 @@ export class AuthorityCoordinator {
133
163
  this.#known.clear();
134
164
  for (const [entity, known] of candidateKnown) this.#known.set(entity, known);
135
165
  this.#nextId = candidateNextId;
136
- this.#tick = batch.tick;
137
- return ok({ ...batch, bytes: encoded.value });
166
+ this.#tick = packet.tick;
167
+ this.#epoch = nextEpoch;
168
+ this.#sequence = nextSequence;
169
+ return ok({ ...packet, bytes: encoded.value });
138
170
  }
139
171
  }
140
172
  export function createAuthorityCoordinator(
@@ -1,35 +1,21 @@
1
1
  import { err, ok, type Result } from '@forgeax/engine-types';
2
- import { REPLICATION_PROTOCOL_VERSION } from './constants';
2
+ import { REPLICATION_PROTOCOL_PREFIX, REPLICATION_PROTOCOL_VERSION } from './constants';
3
3
  import { NetError } from './errors';
4
4
  import type { ReplicationLimits } from './profile';
5
+ import type { ReplicationDataPacket, ReplicationEntityRecord, ReplicationPacket } from './protocol';
5
6
 
6
- export type NetEntityId = number & { readonly __netEntityId: unique symbol };
7
- export interface ReplicationComponentRecord {
8
- readonly name: string;
9
- readonly operation?: 'replace' | 'remove';
10
- readonly data: Record<string, unknown>;
11
- }
12
- export interface ReplicationEntityRecord {
13
- readonly id: number;
14
- readonly kind: 'upsert' | 'despawn';
15
- readonly components: readonly ReplicationComponentRecord[];
16
- }
17
- export interface ReplicationBatch {
18
- readonly version: number;
19
- readonly fingerprint: string;
20
- readonly tick: number;
21
- readonly full: boolean;
22
- readonly entities: readonly ReplicationEntityRecord[];
23
- }
7
+ export type { ReplicationComponentRecord, ReplicationEntityRecord } from './protocol';
24
8
 
25
- const REPLICATION_ENTITY_KINDS = [
26
- 'upsert',
27
- 'despawn',
28
- ] as const satisfies readonly ReplicationEntityRecord['kind'][];
29
-
30
- function isReplicationEntityKind(value: unknown): value is ReplicationEntityRecord['kind'] {
31
- return REPLICATION_ENTITY_KINDS.some((kind) => kind === value);
32
- }
9
+ type PortableTypedArray =
10
+ | Float32Array
11
+ | Float64Array
12
+ | Int8Array
13
+ | Int16Array
14
+ | Int32Array
15
+ | Uint8Array
16
+ | Uint8ClampedArray
17
+ | Uint16Array
18
+ | Uint32Array;
33
19
 
34
20
  const TYPED_ARRAYS = {
35
21
  Float32Array,
@@ -42,8 +28,38 @@ const TYPED_ARRAYS = {
42
28
  Uint16Array,
43
29
  Uint32Array,
44
30
  } as const;
31
+
45
32
  type TypedArrayName = keyof typeof TYPED_ARRAYS;
46
- type PortableTypedArray = InstanceType<(typeof TYPED_ARRAYS)[TypedArrayName]>;
33
+
34
+ const PACKET_KINDS = [
35
+ 'session-open',
36
+ 'session-resume',
37
+ 'baseline',
38
+ 'delta',
39
+ 'ack',
40
+ 'rejection',
41
+ ] as const satisfies readonly ReplicationPacket['kind'][];
42
+
43
+ const REPLICATION_ENTITY_KINDS = [
44
+ 'upsert',
45
+ 'despawn',
46
+ ] as const satisfies readonly ReplicationEntityRecord['kind'][];
47
+
48
+ function isPacketKind(value: unknown): value is ReplicationPacket['kind'] {
49
+ return PACKET_KINDS.some((kind) => kind === value);
50
+ }
51
+
52
+ function isReplicationEntityKind(value: unknown): value is ReplicationEntityRecord['kind'] {
53
+ return REPLICATION_ENTITY_KINDS.some((kind) => kind === value);
54
+ }
55
+
56
+ function isSafeNonNegativeInteger(value: unknown): value is number {
57
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
58
+ }
59
+
60
+ function isSessionId(value: unknown): boolean {
61
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
62
+ }
47
63
 
48
64
  function typedArrayName(value: unknown): TypedArrayName | undefined {
49
65
  for (const [name, typedArrayConstructor] of Object.entries(TYPED_ARRAYS) as [
@@ -60,13 +76,12 @@ function canonicalize(value: unknown): unknown {
60
76
  if (name !== undefined)
61
77
  return { $typedArray: name, values: Array.from(value as PortableTypedArray) };
62
78
  if (Array.isArray(value)) return value.map(canonicalize);
63
- if (value !== null && typeof value === 'object') {
79
+ if (value !== null && typeof value === 'object')
64
80
  return Object.fromEntries(
65
81
  Object.keys(value)
66
82
  .sort()
67
83
  .map((key) => [key, canonicalize((value as Record<string, unknown>)[key])]),
68
84
  );
69
- }
70
85
  return value;
71
86
  }
72
87
 
@@ -84,7 +99,7 @@ function reviveTypedArrays(
84
99
  }
85
100
  if (value === null || typeof value !== 'object') return { value };
86
101
  const record = value as Record<string, unknown>;
87
- if ('$typedArray' in record || 'values' in record) {
102
+ if ('$typedArray' in record) {
88
103
  if (
89
104
  Object.keys(record).length !== 2 ||
90
105
  typeof record.$typedArray !== 'string' ||
@@ -107,6 +122,7 @@ function reviveTypedArrays(
107
122
  }
108
123
  return { value: revived };
109
124
  }
125
+
110
126
  function limitError(limit: string, actual: number, maximum: number): NetError {
111
127
  return new NetError({
112
128
  code: 'decode-limit-exceeded',
@@ -115,15 +131,86 @@ function limitError(limit: string, actual: number, maximum: number): NetError {
115
131
  detail: { limit, actual, maximum },
116
132
  });
117
133
  }
134
+
135
+ function invalid(reason: string): NetError {
136
+ return new NetError({
137
+ code: 'decode-invalid-payload',
138
+ expected: `a version ${REPLICATION_PROTOCOL_VERSION} ${REPLICATION_PROTOCOL_PREFIX} packet`,
139
+ hint: 'send bytes produced by the protocol-v2 replication codec',
140
+ detail: { reason },
141
+ });
142
+ }
143
+
144
+ function validateEntities(entities: readonly ReplicationEntityRecord[]): string | undefined {
145
+ const ids = new Set<number>();
146
+ for (const [entityIndex, entity] of entities.entries()) {
147
+ if (
148
+ entity === null ||
149
+ typeof entity !== 'object' ||
150
+ !isSafeNonNegativeInteger(entity.id) ||
151
+ !isReplicationEntityKind(entity.kind) ||
152
+ !Array.isArray(entity.components) ||
153
+ ids.has(entity.id)
154
+ )
155
+ return `entity record ${entityIndex} has an invalid or duplicate identity`;
156
+ ids.add(entity.id);
157
+ for (const [componentIndex, component] of entity.components.entries()) {
158
+ if (
159
+ component === null ||
160
+ typeof component !== 'object' ||
161
+ typeof component.name !== 'string' ||
162
+ component.name.length === 0 ||
163
+ (component.operation !== undefined &&
164
+ component.operation !== 'replace' &&
165
+ component.operation !== 'remove') ||
166
+ component.data === null ||
167
+ typeof component.data !== 'object' ||
168
+ Array.isArray(component.data) ||
169
+ (component.operation === 'remove' && Object.keys(component.data).length !== 0)
170
+ )
171
+ return `component record ${entityIndex}:${componentIndex} has invalid fields`;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ function validatePacket(packet: ReplicationPacket): string | undefined {
178
+ if (packet.version !== REPLICATION_PROTOCOL_VERSION)
179
+ return 'packet protocol version is unsupported';
180
+ if (!isPacketKind(packet.kind)) return 'packet kind is unsupported';
181
+ if (!isSessionId(packet.sessionId)) return 'sessionId must be a positive safe integer';
182
+ if (!isSafeNonNegativeInteger(packet.epoch)) return 'epoch must be a non-negative safe integer';
183
+ if (packet.kind === 'session-open' || packet.kind === 'session-resume')
184
+ return packet.sequence === 0 ? undefined : 'session control sequence must be zero';
185
+ if (packet.kind === 'ack')
186
+ return isSafeNonNegativeInteger(packet.acknowledgedSequence)
187
+ ? undefined
188
+ : 'acknowledgedSequence must be a non-negative safe integer';
189
+ if (!isSafeNonNegativeInteger(packet.sequence) || packet.sequence === 0)
190
+ return 'sequence must be a positive safe integer';
191
+ if (packet.kind === 'baseline' && packet.sequence !== 1) return 'baseline sequence must be one';
192
+ if (packet.kind === 'rejection') {
193
+ if (!isPacketKind(packet.rejectedKind) || typeof packet.reason !== 'string')
194
+ return 'rejection details are invalid';
195
+ return undefined;
196
+ }
197
+ if (packet.kind !== 'baseline' && packet.kind !== 'delta')
198
+ return 'packet kind does not carry a data payload';
199
+ if (typeof packet.tick !== 'number' || !Number.isSafeInteger(packet.tick))
200
+ return 'tick must be a safe integer';
201
+ if (typeof packet.fingerprint !== 'string') return 'fingerprint must be a string';
202
+ return validateEntities(packet.entities);
203
+ }
204
+
118
205
  function validateLimits(
119
- batch: ReplicationBatch,
206
+ packet: ReplicationDataPacket,
120
207
  bytes: Uint8Array | undefined,
121
208
  limits: ReplicationLimits,
122
209
  ): NetError | null {
123
210
  if (bytes !== undefined && bytes.byteLength > limits.maxMessageBytes)
124
211
  return limitError('maxMessageBytes', bytes.byteLength, limits.maxMessageBytes);
125
- if (batch.entities.length > limits.maxEntities)
126
- return limitError('maxEntities', batch.entities.length, limits.maxEntities);
212
+ if (packet.entities.length > limits.maxEntities)
213
+ return limitError('maxEntities', packet.entities.length, limits.maxEntities);
127
214
  let operations = 0;
128
215
  const visit = (value: unknown): NetError | null => {
129
216
  if (
@@ -159,7 +246,7 @@ function validateLimits(
159
246
  }
160
247
  return null;
161
248
  };
162
- for (const entity of batch.entities) {
249
+ for (const entity of packet.entities) {
163
250
  operations += entity.components.length;
164
251
  for (const component of entity.components) {
165
252
  const problem = visit(component.data);
@@ -170,88 +257,67 @@ function validateLimits(
170
257
  ? limitError('maxComponentOperations', operations, limits.maxComponentOperations)
171
258
  : null;
172
259
  }
260
+
173
261
  function parse(
174
262
  bytes: Uint8Array,
175
- ): { readonly batch: ReplicationBatch } | { readonly reason: string } {
263
+ ): { readonly packet: ReplicationPacket } | { readonly error: NetError } {
264
+ const text = new TextDecoder().decode(bytes);
265
+ const separator = text.indexOf('\n');
266
+ if (separator < 0 || text.slice(0, separator) !== REPLICATION_PROTOCOL_PREFIX)
267
+ return { error: invalid('packet prefix does not match protocol-v2') };
176
268
  try {
177
- const decoded: unknown = JSON.parse(new TextDecoder().decode(bytes));
269
+ const decoded: unknown = JSON.parse(text.slice(separator + 1));
178
270
  const revived = reviveTypedArrays(decoded);
179
- if ('reason' in revived) return revived;
271
+ if ('reason' in revived) return { error: invalid(revived.reason) };
180
272
  if (revived.value === null || typeof revived.value !== 'object')
181
- return { reason: 'batch must be an object' };
182
- const batch = revived.value as Partial<ReplicationBatch>;
183
- if (
184
- !Array.isArray(batch.entities) ||
185
- typeof batch.fingerprint !== 'string' ||
186
- !Number.isSafeInteger(batch.tick) ||
187
- !Number.isSafeInteger(batch.version) ||
188
- typeof batch.full !== 'boolean'
189
- )
190
- return { reason: 'batch envelope has an invalid field type' };
191
- for (const [entityIndex, entity] of batch.entities.entries()) {
192
- if (entity === null || typeof entity !== 'object')
193
- return { reason: `entity record ${entityIndex} must be an object` };
194
- const record = entity as Partial<ReplicationEntityRecord>;
195
- if (
196
- !Number.isSafeInteger(record.id) ||
197
- !isReplicationEntityKind(record.kind) ||
198
- !Array.isArray(record.components)
199
- )
200
- return { reason: `entity record ${entityIndex} has an invalid field type` };
201
- for (const [componentIndex, component] of record.components.entries()) {
202
- if (component === null || typeof component !== 'object')
203
- return { reason: `component record ${entityIndex}:${componentIndex} must be an object` };
204
- const entry = component as Partial<ReplicationComponentRecord>;
205
- if (
206
- typeof entry.name !== 'string' ||
207
- entry.name.length === 0 ||
208
- (entry.operation !== undefined &&
209
- entry.operation !== 'replace' &&
210
- entry.operation !== 'remove') ||
211
- entry.data === null ||
212
- typeof entry.data !== 'object' ||
213
- Array.isArray(entry.data) ||
214
- (entry.operation === 'remove' && Object.keys(entry.data).length !== 0)
215
- )
216
- return {
217
- reason: `component record ${entityIndex}:${componentIndex} has an invalid field type`,
218
- };
219
- }
273
+ return { error: invalid('packet must be an object') };
274
+ const packet = revived.value as ReplicationPacket;
275
+ const reason = validatePacket(packet);
276
+ if (reason !== undefined) {
277
+ if (typeof packet.version === 'number' && packet.version !== REPLICATION_PROTOCOL_VERSION)
278
+ return {
279
+ error: new NetError({
280
+ code: 'protocol-unsupported-version',
281
+ expected: `protocol version ${REPLICATION_PROTOCOL_VERSION}`,
282
+ hint: 'upgrade the peer before sending replicated bytes',
283
+ detail: {
284
+ receivedVersion: packet.version,
285
+ supportedVersion: REPLICATION_PROTOCOL_VERSION,
286
+ },
287
+ }),
288
+ };
289
+ return { error: invalid(reason) };
220
290
  }
221
- return { batch: batch as ReplicationBatch };
291
+ return { packet };
222
292
  } catch {
223
- return { reason: 'payload is not valid JSON' };
293
+ return { error: invalid('payload is not valid JSON') };
224
294
  }
225
295
  }
226
- export function encodeReplicationBatch(
227
- batch: ReplicationBatch,
296
+
297
+ function isDataPacket(packet: ReplicationPacket): packet is ReplicationDataPacket {
298
+ return packet.kind === 'baseline' || packet.kind === 'delta';
299
+ }
300
+
301
+ export function encodeReplicationPacket(
302
+ packet: ReplicationPacket,
228
303
  limits: ReplicationLimits,
229
304
  ): Result<Uint8Array, NetError> {
230
- const bytes = new TextEncoder().encode(JSON.stringify(canonicalize(batch)));
231
- const failure = validateLimits(batch, bytes, limits);
305
+ const reason = validatePacket(packet);
306
+ if (reason !== undefined) return err(invalid(reason));
307
+ const body = JSON.stringify(canonicalize(packet));
308
+ const bytes = new TextEncoder().encode(`${REPLICATION_PROTOCOL_PREFIX}\n${body}`);
309
+ const failure = isDataPacket(packet) ? validateLimits(packet, bytes, limits) : null;
232
310
  return failure ? err(failure) : ok(bytes);
233
311
  }
234
- export function decodeReplicationBatch(
312
+
313
+ export function decodeReplicationPacket(
235
314
  bytes: Uint8Array,
236
315
  limits: ReplicationLimits,
237
- ): Result<ReplicationBatch, NetError> {
316
+ ): Result<ReplicationPacket, NetError> {
238
317
  if (bytes.byteLength > limits.maxMessageBytes)
239
318
  return err(limitError('maxMessageBytes', bytes.byteLength, limits.maxMessageBytes));
240
319
  const parsed = parse(bytes);
241
- if ('reason' in parsed || parsed.batch.version !== REPLICATION_PROTOCOL_VERSION)
242
- return err(
243
- new NetError({
244
- code: 'decode-invalid-payload',
245
- expected: `a version ${REPLICATION_PROTOCOL_VERSION} canonical replication batch`,
246
- hint: 'send bytes produced by the replication codec for the negotiated protocol',
247
- detail: {
248
- reason:
249
- 'reason' in parsed
250
- ? parsed.reason
251
- : 'batch protocol version does not match the decoder',
252
- },
253
- }),
254
- );
255
- const failure = validateLimits(parsed.batch, bytes, limits);
256
- return failure ? err(failure) : ok(parsed.batch);
320
+ if ('error' in parsed) return err(parsed.error);
321
+ const failure = isDataPacket(parsed.packet) ? validateLimits(parsed.packet, bytes, limits) : null;
322
+ return failure ? err(failure) : ok(parsed.packet);
257
323
  }
@@ -1 +1,5 @@
1
- export const REPLICATION_PROTOCOL_VERSION = 1;
1
+ /** The sole application replication protocol version published by net. */
2
+ export const REPLICATION_PROTOCOL_VERSION = 2;
3
+
4
+ /** Fixed wire prefix used to reject non-v2 replication bytes before dispatch. */
5
+ export const REPLICATION_PROTOCOL_PREFIX = 'FXRP2';
@@ -1,4 +1,4 @@
1
- type NetErrorDetailByCode = {
1
+ export type NetErrorDetailByCode = {
2
2
  'handshake-profile-mismatch': {
3
3
  readonly localFingerprint: string;
4
4
  readonly remoteFingerprint: string;
@@ -14,10 +14,28 @@ type NetErrorDetailByCode = {
14
14
  'schema-invalid': { readonly component: string; readonly reason: string };
15
15
  'remap-unresolved-reference': { readonly id: number; readonly referencedId: number };
16
16
  'apply-invariant-failed': { readonly reason: string };
17
+ 'protocol-unsupported-version': {
18
+ readonly receivedVersion: number;
19
+ readonly supportedVersion: number;
20
+ };
21
+ 'session-illegal-transition': {
22
+ readonly from: string;
23
+ readonly to: string;
24
+ };
25
+ 'recovery-policy-invalid': {
26
+ readonly field: string;
27
+ readonly reason: string;
28
+ };
29
+ 'recovery-rejected': { readonly reason: string };
30
+ 'recovery-exhausted': {
31
+ readonly attempts: number;
32
+ readonly maxAttempts: number;
33
+ };
17
34
  };
18
35
 
19
36
  export type NetErrorCode = keyof NetErrorDetailByCode;
20
- export type NetErrorDetail = NetErrorDetailByCode[NetErrorCode];
37
+ export type NetErrorDetailFor<C extends NetErrorCode> = NetErrorDetailByCode[C];
38
+ export type NetErrorDetail = NetErrorDetailFor<NetErrorCode>;
21
39
 
22
40
  class NetErrorClass extends Error {
23
41
  readonly code: NetErrorCode;
@@ -41,7 +59,7 @@ class NetErrorClass extends Error {
41
59
 
42
60
  type Variant<C extends NetErrorCode> = NetErrorClass & {
43
61
  readonly code: C;
44
- readonly detail: NetErrorDetailByCode[C];
62
+ readonly detail: NetErrorDetailFor<C>;
45
63
  };
46
64
 
47
65
  export type NetError = {
@@ -53,7 +71,7 @@ interface NetErrorConstructor {
53
71
  code: C;
54
72
  expected: string;
55
73
  hint: string;
56
- detail: NetErrorDetailByCode[C];
74
+ detail: NetErrorDetailFor<C>;
57
75
  }): Variant<C>;
58
76
  readonly prototype: NetErrorClass;
59
77
  }
@@ -0,0 +1,86 @@
1
+ import type { SessionId } from '../session/recovery';
2
+
3
+ /** Replicated ECS entity operations owned by the protocol manifest. */
4
+ export type ReplicationEntityKind = 'upsert' | 'despawn';
5
+
6
+ export interface ReplicationComponentRecord {
7
+ readonly name: string;
8
+ readonly operation?: 'replace' | 'remove';
9
+ readonly data: Record<string, unknown>;
10
+ }
11
+
12
+ export interface ReplicationEntityRecord {
13
+ readonly id: number;
14
+ readonly kind: ReplicationEntityKind;
15
+ readonly components: readonly ReplicationComponentRecord[];
16
+ }
17
+
18
+ export type ReplicationPacketKind =
19
+ | 'session-open'
20
+ | 'session-resume'
21
+ | 'baseline'
22
+ | 'delta'
23
+ | 'ack'
24
+ | 'rejection';
25
+
26
+ /** Control packet that opens or resumes one application session. */
27
+ export interface ReplicationSessionPacket {
28
+ readonly version: 2;
29
+ readonly kind: 'session-open' | 'session-resume';
30
+ readonly sessionId: SessionId;
31
+ readonly epoch: number;
32
+ readonly sequence: 0;
33
+ }
34
+
35
+ /** Shared fields for baseline and delta data packets. */
36
+ export interface ReplicationDataPacketBase {
37
+ readonly version: 2;
38
+ readonly kind: 'baseline' | 'delta';
39
+ readonly sessionId: SessionId;
40
+ readonly epoch: number;
41
+ readonly sequence: number;
42
+ readonly tick: number;
43
+ readonly fingerprint: string;
44
+ readonly entities: readonly ReplicationEntityRecord[];
45
+ }
46
+
47
+ /** Complete authoritative baseline; sequence one is required for every epoch. */
48
+ export interface ReplicationBaselinePacket extends ReplicationDataPacketBase {
49
+ readonly kind: 'baseline';
50
+ readonly sequence: 1;
51
+ }
52
+
53
+ /** Ordered authoritative delta after the accepted baseline. */
54
+ export interface ReplicationDeltaPacket extends ReplicationDataPacketBase {
55
+ readonly kind: 'delta';
56
+ readonly sequence: number;
57
+ }
58
+
59
+ export type ReplicationDataPacket = ReplicationBaselinePacket | ReplicationDeltaPacket;
60
+
61
+ export interface ReplicationAckPacket {
62
+ readonly version: 2;
63
+ readonly kind: 'ack';
64
+ readonly sessionId: SessionId;
65
+ readonly epoch: number;
66
+ readonly acknowledgedSequence: number;
67
+ }
68
+
69
+ export interface ReplicationRejectionPacket {
70
+ readonly version: 2;
71
+ readonly kind: 'rejection';
72
+ readonly sessionId: SessionId;
73
+ readonly epoch: number;
74
+ readonly sequence: number;
75
+ readonly rejectedKind: ReplicationPacketKind;
76
+ readonly reason: string;
77
+ }
78
+
79
+ export type ReplicationPacket =
80
+ | ReplicationSessionPacket
81
+ | ReplicationBaselinePacket
82
+ | ReplicationDeltaPacket
83
+ | ReplicationAckPacket
84
+ | ReplicationRejectionPacket;
85
+
86
+ export type ReplicationDataPacketKind = ReplicationDataPacket['kind'];