@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.
- 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 +862 -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 +89 -28
- package/src/session/net-session.ts +617 -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,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(
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
120
|
+
detail: { receivedTick: packet.tick, lastTick: this.#lastTick },
|
|
82
121
|
});
|
|
83
122
|
const batchIds = new Set<number>();
|
|
84
|
-
|
|
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
|
|
95
|
-
if (record.kind === 'despawn' && !
|
|
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
|
-
(!
|
|
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(
|
|
143
|
-
const failure = this.validate(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.#
|
|
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
|
|
274
|
+
export function applyReplicationPacket(
|
|
223
275
|
replica: ReplicaCoordinator,
|
|
224
|
-
|
|
276
|
+
packet: ReplicationDataPacket,
|
|
225
277
|
): Result<void, NetError> {
|
|
226
|
-
return replica.apply(
|
|
278
|
+
return replica.apply(packet);
|
|
227
279
|
}
|
|
228
280
|
|
|
229
|
-
export function
|
|
281
|
+
export function decodeAndApplyReplicationPacket(
|
|
230
282
|
replica: ReplicaCoordinator,
|
|
231
283
|
bytes: Uint8Array,
|
|
232
284
|
limits: ReplicationLimits,
|
|
233
285
|
): Result<void, NetError> {
|
|
234
|
-
const decoded =
|
|
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
|
}
|