@forgeax/engine-net 0.1.3 → 0.1.6

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