@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.
- package/README.md +156 -105
- package/dist/.tsbuildinfo +1 -1
- package/dist/endpoint/endpoint.d.ts +7 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -1
- package/dist/endpoint/memory.d.ts +3 -1
- package/dist/endpoint/memory.d.ts.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +860 -117
- package/dist/index.mjs.map +1 -1
- package/dist/replication/authority.d.ts +8 -6
- package/dist/replication/authority.d.ts.map +1 -1
- package/dist/replication/codec.d.ts +4 -22
- package/dist/replication/codec.d.ts.map +1 -1
- package/dist/replication/constants.d.ts +4 -1
- package/dist/replication/constants.d.ts.map +1 -1
- package/dist/replication/errors.d.ts +24 -4
- package/dist/replication/errors.d.ts.map +1 -1
- package/dist/replication/protocol.d.ts +63 -0
- package/dist/replication/protocol.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +9 -6
- package/dist/replication/replica.d.ts.map +1 -1
- package/dist/session/net-session.d.ts +40 -6
- package/dist/session/net-session.d.ts.map +1 -1
- package/dist/session/recovery.d.ts +93 -0
- package/dist/session/recovery.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +8 -2
- package/dist/session/session-plugin.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/endpoint/endpoint.ts +8 -0
- package/src/endpoint/memory.ts +23 -1
- package/src/index.ts +56 -12
- package/src/replication/authority.ts +55 -23
- package/src/replication/codec.ts +167 -101
- package/src/replication/constants.ts +5 -1
- package/src/replication/errors.ts +22 -4
- package/src/replication/protocol.ts +86 -0
- package/src/replication/replica.ts +88 -28
- package/src/session/net-session.ts +612 -38
- package/src/session/recovery.ts +204 -0
- 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 {
|
|
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,
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
119
|
+
detail: { receivedTick: packet.tick, lastTick: this.#lastTick },
|
|
82
120
|
});
|
|
83
121
|
const batchIds = new Set<number>();
|
|
84
|
-
|
|
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
|
|
95
|
-
if (record.kind === 'despawn' && !
|
|
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
|
-
(!
|
|
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(
|
|
143
|
-
const failure = this.validate(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.#
|
|
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
|
|
273
|
+
export function applyReplicationPacket(
|
|
223
274
|
replica: ReplicaCoordinator,
|
|
224
|
-
|
|
275
|
+
packet: ReplicationDataPacket,
|
|
225
276
|
): Result<void, NetError> {
|
|
226
|
-
return replica.apply(
|
|
277
|
+
return replica.apply(packet);
|
|
227
278
|
}
|
|
228
279
|
|
|
229
|
-
export function
|
|
280
|
+
export function decodeAndApplyReplicationPacket(
|
|
230
281
|
replica: ReplicaCoordinator,
|
|
231
282
|
bytes: Uint8Array,
|
|
232
283
|
limits: ReplicationLimits,
|
|
233
284
|
): Result<void, NetError> {
|
|
234
|
-
const decoded =
|
|
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
|
}
|