@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
@@ -3,21 +3,23 @@ import { classifyEntityField } from '@forgeax/engine-ecs/externalization';
3
3
  import { componentSchema } from '@forgeax/engine-ecs/internal';
4
4
  import { err, ok, type Result } from '@forgeax/engine-types';
5
5
  import type { NetEndpoint } from '../endpoint/endpoint';
6
- import { decodeReplicationBatch, type ReplicationBatch } from './codec';
6
+ import { decodeReplicationPacket } from './codec';
7
7
  import { NetError } from './errors';
8
8
  import type { ReplicationLimits, ReplicationProfile } from './profile';
9
+ import type { ReplicationDataPacket } from './protocol';
9
10
 
10
11
  export class ReplicaCoordinator {
11
12
  readonly #world: World;
12
13
  readonly #profile: ReplicationProfile;
13
- readonly #endpoint: NetEndpoint | undefined;
14
14
  readonly #entities = new Map<number, EntityHandle>();
15
15
  #lastTick = 0;
16
+ #epoch = -1;
17
+ #lastSequence = 0;
18
+ #lastPacketOutcome: 'accepted' | 'duplicate' | 'ignored-old-epoch' = 'accepted';
16
19
  #stopped = false;
17
- constructor(world: World, profile: ReplicationProfile, endpoint?: NetEndpoint) {
20
+ constructor(world: World, profile: ReplicationProfile, _endpoint?: unknown) {
18
21
  this.#world = world;
19
22
  this.#profile = profile;
20
- this.#endpoint = endpoint;
21
23
  }
22
24
  entityFor(id: number): EntityHandle | undefined {
23
25
  return this.#entities.get(id);
@@ -38,9 +40,7 @@ export class ReplicaCoordinator {
38
40
  }))
39
41
  .sort((a, b) => a.id - b.id);
40
42
  }
41
- disconnect(): void {
42
- this.#endpoint?.close();
43
- }
43
+ disconnect(): void {}
44
44
  /** Remove the last replica baseline when the authority connection closes. */
