@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,194 @@
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, NetEndpointConnector, 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
+ }
173
+
174
+ /** Create a replaceable, realm-neutral connector over a deterministic endpoint factory. */
175
+ export function createMemoryEndpointConnector(
176
+ createEndpoint: () => NetEndpoint,
177
+ ): NetEndpointConnector {
178
+ return {
179
+ connect(signal) {
180
+ if (signal.aborted)
181
+ return Promise.resolve(
182
+ err(
183
+ new EndpointError({
184
+ code: 'connection-failed',
185
+ expected: ENDPOINT_EXPECTED['connection-failed'],
186
+ hint: ENDPOINT_ERROR_HINTS['connection-failed'],
187
+ detail: { address: 'memory', cause: 'connect aborted' },
188
+ }),
189
+ ),
190
+ );
191
+ return Promise.resolve(ok(createEndpoint()));
192
+ },
193
+ };
194
+ }
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
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, NetEndpointConnector, 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
+ createMemoryEndpointConnector,
19
+ createMemoryEndpointPair,
20
+ createMemoryEndpointPairWithController,
21
+ } from './endpoint/memory';
22
+ export { AuthorityCoordinator, createAuthorityCoordinator } from './replication/authority';
23
+ export { decodeReplicationPacket, encodeReplicationPacket } from './replication/codec';
24
+ export {
25
+ REPLICATION_PROTOCOL_PREFIX,
26
+ REPLICATION_PROTOCOL_VERSION,
27
+ } from './replication/constants';
28
+ export {
29
+ NetError,
30
+ type NetErrorCode,
31
+ type NetErrorDetail,
32
+ type NetErrorDetailByCode,
33
+ type NetErrorDetailFor,
34
+ } from './replication/errors';
35
+ export { validateHandshake } from './replication/handshake';
36
+ export type {
37
+ DefineReplicationOptions,
38
+ ReplicationLimits,
39
+ ReplicationProfile,
40
+ } from './replication/profile';
41
+ export { DEFAULT_REPLICATION_LIMITS, defineReplication } from './replication/profile';
42
+ export type {
43
+ ReplicationAckPacket,
44
+ ReplicationBaselinePacket,
45
+ ReplicationComponentRecord,
46
+ ReplicationDataPacket,
47
+ ReplicationDataPacketBase,
48
+ ReplicationDataPacketKind,
49
+ ReplicationDeltaPacket,
50
+ ReplicationEntityKind,
51
+ ReplicationEntityRecord,
52
+ ReplicationPacket,
53
+ ReplicationPacketKind,
54
+ ReplicationRejectionPacket,
55
+ ReplicationSessionPacket,
56
+ } from './replication/protocol';
57
+ export {
58
+ applyReplicationPacket,
59
+ createReplicaCoordinator,
60
+ decodeAndApplyReplicationPacket,
61
+ ReplicaCoordinator,
62
+ } from './replication/replica';
63
+ export type {
64
+ NetSessionConfig,
65
+ PeerSnapshot,
66
+ RawMessage,
67
+ SessionSnapshot,
68
+ } from './session/net-session';
69
+ // Session (requirements AC-04)
70
+ export { NetSession } from './session/net-session';
71
+ export type {
72
+ NetRecoveryOutcome,
73
+ NetRecoveryPolicy,
74
+ NetRecoverySnapshot,
75
+ NetSessionFailure,
76
+ NetSessionState,
77
+ NetSessionStateKind,
78
+ SessionId,
79
+ } from './session/recovery';
80
+ export {
81
+ createSessionId,
82
+ DEFAULT_NET_RECOVERY_POLICY,
83
+ isLegalNetSessionTransition,
84
+ RECOVERY_ERROR_CODES,
85
+ resolveNetRecoveryPolicy,
86
+ transitionNetSessionState,
87
+ validateNetRecoveryPolicy,
88
+ } from './session/recovery';
89
+ export type { NetPluginConfig } from './session/session-plugin';
90
+ export { netPlugin } from './session/session-plugin';
@@ -0,0 +1,177 @@
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 type { SessionId } from '../session/recovery';
5
+ import { encodeReplicationPacket } from './codec';
6
+ import { REPLICATION_PROTOCOL_VERSION } from './constants';
7
+ import type { NetError } from './errors';
8
+ import { DEFAULT_REPLICATION_LIMITS, type ReplicationProfile } from './profile';
9
+ import type {
10
+ ReplicationComponentRecord,
11
+ ReplicationDataPacket,
12
+ ReplicationEntityRecord,
13
+ } from './protocol';
14
+
15
+ export type PublishedPacket = ReplicationDataPacket & {
16
+ readonly bytes: Uint8Array;
17
+ };
18
+ interface KnownEntity {
19
+ readonly id: number;
20
+ readonly components: Map<string, string>;
21
+ }
22
+ function stable(value: unknown): string {
23
+ return JSON.stringify(value);
24
+ }
25
+
26
+ export class AuthorityCoordinator {
27
+ readonly #world: World;
28
+ readonly #profile: ReplicationProfile;
29
+ readonly #ids = new Map<EntityHandle, number>();
30
+ readonly #known = new Map<EntityHandle, KnownEntity>();
31
+ #nextId = 1;
32
+ #tick = 0;
33
+ #epoch = 0;
34
+ #sequence = 0;
35
+ readonly #sessionId: SessionId;
36
+ constructor(world: World, profile: ReplicationProfile, sessionId: SessionId = 1 as SessionId) {
37
+ this.#world = world;
38
+ this.#profile = profile;
39
+ this.#sessionId = sessionId;
40
+ }
41
+ idFor(entity: EntityHandle): number {
42
+ return this.#ids.get(entity) ?? 0;
43
+ }
44
+ publish(): Result<PublishedPacket, NetError> {
45
+ return this.#publish(false);
46
+ }
47
+ publishFull(): Result<PublishedPacket, NetError> {
48
+ return this.#publish(true);
49
+ }
50
+ nextPublicationEpoch(forceFull = false): number {
51
+ return forceFull && this.#tick > 0 ? this.#epoch + 1 : this.#epoch;
52
+ }
53
+ #publish(forceFull: boolean): Result<PublishedPacket, NetError> {
54
+ const candidateIds = new Map(this.#ids);
55
+ let candidateNextId = this.#nextId;
56
+ const current = new Map<
57
+ EntityHandle,
58
+ { id: number; components: ReplicationComponentRecord[] }
59
+ >();
60
+ const query = this.#world.query(this.#profile.entities).unwrap();
61
+ // Allocate every visible entity id before projecting any component data.
62
+ // Query iteration visits storage groups independently, so projecting while
63
+ // discovering ids can encode references to a later chunk as zero.
64
+ for (const row of query) {
65
+ if (!candidateIds.has(row.entity)) candidateIds.set(row.entity, candidateNextId++);
66
+ }
67
+ for (const row of query) {
68
+ const entity = row.entity;
69
+ const components: ReplicationComponentRecord[] = [];
70
+ for (const component of this.#profile.components) {
71
+ const raw = this.#world.get(entity, component);
72
+ if (raw.ok) {
73
+ components.push({
74
+ name: component.name,
75
+ data: projectComponentData(
76
+ component,
77
+ raw.value as Record<string, unknown>,
78
+ (reference) => candidateIds.get(reference as EntityHandle) ?? 0,
79
+ ),
80
+ });
81
+ }
82
+ }
83
+ const id = candidateIds.get(entity);
84
+ if (id !== undefined) current.set(entity, { id, components });
85
+ }
86
+
87
+ const full = forceFull || this.#tick === 0;
88
+ let nextEpoch = this.#epoch;
89
+ let nextSequence = this.#sequence;
90
+ if (forceFull && this.#tick > 0) {
91
+ nextEpoch += 1;
92
+ nextSequence = 0;
93
+ }
94
+ if (full && nextSequence === 0) nextSequence = 1;
95
+ else nextSequence += 1;
96
+ const entities: ReplicationEntityRecord[] = [];
97
+ for (const [entity, entry] of current) {
98
+ const prior = this.#known.get(entity);
99
+ const components =
100
+ full || prior === undefined
101
+ ? entry.components
102
+ : [
103
+ ...entry.components.filter(
104
+ (component) => prior.components.get(component.name) !== stable(component.data),
105
+ ),
106
+ ...[...prior.components.keys()]
107
+ .filter((name) => !entry.components.some((component) => component.name === name))
108
+ .map((name) => ({ name, operation: 'remove' as const, data: {} })),
109
+ ];
110
+ if (full || prior === undefined || components.length > 0)
111
+ entities.push({ id: entry.id, kind: 'upsert', components });
112
+ }
113
+ // A full baseline is consumed by a fresh replica, so it must describe
114
+ // only live entities. Despawn records refer to the previous authority
115
+ // baseline and would be unknown identities on a late-joining replica.
116
+ if (!full)
117
+ for (const [entity, prior] of this.#known) {
118
+ if (!current.has(entity)) entities.push({ id: prior.id, kind: 'despawn', components: [] });
119
+ }
120
+
121
+ const candidateKnown = new Map<EntityHandle, KnownEntity>();
122
+ for (const [entity, entry] of current) {
123
+ candidateKnown.set(entity, {
124
+ id: entry.id,
125
+ components: new Map(
126
+ entry.components.map((component) => [component.name, stable(component.data)]),
127
+ ),
128
+ });
129
+ }
130
+ for (const [entity] of candidateIds) {
131
+ if (!current.has(entity)) candidateIds.delete(entity);
132
+ }
133
+
134
+ const packet: ReplicationDataPacket = full
135
+ ? {
136
+ version: REPLICATION_PROTOCOL_VERSION,
137
+ kind: 'baseline',
138
+ sessionId: this.#sessionId,
139
+ epoch: nextEpoch,
140
+ sequence: nextSequence as 1,
141
+ fingerprint: this.#profile.fingerprint,
142
+ tick: this.#tick + 1,
143
+ entities,
144
+ }
145
+ : {
146
+ version: REPLICATION_PROTOCOL_VERSION,
147
+ kind: 'delta',
148
+ sessionId: this.#sessionId,
149
+ epoch: nextEpoch,
150
+ sequence: nextSequence,
151
+ fingerprint: this.#profile.fingerprint,
152
+ tick: this.#tick + 1,
153
+ entities,
154
+ };
155
+ const encoded = encodeReplicationPacket(
156
+ packet,
157
+ this.#profile.limits ?? DEFAULT_REPLICATION_LIMITS,
158
+ );
159
+ if (!encoded.ok) return err(encoded.error);
160
+
161
+ this.#ids.clear();
162
+ for (const [entity, id] of candidateIds) this.#ids.set(entity, id);
163
+ this.#known.clear();
164
+ for (const [entity, known] of candidateKnown) this.#known.set(entity, known);
165
+ this.#nextId = candidateNextId;
166
+ this.#tick = packet.tick;
167
+ this.#epoch = nextEpoch;
168
+ this.#sequence = nextSequence;
169
+ return ok({ ...packet, bytes: encoded.value });
170
+ }
171
+ }
172
+ export function createAuthorityCoordinator(
173
+ world: World,
174
+ profile: ReplicationProfile,
175
+ ): AuthorityCoordinator {
176
+ return new AuthorityCoordinator(world, profile);
177
+ }