@forgeax/engine-net 0.1.2
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/LICENSE +202 -0
- package/README.md +145 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/endpoint/endpoint.d.ts +36 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -0
- package/dist/endpoint/errors.d.ts +82 -0
- package/dist/endpoint/errors.d.ts.map +1 -0
- package/dist/endpoint/memory.d.ts +13 -0
- package/dist/endpoint/memory.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +903 -0
- package/dist/index.mjs.map +1 -0
- package/dist/replication/authority.d.ts +17 -0
- package/dist/replication/authority.d.ts.map +1 -0
- package/dist/replication/codec.d.ts +26 -0
- package/dist/replication/codec.d.ts.map +1 -0
- package/dist/replication/constants.d.ts +2 -0
- package/dist/replication/constants.d.ts.map +1 -0
- package/dist/replication/errors.d.ts +66 -0
- package/dist/replication/errors.d.ts.map +1 -0
- package/dist/replication/handshake.d.ts +5 -0
- package/dist/replication/handshake.d.ts.map +1 -0
- package/dist/replication/profile.d.ts +31 -0
- package/dist/replication/profile.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +27 -0
- package/dist/replication/replica.d.ts.map +1 -0
- package/dist/session/net-session.d.ts +32 -0
- package/dist/session/net-session.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +8 -0
- package/dist/session/session-plugin.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/endpoint/endpoint.ts +50 -0
- package/src/endpoint/errors.ts +164 -0
- package/src/endpoint/memory.ts +172 -0
- package/src/index.ts +46 -0
- package/src/replication/authority.ts +145 -0
- package/src/replication/codec.ts +257 -0
- package/src/replication/constants.ts +1 -0
- package/src/replication/errors.ts +60 -0
- package/src/replication/handshake.ts +18 -0
- package/src/replication/profile.ts +111 -0
- package/src/replication/replica.ts +240 -0
- package/src/session/net-session.ts +118 -0
- package/src/session/session-plugin.ts +52 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @forgeax/engine-net -- endpoint structured errors.
|
|
2
|
+
//
|
|
3
|
+
// Closed union of transport-level failures. Each variant carries .code, .expected,
|
|
4
|
+
// .hint, and per-code .detail. AI users exhaustively switch on .code without a
|
|
5
|
+
// default branch (requirements AC-13).
|
|
6
|
+
|
|
7
|
+
import type { PeerId } from './endpoint';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// EndpointErrorCode -- closed 5-member union derived from endpointErrorPolicy
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Transport-level endpoint error codes (requirements AC-02, AC-13). */
|
|
14
|
+
export type EndpointErrorCode = keyof typeof endpointErrorPolicy;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Per-code detail payloads
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface EndpointDetailPeerNotFound {
|
|
21
|
+
readonly peerId: PeerId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EndpointDetailConnectionClosed {
|
|
25
|
+
readonly peerId: PeerId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EndpointDetailSendFailed {
|
|
29
|
+
readonly peerId: PeerId;
|
|
30
|
+
readonly cause: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EndpointDetailAlreadyClosed {
|
|
34
|
+
readonly cause: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EndpointDetailConnectionFailed {
|
|
38
|
+
readonly address: string;
|
|
39
|
+
readonly cause: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Conditional resolver
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export type EndpointErrorDetailFor<C extends EndpointErrorCode> = C extends 'peer-not-found'
|
|
47
|
+
? EndpointDetailPeerNotFound
|
|
48
|
+
: C extends 'connection-closed'
|
|
49
|
+
? EndpointDetailConnectionClosed
|
|
50
|
+
: C extends 'send-failed'
|
|
51
|
+
? EndpointDetailSendFailed
|
|
52
|
+
: C extends 'already-closed'
|
|
53
|
+
? EndpointDetailAlreadyClosed
|
|
54
|
+
: C extends 'connection-failed'
|
|
55
|
+
? EndpointDetailConnectionFailed
|
|
56
|
+
: never;
|
|
57
|
+
|
|
58
|
+
/** Tagged union of all endpoint error detail variants. */
|
|
59
|
+
export type EndpointErrorDetail = EndpointErrorDetailFor<EndpointErrorCode>;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Error class
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class EndpointErrorClass extends Error {
|
|
66
|
+
readonly code: EndpointErrorCode;
|
|
67
|
+
readonly expected: string;
|
|
68
|
+
readonly hint: string;
|
|
69
|
+
readonly detail: EndpointErrorDetail;
|
|
70
|
+
|
|
71
|
+
constructor(args: {
|
|
72
|
+
code: EndpointErrorCode;
|
|
73
|
+
expected: string;
|
|
74
|
+
hint: string;
|
|
75
|
+
detail: EndpointErrorDetail;
|
|
76
|
+
}) {
|
|
77
|
+
let suffix = '';
|
|
78
|
+
if (args.code === 'peer-not-found') {
|
|
79
|
+
const d = args.detail as EndpointDetailPeerNotFound;
|
|
80
|
+
suffix = ` (peerId=${d.peerId})`;
|
|
81
|
+
} else if (args.code === 'connection-closed') {
|
|
82
|
+
const d = args.detail as EndpointDetailConnectionClosed;
|
|
83
|
+
suffix = ` (peerId=${d.peerId})`;
|
|
84
|
+
} else if (args.code === 'send-failed') {
|
|
85
|
+
const d = args.detail as EndpointDetailSendFailed;
|
|
86
|
+
suffix = ` (peerId=${d.peerId}, cause=${d.cause})`;
|
|
87
|
+
} else if (args.code === 'already-closed') {
|
|
88
|
+
const d = args.detail as EndpointDetailAlreadyClosed;
|
|
89
|
+
suffix = ` (cause=${d.cause})`;
|
|
90
|
+
} else if (args.code === 'connection-failed') {
|
|
91
|
+
const d = args.detail as EndpointDetailConnectionFailed;
|
|
92
|
+
suffix = ` (address=${d.address}, cause=${d.cause})`;
|
|
93
|
+
}
|
|
94
|
+
super(`[EndpointError ${args.code}] expected: ${args.expected}; hint: ${args.hint}${suffix}`);
|
|
95
|
+
this.name = 'EndpointError';
|
|
96
|
+
this.code = args.code;
|
|
97
|
+
this.expected = args.expected;
|
|
98
|
+
this.hint = args.hint;
|
|
99
|
+
this.detail = args.detail;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type EndpointErrorVariant<C extends EndpointErrorCode> = EndpointErrorClass & {
|
|
104
|
+
readonly code: C;
|
|
105
|
+
readonly detail: EndpointErrorDetailFor<C>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type EndpointError = {
|
|
109
|
+
[C in EndpointErrorCode]: EndpointErrorVariant<C>;
|
|
110
|
+
}[EndpointErrorCode];
|
|
111
|
+
|
|
112
|
+
interface EndpointErrorConstructor {
|
|
113
|
+
new <C extends EndpointErrorCode>(args: {
|
|
114
|
+
code: C;
|
|
115
|
+
expected: string;
|
|
116
|
+
hint: string;
|
|
117
|
+
detail: EndpointErrorDetailFor<C>;
|
|
118
|
+
}): EndpointErrorVariant<C>;
|
|
119
|
+
readonly prototype: EndpointErrorClass;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const EndpointError: EndpointErrorConstructor =
|
|
123
|
+
EndpointErrorClass as unknown as EndpointErrorConstructor;
|
|
124
|
+
|
|
125
|
+
type EndpointErrorPolicy = { readonly expected: string; readonly hint: string };
|
|
126
|
+
|
|
127
|
+
const endpointErrorPolicy = {
|
|
128
|
+
'peer-not-found': {
|
|
129
|
+
expected: 'the target peer must exist in the current connection set',
|
|
130
|
+
hint: 'verify the PeerId is from a connect event; check that the peer has not disconnected',
|
|
131
|
+
},
|
|
132
|
+
'connection-closed': {
|
|
133
|
+
expected: 'the peer connection must be alive for the operation',
|
|
134
|
+
hint: 'the peer disconnected; poll for a disconnect event and handle the lifecycle',
|
|
135
|
+
},
|
|
136
|
+
'send-failed': {
|
|
137
|
+
expected: 'message bytes must be delivered to the target peer or the connection must fail',
|
|
138
|
+
hint: 'the memory connection is broken; the peer may have disconnected or the buffer is full',
|
|
139
|
+
},
|
|
140
|
+
'already-closed': {
|
|
141
|
+
expected: 'the endpoint must be open for any operation',
|
|
142
|
+
hint: 'the endpoint is closed; create a new endpoint pair for further communication',
|
|
143
|
+
},
|
|
144
|
+
'connection-failed': {
|
|
145
|
+
expected:
|
|
146
|
+
'the endpoint factory must successfully establish a connection or bind to the listen address',
|
|
147
|
+
hint: 'the initial connection or bind failed; verify the address is reachable and the port is not in use, then retry',
|
|
148
|
+
},
|
|
149
|
+
} satisfies Record<string, EndpointErrorPolicy>;
|
|
150
|
+
|
|
151
|
+
/** Expected-invariant table per error code. */
|
|
152
|
+
export const ENDPOINT_EXPECTED: Readonly<Record<EndpointErrorCode, string>> = Object.fromEntries(
|
|
153
|
+
Object.entries(endpointErrorPolicy).map(([code, policy]) => [code, policy.expected]),
|
|
154
|
+
) as Readonly<Record<EndpointErrorCode, string>>;
|
|
155
|
+
|
|
156
|
+
/** Actionable hint table per error code. */
|
|
157
|
+
export const ENDPOINT_ERROR_HINTS: Readonly<Record<EndpointErrorCode, string>> = Object.fromEntries(
|
|
158
|
+
Object.entries(endpointErrorPolicy).map(([code, policy]) => [code, policy.hint]),
|
|
159
|
+
) as Readonly<Record<EndpointErrorCode, string>>;
|
|
160
|
+
|
|
161
|
+
/** Type guard for narrowing unknown to EndpointError. */
|
|
162
|
+
export function isEndpointError(err: unknown): err is EndpointError {
|
|
163
|
+
return err instanceof EndpointErrorClass;
|
|
164
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @forgeax/engine-net -- memory endpoint implementation.
|
|
2
|
+
// Deterministic memory backend for the NetEndpoint contract.
|
|
3
|
+
// (requirements AC-03, plan-strategy D-3)
|
|
4
|
+
|
|
5
|
+
import type { Result } from '@forgeax/engine-types';
|
|
6
|
+
import { err, ok } from '@forgeax/engine-types';
|
|
7
|
+
import type { EndpointEvent, NetEndpoint, PeerId } from './endpoint';
|
|
8
|
+
import type { EndpointError as EndpointErrorType } from './errors';
|
|
9
|
+
import { ENDPOINT_ERROR_HINTS, ENDPOINT_EXPECTED, EndpointError } from './errors';
|
|
10
|
+
|
|
11
|
+
interface InternalState {
|
|
12
|
+
delayNext: boolean;
|
|
13
|
+
duplicateNext: boolean;
|
|
14
|
+
malformNext: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class MemoryEndpoint implements NetEndpoint {
|
|
18
|
+
readonly _peerId: PeerId;
|
|
19
|
+
_remote: MemoryEndpoint | null = null;
|
|
20
|
+
_closed = false;
|
|
21
|
+
_remoteConnected = false;
|
|
22
|
+
_incoming: EndpointEvent[] = [];
|
|
23
|
+
_delayed: EndpointEvent[] = [];
|
|
24
|
+
_state: InternalState = { delayNext: false, duplicateNext: false, malformNext: false };
|
|
25
|
+
|
|
26
|
+
constructor(peerId: PeerId) {
|
|
27
|
+
this._peerId = peerId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
poll(): EndpointEvent[] {
|
|
31
|
+
if (this._closed) return [];
|
|
32
|
+
const events = this._incoming.splice(0);
|
|
33
|
+
this._incoming = this._delayed.splice(0);
|
|
34
|
+
return events;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
send(peerId: PeerId, data: Uint8Array): Result<void, EndpointErrorType> {
|
|
38
|
+
if (this._closed) {
|
|
39
|
+
return err(
|
|
40
|
+
new EndpointError({
|
|
41
|
+
code: 'already-closed',
|
|
42
|
+
expected: ENDPOINT_EXPECTED['already-closed'],
|
|
43
|
+
hint: ENDPOINT_ERROR_HINTS['already-closed'],
|
|
44
|
+
detail: { cause: 'endpoint is closed' },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!this._remote || this._remote._peerId !== peerId) {
|
|
49
|
+
return err(
|
|
50
|
+
new EndpointError({
|
|
51
|
+
code: 'peer-not-found',
|
|
52
|
+
expected: ENDPOINT_EXPECTED['peer-not-found'],
|
|
53
|
+
hint: ENDPOINT_ERROR_HINTS['peer-not-found'],
|
|
54
|
+
detail: { peerId },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!this._remoteConnected) {
|
|
59
|
+
return err(
|
|
60
|
+
new EndpointError({
|
|
61
|
+
code: 'connection-closed',
|
|
62
|
+
expected: ENDPOINT_EXPECTED['connection-closed'],
|
|
63
|
+
hint: ENDPOINT_ERROR_HINTS['connection-closed'],
|
|
64
|
+
detail: { peerId },
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const deliver = (bytes: Uint8Array) => {
|
|
70
|
+
if (this._state.delayNext) {
|
|
71
|
+
this._remote?._delayed.push({ kind: 'message', peerId: this._peerId, data: bytes });
|
|
72
|
+
this._state.delayNext = false;
|
|
73
|
+
} else {
|
|
74
|
+
this._remote?._incoming.push({ kind: 'message', peerId: this._peerId, data: bytes });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (this._state.malformNext) {
|
|
79
|
+
const corrupted = new Uint8Array(data);
|
|
80
|
+
if (corrupted.length > 0) {
|
|
81
|
+
const firstByte = corrupted[0];
|
|
82
|
+
if (firstByte !== undefined) corrupted[0] = firstByte ^ 0xff;
|
|
83
|
+
}
|
|
84
|
+
deliver(corrupted);
|
|
85
|
+
this._state.malformNext = false;
|
|
86
|
+
} else {
|
|
87
|
+
deliver(data);
|
|
88
|
+
if (this._state.duplicateNext) {
|
|
89
|
+
this._state.duplicateNext = false;
|
|
90
|
+
if (this._state.delayNext) {
|
|
91
|
+
this._remote?._delayed.push({ kind: 'message', peerId: this._peerId, data });
|
|
92
|
+
this._state.delayNext = false;
|
|
93
|
+
} else {
|
|
94
|
+
this._remote?._incoming.push({ kind: 'message', peerId: this._peerId, data });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return ok(undefined);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close(): Result<void, EndpointErrorType> {
|
|
103
|
+
if (this._closed) {
|
|
104
|
+
return err(
|
|
105
|
+
new EndpointError({
|
|
106
|
+
code: 'already-closed',
|
|
107
|
+
expected: ENDPOINT_EXPECTED['already-closed'],
|
|
108
|
+
hint: ENDPOINT_ERROR_HINTS['already-closed'],
|
|
109
|
+
detail: { cause: 'endpoint is already closed' },
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
this._closed = true;
|
|
114
|
+
this._remoteConnected = false;
|
|
115
|
+
if (this._remote && !this._remote._closed) {
|
|
116
|
+
this._remote._remoteConnected = false;
|
|
117
|
+
this._remote._incoming.push({ kind: 'peer-disconnected', peerId: this._peerId });
|
|
118
|
+
}
|
|
119
|
+
return ok(undefined);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_forceDisconnect(): void {
|
|
123
|
+
if (this._remote && !this._remote._closed) {
|
|
124
|
+
this._remote._remoteConnected = false;
|
|
125
|
+
this._remote._incoming.push({ kind: 'peer-disconnected', peerId: this._peerId });
|
|
126
|
+
}
|
|
127
|
+
this._remoteConnected = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createMemoryEndpointPair(): [NetEndpoint, NetEndpoint] {
|
|
132
|
+
const epA = new MemoryEndpoint(1 as PeerId);
|
|
133
|
+
const epB = new MemoryEndpoint(2 as PeerId);
|
|
134
|
+
epA._remote = epB;
|
|
135
|
+
epB._remote = epA;
|
|
136
|
+
epA._remoteConnected = true;
|
|
137
|
+
epB._remoteConnected = true;
|
|
138
|
+
epA._incoming.push({ kind: 'peer-connected', peerId: 2 as PeerId });
|
|
139
|
+
epB._incoming.push({ kind: 'peer-connected', peerId: 1 as PeerId });
|
|
140
|
+
return [epA, epB];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface MemoryFaultController {
|
|
144
|
+
delayNextDelivery(ms: number): void;
|
|
145
|
+
duplicateNextDelivery(): void;
|
|
146
|
+
malformNextDelivery(): void;
|
|
147
|
+
disconnectPeer(endpoint: NetEndpoint): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createMemoryEndpointPairWithController(): {
|
|
151
|
+
readonly endpoints: [NetEndpoint, NetEndpoint];
|
|
152
|
+
readonly controller: MemoryFaultController;
|
|
153
|
+
} {
|
|
154
|
+
const [epA, epB] = createMemoryEndpointPair();
|
|
155
|
+
|
|
156
|
+
const controller: MemoryFaultController = {
|
|
157
|
+
delayNextDelivery(_ms: number): void {
|
|
158
|
+
(epA as MemoryEndpoint)._state.delayNext = true;
|
|
159
|
+
},
|
|
160
|
+
duplicateNextDelivery(): void {
|
|
161
|
+
(epA as MemoryEndpoint)._state.duplicateNext = true;
|
|
162
|
+
},
|
|
163
|
+
malformNextDelivery(): void {
|
|
164
|
+
(epA as MemoryEndpoint)._state.malformNext = true;
|
|
165
|
+
},
|
|
166
|
+
disconnectPeer(endpoint: NetEndpoint): void {
|
|
167
|
+
(endpoint as MemoryEndpoint)._forceDisconnect();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return { endpoints: [epA, epB], controller };
|
|
172
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @forgeax/engine-net -- memory transport, replication session, and profile-driven ECS sync.
|
|
2
|
+
//
|
|
3
|
+
// Depends on @forgeax/engine-ecs (World, schedule), @forgeax/engine-plugin (Plugin),
|
|
4
|
+
// and @forgeax/engine-types (Result, errors). No WebSocket, browser, app, or runtime dependency.
|
|
5
|
+
|
|
6
|
+
// Endpoint contract (requirements AC-02, AC-13)
|
|
7
|
+
export type { EndpointEvent, NetEndpoint, PeerId } from './endpoint/endpoint';
|
|
8
|
+
export type { EndpointErrorCode, EndpointErrorDetail } from './endpoint/errors';
|
|
9
|
+
export {
|
|
10
|
+
ENDPOINT_ERROR_HINTS,
|
|
11
|
+
ENDPOINT_EXPECTED,
|
|
12
|
+
EndpointError,
|
|
13
|
+
isEndpointError,
|
|
14
|
+
} from './endpoint/errors';
|
|
15
|
+
export type { MemoryFaultController } from './endpoint/memory';
|
|
16
|
+
// Memory endpoint (requirements AC-03)
|
|
17
|
+
export {
|
|
18
|
+
createMemoryEndpointPair,
|
|
19
|
+
createMemoryEndpointPairWithController,
|
|
20
|
+
} from './endpoint/memory';
|
|
21
|
+
export { AuthorityCoordinator, createAuthorityCoordinator } from './replication/authority';
|
|
22
|
+
export type {
|
|
23
|
+
NetEntityId,
|
|
24
|
+
ReplicationBatch,
|
|
25
|
+
ReplicationComponentRecord,
|
|
26
|
+
ReplicationEntityRecord,
|
|
27
|
+
} from './replication/codec';
|
|
28
|
+
export { NetError, type NetErrorCode, type NetErrorDetail } from './replication/errors';
|
|
29
|
+
export { validateHandshake } from './replication/handshake';
|
|
30
|
+
export type {
|
|
31
|
+
DefineReplicationOptions,
|
|
32
|
+
ReplicationLimits,
|
|
33
|
+
ReplicationProfile,
|
|
34
|
+
} from './replication/profile';
|
|
35
|
+
export { defineReplication } from './replication/profile';
|
|
36
|
+
export {
|
|
37
|
+
applyReplicaBatch,
|
|
38
|
+
createReplicaCoordinator,
|
|
39
|
+
decodeAndApplyReplicaBatch,
|
|
40
|
+
ReplicaCoordinator,
|
|
41
|
+
} from './replication/replica';
|
|
42
|
+
export type { NetSessionConfig, PeerSnapshot, RawMessage } from './session/net-session';
|
|
43
|
+
// Session (requirements AC-04)
|
|
44
|
+
export { NetSession } from './session/net-session';
|
|
45
|
+
export type { NetPluginConfig } from './session/session-plugin';
|
|
46
|
+
export { netPlugin } from './session/session-plugin';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { projectComponentData } from '@forgeax/engine-ecs/externalization';
|
|
3
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
4
|
+
import {
|
|
5
|
+
encodeReplicationBatch,
|
|
6
|
+
type ReplicationBatch,
|
|
7
|
+
type ReplicationComponentRecord,
|
|
8
|
+
type ReplicationEntityRecord,
|
|
9
|
+
} from './codec';
|
|
10
|
+
import { REPLICATION_PROTOCOL_VERSION } from './constants';
|
|
11
|
+
import type { NetError } from './errors';
|
|
12
|
+
import { DEFAULT_REPLICATION_LIMITS, type ReplicationProfile } from './profile';
|
|
13
|
+
|
|
14
|
+
export interface PublishedBatch extends ReplicationBatch {
|
|
15
|
+
readonly bytes: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
interface KnownEntity {
|
|
18
|
+
readonly id: number;
|
|
19
|
+
readonly components: Map<string, string>;
|
|
20
|
+
}
|
|
21
|
+
function stable(value: unknown): string {
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class AuthorityCoordinator {
|
|
26
|
+
readonly #world: World;
|
|
27
|
+
readonly #profile: ReplicationProfile;
|
|
28
|
+
readonly #ids = new Map<EntityHandle, number>();
|
|
29
|
+
readonly #known = new Map<EntityHandle, KnownEntity>();
|
|
30
|
+
#nextId = 1;
|
|
31
|
+
#tick = 0;
|
|
32
|
+
constructor(world: World, profile: ReplicationProfile) {
|
|
33
|
+
this.#world = world;
|
|
34
|
+
this.#profile = profile;
|
|
35
|
+
}
|
|
36
|
+
idFor(entity: EntityHandle): number {
|
|
37
|
+
return this.#ids.get(entity) ?? 0;
|
|
38
|
+
}
|
|
39
|
+
publish(): Result<PublishedBatch, NetError> {
|
|
40
|
+
return this.#publish(false);
|
|
41
|
+
}
|
|
42
|
+
publishFull(): Result<PublishedBatch, NetError> {
|
|
43
|
+
return this.#publish(true);
|
|
44
|
+
}
|
|
45
|
+
#publish(forceFull: boolean): Result<PublishedBatch, NetError> {
|
|
46
|
+
const candidateIds = new Map(this.#ids);
|
|
47
|
+
let candidateNextId = this.#nextId;
|
|
48
|
+
const current = new Map<
|
|
49
|
+
EntityHandle,
|
|
50
|
+
{ id: number; components: ReplicationComponentRecord[] }
|
|
51
|
+
>();
|
|
52
|
+
const query = this.#world.query(this.#profile.entities).unwrap();
|
|
53
|
+
// Allocate every visible entity id before projecting any component data.
|
|
54
|
+
// Query iteration visits storage groups independently, so projecting while
|
|
55
|
+
// discovering ids can encode references to a later chunk as zero.
|
|
56
|
+
for (const row of query) {
|
|
57
|
+
if (!candidateIds.has(row.entity)) candidateIds.set(row.entity, candidateNextId++);
|
|
58
|
+
}
|
|
59
|
+
for (const row of query) {
|
|
60
|
+
const entity = row.entity;
|
|
61
|
+
const components: ReplicationComponentRecord[] = [];
|
|
62
|
+
for (const component of this.#profile.components) {
|
|
63
|
+
const raw = this.#world.get(entity, component);
|
|
64
|
+
if (raw.ok) {
|
|
65
|
+
components.push({
|
|
66
|
+
name: component.name,
|
|
67
|
+
data: projectComponentData(
|
|
68
|
+
component,
|
|
69
|
+
raw.value as Record<string, unknown>,
|
|
70
|
+
(reference) => candidateIds.get(reference as EntityHandle) ?? 0,
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const id = candidateIds.get(entity);
|
|
76
|
+
if (id !== undefined) current.set(entity, { id, components });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const full = forceFull || this.#tick === 0;
|
|
80
|
+
const entities: ReplicationEntityRecord[] = [];
|
|
81
|
+
for (const [entity, entry] of current) {
|
|
82
|
+
const prior = this.#known.get(entity);
|
|
83
|
+
const components =
|
|
84
|
+
full || prior === undefined
|
|
85
|
+
? entry.components
|
|
86
|
+
: [
|
|
87
|
+
...entry.components.filter(
|
|
88
|
+
(component) => prior.components.get(component.name) !== stable(component.data),
|
|
89
|
+
),
|
|
90
|
+
...[...prior.components.keys()]
|
|
91
|
+
.filter((name) => !entry.components.some((component) => component.name === name))
|
|
92
|
+
.map((name) => ({ name, operation: 'remove' as const, data: {} })),
|
|
93
|
+
];
|
|
94
|
+
if (full || prior === undefined || components.length > 0)
|
|
95
|
+
entities.push({ id: entry.id, kind: 'upsert', components });
|
|
96
|
+
}
|
|
97
|
+
// A full baseline is consumed by a fresh replica, so it must describe
|
|
98
|
+
// only live entities. Despawn records refer to the previous authority
|
|
99
|
+
// baseline and would be unknown identities on a late-joining replica.
|
|
100
|
+
if (!full)
|
|
101
|
+
for (const [entity, prior] of this.#known) {
|
|
102
|
+
if (!current.has(entity)) entities.push({ id: prior.id, kind: 'despawn', components: [] });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const candidateKnown = new Map<EntityHandle, KnownEntity>();
|
|
106
|
+
for (const [entity, entry] of current) {
|
|
107
|
+
candidateKnown.set(entity, {
|
|
108
|
+
id: entry.id,
|
|
109
|
+
components: new Map(
|
|
110
|
+
entry.components.map((component) => [component.name, stable(component.data)]),
|
|
111
|
+
),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
for (const [entity] of candidateIds) {
|
|
115
|
+
if (!current.has(entity)) candidateIds.delete(entity);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const batch: ReplicationBatch = {
|
|
119
|
+
version: REPLICATION_PROTOCOL_VERSION,
|
|
120
|
+
fingerprint: this.#profile.fingerprint,
|
|
121
|
+
tick: this.#tick + 1,
|
|
122
|
+
full,
|
|
123
|
+
entities,
|
|
124
|
+
};
|
|
125
|
+
const encoded = encodeReplicationBatch(
|
|
126
|
+
batch,
|
|
127
|
+
this.#profile.limits ?? DEFAULT_REPLICATION_LIMITS,
|
|
128
|
+
);
|
|
129
|
+
if (!encoded.ok) return err(encoded.error);
|
|
130
|
+
|
|
131
|
+
this.#ids.clear();
|
|
132
|
+
for (const [entity, id] of candidateIds) this.#ids.set(entity, id);
|
|
133
|
+
this.#known.clear();
|
|
134
|
+
for (const [entity, known] of candidateKnown) this.#known.set(entity, known);
|
|
135
|
+
this.#nextId = candidateNextId;
|
|
136
|
+
this.#tick = batch.tick;
|
|
137
|
+
return ok({ ...batch, bytes: encoded.value });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function createAuthorityCoordinator(
|
|
141
|
+
world: World,
|
|
142
|
+
profile: ReplicationProfile,
|
|
143
|
+
): AuthorityCoordinator {
|
|
144
|
+
return new AuthorityCoordinator(world, profile);
|
|
145
|
+
}
|