45
45
  clear(): void {
46
46
  for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
@@ -52,13 +52,21 @@ export class ReplicaCoordinator {
52
52
  get tick(): number {
53
53
  return this.#lastTick;
54
54
  }
55
+ /** Report the last accepted, duplicate, or stale-epoch packet decision. */
56
+ get lastPacketOutcome(): 'accepted' | 'duplicate' | 'ignored-old-epoch' {
57
+ return this.#lastPacketOutcome;
58
+ }
59
+ getPendingUnresolvedReferences(): number {
60
+ return 0;
61
+ }
55
62
  #entityReferences(value: unknown): readonly unknown[] {
56
63
  if (Array.isArray(value) || ArrayBuffer.isView(value)) {
57
64
  return Array.from(value as ArrayLike<unknown>);
58
65
  }
59
66
  return [];
60
67
  }
61
- validate(batch: ReplicationBatch): NetError | null {
68
+ validate(packet: ReplicationDataPacket): NetError | null {
69
+ this.#lastPacketOutcome = 'accepted';
62
70
  if (this.#stopped)
63
71
  return new NetError({
64
72
  code: 'apply-invariant-failed',
@@ -66,22 +74,54 @@ export class ReplicaCoordinator {
66
74
  hint: 'create a new session after a fatal apply failure',
67
75
  detail: { reason: 'replication stopped' },
68
76
  });
69
- if (batch.fingerprint !== this.#profile.fingerprint)
77
+ if (packet.fingerprint !== this.#profile.fingerprint)
70
78
  return new NetError({
71
79
  code: 'schema-invalid',
72
80
  expected: 'a batch for the negotiated replication profile',
73
81
  hint: 'complete handshake before applying replication bytes',
74
82
  detail: { component: '', reason: 'fingerprint mismatch' },
75
83
  });
76
- if (batch.tick <= this.#lastTick)
84
+ const newEpoch = packet.epoch > this.#epoch;
85
+ if (this.#epoch < 0 && packet.kind !== 'baseline')
86
+ return new NetError({
87
+ code: 'session-illegal-transition',
88
+ expected: 'a baseline before any delta in a session epoch',
89
+ hint: 'accept a complete authoritative baseline before applying deltas',
90
+ detail: { from: 'connecting', to: packet.kind },
91
+ });
92
+ if (packet.epoch > this.#epoch && (packet.kind !== 'baseline' || packet.sequence !== 1))
93
+ return new NetError({
94
+ code: 'session-illegal-transition',
95
+ expected: 'a sequence-one baseline at the start of a new epoch',
96
+ hint: 'request a fresh baseline before applying the next delta',
97
+ detail: { from: 'resyncing', to: packet.kind },
98
+ });
99
+ if (packet.epoch < this.#epoch) return null;
100
+ if (packet.kind === 'baseline' && !newEpoch && this.#lastSequence >= 1) {
101
+ this.#lastPacketOutcome = 'duplicate';
102
+ return null;
103
+ }
104
+ if (packet.kind === 'delta' && packet.sequence <= this.#lastSequence) {
105
+ this.#lastPacketOutcome = 'duplicate';
106
+ return null;
107
+ }
108
+ if (packet.kind === 'delta' && packet.sequence !== this.#lastSequence + 1)
109
+ return new NetError({
110
+ code: 'ordering-invalid-tick',
111
+ expected: 'the next contiguous replication sequence',
112
+ hint: 'request a fresh baseline when a sequence gap is detected',
113
+ detail: { receivedTick: packet.sequence, lastTick: this.#lastSequence },
114
+ });
115
+ if (!newEpoch && packet.tick <= this.#lastTick)
77
116
  return new NetError({
78
117
  code: 'ordering-invalid-tick',
79
118
  expected: 'a strictly monotonic authority tick',
80
119
  hint: 'discard duplicate, stale, and out-of-order batches',
81
- detail: { receivedTick: batch.tick, lastTick: this.#lastTick },
120
+ detail: { receivedTick: packet.tick, lastTick: this.#lastTick },
82
121
  });
83
122
  const batchIds = new Set<number>();
84
- for (const record of batch.entities) {
123
+ const knownIds = newEpoch ? new Set<number>() : new Set(this.#entities.keys());
124
+ for (const record of packet.entities) {
85
125
  if (!Number.isSafeInteger(record.id) || record.id <= 0 || batchIds.has(record.id))
86
126
  return new NetError({
87
127
  code: 'identity-invalid',
@@ -91,8 +131,8 @@ export class ReplicaCoordinator {
91
131
  });
92
132
  batchIds.add(record.id);
93
133
  }
94
- for (const record of batch.entities) {
95
- if (record.kind === 'despawn' && !this.#entities.has(record.id))
134
+ for (const record of packet.entities) {
135
+ if (record.kind === 'despawn' && !knownIds.has(record.id))
96
136
  return new NetError({
97
137
  code: 'identity-invalid',
98
138
  expected: 'a known identity for despawn',
@@ -126,7 +166,7 @@ export class ReplicaCoordinator {
126
166
  reference !== null &&
127
167
  (typeof reference !== 'number' ||
128
168
  reference === 0 ||
129
- (!this.#entities.has(reference) && !batchIds.has(reference)))
169
+ (!knownIds.has(reference) && !batchIds.has(reference)))
130
170
  )
131
171
  return new NetError({
132
172
  code: 'remap-unresolved-reference',
@@ -139,17 +179,26 @@ export class ReplicaCoordinator {
139
179
  }
140
180
  return null;
141
181
  }
142
- apply(batch: ReplicationBatch): Result<void, NetError> {
143
- const failure = this.validate(batch);
182
+ apply(packet: ReplicationDataPacket): Result<void, NetError> {
183
+ const failure = this.validate(packet);
144
184
  if (failure) {
145
- this.disconnect();
146
185
  return err(failure);
147
186
  }
187
+ if (packet.epoch < this.#epoch) {
188
+ this.#lastPacketOutcome = 'ignored-old-epoch';
189
+ return ok(undefined);
190
+ }
191
+ if (this.#lastPacketOutcome === 'duplicate') return ok(undefined);
192
+ const replacingEpoch = packet.epoch > this.#epoch;
148
193
  try {
149
- for (const record of batch.entities)
194
+ if (replacingEpoch) {
195
+ for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
196
+ this.#entities.clear();
197
+ }
198
+ for (const record of packet.entities)
150
199
  if (record.kind === 'upsert' && !this.#entities.has(record.id))
151
200
  this.#entities.set(record.id, this.#world.spawn().unwrap());
152
- for (const record of batch.entities)
201
+ for (const record of packet.entities)
153
202
  if (record.kind === 'upsert') {
154
203
  const entity = this.#entities.get(record.id);
155
204
  if (entity === undefined) throw new Error(`missing allocated entity ${record.id}`);
@@ -190,14 +239,17 @@ export class ReplicaCoordinator {
190
239
  if (!write.ok) throw write.error;
191
240
  }
192
241
  }
193
- for (const record of batch.entities)
242
+ for (const record of packet.entities)
194
243
  if (record.kind === 'despawn') {
195
244
  const entity = this.#entities.get(record.id);
196
245
  if (entity === undefined) throw new Error(`missing despawn entity ${record.id}`);
197
246
  this.#world.despawn(entity).unwrap();
198
247
  this.#entities.delete(record.id);
199
248
  }
200
- this.#lastTick = batch.tick;
249
+ this.#epoch = packet.epoch;
250
+ this.#lastSequence = packet.sequence;
251
+ this.#lastTick = packet.tick;
252
+ this.#lastPacketOutcome = 'accepted';
201
253
  return ok(undefined);
202
254
  } catch (cause) {
203
255
  this.#stopped = true;
@@ -219,22 +271,31 @@ export function createReplicaCoordinator(
219
271
  ): ReplicaCoordinator {
220
272
  return new ReplicaCoordinator(world, profile, endpoint);
221
273
  }
222
- export function applyReplicaBatch(
274
+ export function applyReplicationPacket(
223
275
  replica: ReplicaCoordinator,
224
- batch: ReplicationBatch,
276
+ packet: ReplicationDataPacket,
225
277
  ): Result<void, NetError> {
226
- return replica.apply(batch);
278
+ return replica.apply(packet);
227
279
  }
228
280
 
229
- export function decodeAndApplyReplicaBatch(
281
+ export function decodeAndApplyReplicationPacket(
230
282
  replica: ReplicaCoordinator,
231
283
  bytes: Uint8Array,
232
284
  limits: ReplicationLimits,
233
285
  ): Result<void, NetError> {
234
- const decoded = decodeReplicationBatch(bytes, limits);
286
+ const decoded = decodeReplicationPacket(bytes, limits);
235
287
  if (!decoded.ok) {
236
- replica.disconnect();
237
288
  return err(decoded.error);
238
289
  }
290
+ if (decoded.value.kind !== 'baseline' && decoded.value.kind !== 'delta') {
291
+ return err(
292
+ new NetError({
293
+ code: 'decode-invalid-payload',
294
+ expected: 'a baseline or delta replication packet',
295
+ hint: 'apply only data packets through the replica coordinator',
296
+ detail: { reason: 'control packet cannot be applied as ECS data' },
297
+ }),
298
+ );
299
+ }
239
300
  return replica.apply(decoded.value);
240
301
  }