@forgeax/engine-net 0.0.0-dev.8d955ade1c79

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 (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +196 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/endpoint/endpoint.d.ts +43 -0
  5. package/dist/endpoint/endpoint.d.ts.map +1 -0
  6. package/dist/endpoint/errors.d.ts +82 -0
  7. package/dist/endpoint/errors.d.ts.map +1 -0
  8. package/dist/endpoint/memory.d.ts +15 -0
  9. package/dist/endpoint/memory.d.ts.map +1 -0
  10. package/dist/index.d.ts +21 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.mjs +1648 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/replication/authority.d.ts +19 -0
  15. package/dist/replication/authority.d.ts.map +1 -0
  16. package/dist/replication/codec.d.ts +8 -0
  17. package/dist/replication/codec.d.ts.map +1 -0
  18. package/dist/replication/constants.d.ts +5 -0
  19. package/dist/replication/constants.d.ts.map +1 -0
  20. package/dist/replication/errors.d.ts +86 -0
  21. package/dist/replication/errors.d.ts.map +1 -0
  22. package/dist/replication/handshake.d.ts +5 -0
  23. package/dist/replication/handshake.d.ts.map +1 -0
  24. package/dist/replication/profile.d.ts +31 -0
  25. package/dist/replication/profile.d.ts.map +1 -0
  26. package/dist/replication/protocol.d.ts +63 -0
  27. package/dist/replication/protocol.d.ts.map +1 -0
  28. package/dist/replication/replica.d.ts +30 -0
  29. package/dist/replication/replica.d.ts.map +1 -0
  30. package/dist/session/net-session.d.ts +66 -0
  31. package/dist/session/net-session.d.ts.map +1 -0
  32. package/dist/session/recovery.d.ts +93 -0
  33. package/dist/session/recovery.d.ts.map +1 -0
  34. package/dist/session/session-plugin.d.ts +14 -0
  35. package/dist/session/session-plugin.d.ts.map +1 -0
  36. package/package.json +58 -0
  37. package/src/endpoint/endpoint.ts +58 -0
  38. package/src/endpoint/errors.ts +164 -0
  39. package/src/endpoint/memory.ts +194 -0
  40. package/src/index.ts +90 -0
  41. package/src/replication/authority.ts +177 -0
  42. package/src/replication/codec.ts +323 -0
  43. package/src/replication/constants.ts +5 -0
  44. package/src/replication/errors.ts +78 -0
  45. package/src/replication/handshake.ts +18 -0
  46. package/src/replication/profile.ts +111 -0
  47. package/src/replication/protocol.ts +86 -0
  48. package/src/replication/replica.ts +301 -0
  49. package/src/session/net-session.ts +697 -0
  50. package/src/session/recovery.ts +204 -0
  51. package/src/session/session-plugin.ts +68 -0
@@ -0,0 +1,301 @@
1
+ import type { Component, EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { classifyEntityField } from '@forgeax/engine-ecs/externalization';
3
+ import { componentSchema } from '@forgeax/engine-ecs/internal';
4
+ import { err, ok, type Result } from '@forgeax/engine-types';
5
+ import type { NetEndpoint } from '../endpoint/endpoint';
6
+ import { decodeReplicationPacket } from './codec';
7
+ import { NetError } from './errors';
8
+ import type { ReplicationLimits, ReplicationProfile } from './profile';
9
+ import type { ReplicationDataPacket } from './protocol';
10
+
11
+ export class ReplicaCoordinator {
12
+ readonly #world: World;
13
+ readonly #profile: ReplicationProfile;
14
+ readonly #entities = new Map<number, EntityHandle>();
15
+ #lastTick = 0;
16
+ #epoch = -1;
17
+ #lastSequence = 0;
18
+ #lastPacketOutcome: 'accepted' | 'duplicate' | 'ignored-old-epoch' = 'accepted';
19
+ #stopped = false;
20
+ constructor(world: World, profile: ReplicationProfile, _endpoint?: unknown) {
21
+ this.#world = world;
22
+ this.#profile = profile;
23
+ }
24
+ entityFor(id: number): EntityHandle | undefined {
25
+ return this.#entities.get(id);
26
+ }
27
+ readComponent(id: number, component: Component): Record<string, unknown> | undefined {
28
+ const entity = this.#entities.get(id);
29
+ if (entity === undefined) return undefined;
30
+ const read = this.#world.get(entity, component);
31
+ return read.ok ? (read.value as Record<string, unknown>) : undefined;
32
+ }
33
+ snapshot(): readonly { id: number; components: readonly string[] }[] {
34
+ return [...this.#entities]
35
+ .map(([id, entity]) => ({
36
+ id,
37
+ components: this.#profile.components
38
+ .filter((component) => this.#world.get(entity, component).ok)
39
+ .map((component) => component.name),
40
+ }))
41
+ .sort((a, b) => a.id - b.id);
42
+ }
43
+ disconnect(): void {}
44
+ /** Remove the last replica baseline when the authority connection closes. */
45
+ clear(): void {
46
+ for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
47
+ this.#entities.clear();
48
+ }
49
+ get stopped(): boolean {
50
+ return this.#stopped;
51
+ }
52
+ get tick(): number {
53
+ return this.#lastTick;
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
+ }
62
+ #entityReferences(value: unknown): readonly unknown[] {
63
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
64
+ return Array.from(value as ArrayLike<unknown>);
65
+ }
66
+ return [];
67
+ }
68
+ validate(packet: ReplicationDataPacket): NetError | null {
69
+ this.#lastPacketOutcome = 'accepted';
70
+ if (this.#stopped)
71
+ return new NetError({
72
+ code: 'apply-invariant-failed',
73
+ expected: 'an active replica coordinator',
74
+ hint: 'create a new session after a fatal apply failure',
75
+ detail: { reason: 'replication stopped' },
76
+ });
77
+ if (packet.fingerprint !== this.#profile.fingerprint)
78
+ return new NetError({
79
+ code: 'schema-invalid',
80
+ expected: 'a batch for the negotiated replication profile',
81
+ hint: 'complete handshake before applying replication bytes',
82
+ detail: { component: '', reason: 'fingerprint mismatch' },
83
+ });
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)
116
+ return new NetError({
117
+ code: 'ordering-invalid-tick',
118
+ expected: 'a strictly monotonic authority tick',
119
+ hint: 'discard duplicate, stale, and out-of-order batches',
120
+ detail: { receivedTick: packet.tick, lastTick: this.#lastTick },
121
+ });
122
+ const batchIds = new Set<number>();
123
+ const knownIds = newEpoch ? new Set<number>() : new Set(this.#entities.keys());
124
+ for (const record of packet.entities) {
125
+ if (!Number.isSafeInteger(record.id) || record.id <= 0 || batchIds.has(record.id))
126
+ return new NetError({
127
+ code: 'identity-invalid',
128
+ expected: 'unique non-zero NetEntityId values',
129
+ hint: 'use session-issued identity values exactly once per batch',
130
+ detail: { id: record.id, reason: 'zero, invalid, or duplicate identity' },
131
+ });
132
+ batchIds.add(record.id);
133
+ }
134
+ for (const record of packet.entities) {
135
+ if (record.kind === 'despawn' && !knownIds.has(record.id))
136
+ return new NetError({
137
+ code: 'identity-invalid',
138
+ expected: 'a known identity for despawn',
139
+ hint: 'do not reuse or despawn unknown network identities',
140
+ detail: { id: record.id, reason: 'unknown identity' },
141
+ });
142
+ for (const entry of record.components) {
143
+ const component = this.#profile.components.find(
144
+ (candidate) => candidate.name === entry.name,
145
+ );
146
+ if (component === undefined)
147
+ return new NetError({
148
+ code: 'schema-invalid',
149
+ expected: 'a component selected by the negotiated profile',
150
+ hint: 'send only components from the ordered replication profile',
151
+ detail: { component: entry.name, reason: 'unselected component' },
152
+ });
153
+ if (entry.operation === 'remove') continue;
154
+ for (const [field, value] of Object.entries(entry.data)) {
155
+ if (!(field in componentSchema(component)))
156
+ return new NetError({
157
+ code: 'schema-invalid',
158
+ expected: 'component fields declared by the negotiated ECS schema',
159
+ hint: 'send only fields declared by the replicated component token',
160
+ detail: { component: entry.name, reason: `unknown field ${field}` },
161
+ });
162
+ const kind = classifyEntityField(component, field);
163
+ const refs = kind?.isArray ? this.#entityReferences(value) : kind ? [value] : [];
164
+ for (const reference of refs)
165
+ if (
166
+ reference !== null &&
167
+ (typeof reference !== 'number' ||
168
+ reference === 0 ||
169
+ (!knownIds.has(reference) && !batchIds.has(reference)))
170
+ )
171
+ return new NetError({
172
+ code: 'remap-unresolved-reference',
173
+ expected: 'every entity reference to resolve in the current or same batch',
174
+ hint: 'include the referenced spawn in this batch; cross-batch pending references are unsupported',
175
+ detail: { id: record.id, referencedId: Number(reference) },
176
+ });
177
+ }
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+ apply(packet: ReplicationDataPacket): Result<void, NetError> {
183
+ const failure = this.validate(packet);
184
+ if (failure) {
185
+ return err(failure);
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;
193
+ try {
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)
199
+ if (record.kind === 'upsert' && !this.#entities.has(record.id))
200
+ this.#entities.set(record.id, this.#world.spawn().unwrap());
201
+ for (const record of packet.entities)
202
+ if (record.kind === 'upsert') {
203
+ const entity = this.#entities.get(record.id);
204
+ if (entity === undefined) throw new Error(`missing allocated entity ${record.id}`);
205
+ for (const entry of record.components) {
206
+ const component = this.#profile.components.find(
207
+ (candidate) => candidate.name === entry.name,
208
+ );
209
+ if (component === undefined) throw new Error(`missing profile component ${entry.name}`);
210
+ if (entry.operation === 'remove') {
211
+ const removal = this.#world.removeComponent(entity, component);
212
+ if (!removal.ok) throw removal.error;
213
+ continue;
214
+ }
215
+ const data = Object.fromEntries(
216
+ Object.entries(entry.data).map(([field, value]) => {
217
+ const kind = classifyEntityField(component, field);
218
+ if (kind === null) return [field, value];
219
+ const mapped = kind.isArray
220
+ ? this.#entityReferences(value).map((id) => {
221
+ if (id === null) return null;
222
+ const reference = this.#entities.get(id as number);
223
+ if (reference === undefined)
224
+ throw new Error(`missing entity reference ${id}`);
225
+ return reference;
226
+ })
227
+ : value === null
228
+ ? null
229
+ : this.#entities.get(value as number);
230
+ if (mapped === undefined) throw new Error(`missing entity reference ${value}`);
231
+ return [field, mapped];
232
+ }),
233
+ );
234
+ const typedData = data as never;
235
+ const exists = this.#world.get(entity, component);
236
+ const write = exists.ok
237
+ ? this.#world.set(entity, component, typedData)
238
+ : this.#world.addComponent(entity, { component, data: typedData });
239
+ if (!write.ok) throw write.error;
240
+ }
241
+ }
242
+ for (const record of packet.entities)
243
+ if (record.kind === 'despawn') {
244
+ const entity = this.#entities.get(record.id);
245
+ if (entity === undefined) throw new Error(`missing despawn entity ${record.id}`);
246
+ this.#world.despawn(entity).unwrap();
247
+ this.#entities.delete(record.id);
248
+ }
249
+ this.#epoch = packet.epoch;
250
+ this.#lastSequence = packet.sequence;
251
+ this.#lastTick = packet.tick;
252
+ this.#lastPacketOutcome = 'accepted';
253
+ return ok(undefined);
254
+ } catch (cause) {
255
+ this.#stopped = true;
256
+ return err(
257
+ new NetError({
258
+ code: 'apply-invariant-failed',
259
+ expected: 'ECS apply invariants to accept a validated batch',
260
+ hint: 'stop this replication session and inspect the ECS error',
261
+ detail: { reason: cause instanceof Error ? cause.message : String(cause) },
262
+ }),
263
+ );
264
+ }
265
+ }
266
+ }
267
+ export function createReplicaCoordinator(
268
+ world: World,
269
+ profile: ReplicationProfile,
270
+ endpoint?: NetEndpoint,
271
+ ): ReplicaCoordinator {
272
+ return new ReplicaCoordinator(world, profile, endpoint);
273
+ }
274
+ export function applyReplicationPacket(
275
+ replica: ReplicaCoordinator,
276
+ packet: ReplicationDataPacket,
277
+ ): Result<void, NetError> {
278
+ return replica.apply(packet);
279
+ }
280
+
281
+ export function decodeAndApplyReplicationPacket(
282
+ replica: ReplicaCoordinator,
283
+ bytes: Uint8Array,
284
+ limits: ReplicationLimits,
285
+ ): Result<void, NetError> {
286
+ const decoded = decodeReplicationPacket(bytes, limits);
287
+ if (!decoded.ok) {
288
+ return err(decoded.error);
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
+ }
300
+ return replica.apply(decoded.value);
301
+ }