modd-network 1.0.2 → 1.0.3
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/dist/binary-input.d.ts +53 -0
- package/dist/binary-input.js +168 -0
- package/dist/game-input-codec.d.ts +55 -0
- package/dist/game-input-codec.js +263 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -1
- package/dist/input-codec.d.ts +33 -0
- package/dist/input-codec.js +192 -0
- package/dist/modd-engine.d.ts +77 -56
- package/dist/modd-engine.js +479 -244
- package/dist/modd-engine.test.d.ts +1 -0
- package/dist/modd-engine.test.js +190 -0
- package/dist/modd-network.d.ts +55 -22
- package/dist/modd-network.js +497 -352
- package/dist/state-codec.d.ts +81 -0
- package/dist/state-codec.js +337 -0
- package/package.json +5 -2
package/dist/modd-network.js
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* MODD Network SDK
|
|
4
|
-
* Pure network layer for real-time multiplayer applications
|
|
5
|
-
* Works with any app type: games, chat, collaboration, etc.
|
|
6
|
-
*/
|
|
7
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
-
};
|
|
10
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.modd = void 0;
|
|
3
|
+
exports.modd = exports.unregisterPlayerId = exports.registerPlayerId = exports.hashPlayerId = void 0;
|
|
4
|
+
exports.hashClientId = hashClientId;
|
|
5
|
+
exports.registerClientId = registerClientId;
|
|
6
|
+
exports.unregisterClientId = unregisterClientId;
|
|
7
|
+
exports.encodeSyncHash = encodeSyncHash;
|
|
8
|
+
exports.decodeBinaryMessage = decodeBinaryMessage;
|
|
12
9
|
exports.connect = connect;
|
|
13
|
-
const
|
|
14
|
-
//
|
|
15
|
-
// Binary Protocol
|
|
16
|
-
// ============================================
|
|
10
|
+
const state_codec_1 = require("./state-codec");
|
|
11
|
+
// Binary Message Types (Must match node/src/binary-protocol.ts)
|
|
17
12
|
const BinaryMessageType = {
|
|
18
13
|
TICK: 0x01,
|
|
19
14
|
INITIAL_STATE: 0x02,
|
|
@@ -22,128 +17,243 @@ const BinaryMessageType = {
|
|
|
22
17
|
ERROR: 0x05,
|
|
23
18
|
SNAPSHOT_UPDATE: 0x06,
|
|
24
19
|
ROOM_LEFT: 0x07,
|
|
25
|
-
SYNC_HASH: 0x08
|
|
20
|
+
SYNC_HASH: 0x08,
|
|
21
|
+
CLIENT_LIST_UPDATE: 0x09
|
|
26
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Hash a client ID to a 4-byte identifier (matches server-side hash)
|
|
25
|
+
* Uses FNV-1a hash for speed and consistency
|
|
26
|
+
*/
|
|
27
|
+
function hashClientId(clientId) {
|
|
28
|
+
let hash = 2166136261; // FNV offset basis
|
|
29
|
+
for (let i = 0; i < clientId.length; i++) {
|
|
30
|
+
hash ^= clientId.charCodeAt(i);
|
|
31
|
+
hash = Math.imul(hash, 16777619) >>> 0; // FNV prime, keep as 32-bit unsigned
|
|
32
|
+
}
|
|
33
|
+
return hash;
|
|
34
|
+
}
|
|
35
|
+
// Backwards compat alias
|
|
36
|
+
exports.hashPlayerId = hashClientId;
|
|
37
|
+
// Map clientHash -> clientId for decoding TICK messages
|
|
38
|
+
const clientHashMap = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Register a client ID so we can decode their hash in TICK messages
|
|
41
|
+
* Call this when a client joins (from the join event's clientId)
|
|
42
|
+
*/
|
|
43
|
+
function registerClientId(clientId) {
|
|
44
|
+
const hash = hashClientId(clientId);
|
|
45
|
+
clientHashMap.set(hash, clientId);
|
|
46
|
+
}
|
|
47
|
+
// Backwards compat alias
|
|
48
|
+
exports.registerPlayerId = registerClientId;
|
|
49
|
+
/**
|
|
50
|
+
* Unregister a client ID (call on leave)
|
|
51
|
+
*/
|
|
52
|
+
function unregisterClientId(clientId) {
|
|
53
|
+
const hash = hashClientId(clientId);
|
|
54
|
+
clientHashMap.delete(hash);
|
|
55
|
+
}
|
|
56
|
+
// Backwards compat alias
|
|
57
|
+
exports.unregisterPlayerId = unregisterClientId;
|
|
58
|
+
/**
|
|
59
|
+
* Look up client ID from hash
|
|
60
|
+
*/
|
|
61
|
+
function lookupClientHash(hash) {
|
|
62
|
+
return clientHashMap.get(hash);
|
|
63
|
+
}
|
|
64
|
+
// Encode sync hash
|
|
27
65
|
function encodeSyncHash(roomId, hash, seq, frame) {
|
|
28
66
|
const roomIdBytes = new TextEncoder().encode(roomId);
|
|
29
67
|
const hashBytes = new TextEncoder().encode(hash);
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const view = new DataView(buffer);
|
|
33
|
-
const uint8 = new Uint8Array(buffer);
|
|
68
|
+
// 1 (type) + 2 (roomId len) + roomId + 2 (hash len) + hash + 4 (seq) + 4 (frame)
|
|
69
|
+
const buf = new Uint8Array(1 + 2 + roomIdBytes.length + 2 + hashBytes.length + 4 + 4);
|
|
70
|
+
const view = new DataView(buf.buffer);
|
|
34
71
|
let offset = 0;
|
|
35
|
-
|
|
36
|
-
offset += 1;
|
|
72
|
+
buf[offset++] = BinaryMessageType.SYNC_HASH;
|
|
37
73
|
view.setUint16(offset, roomIdBytes.length, true);
|
|
38
74
|
offset += 2;
|
|
39
|
-
|
|
75
|
+
buf.set(roomIdBytes, offset);
|
|
40
76
|
offset += roomIdBytes.length;
|
|
41
77
|
view.setUint16(offset, hashBytes.length, true);
|
|
42
78
|
offset += 2;
|
|
43
|
-
|
|
79
|
+
buf.set(hashBytes, offset);
|
|
44
80
|
offset += hashBytes.length;
|
|
45
81
|
view.setUint32(offset, seq, true);
|
|
46
82
|
offset += 4;
|
|
47
83
|
view.setUint32(offset, frame, true);
|
|
48
|
-
return
|
|
84
|
+
return buf;
|
|
49
85
|
}
|
|
50
86
|
function decodeBinaryMessage(buffer) {
|
|
51
|
-
const view = new DataView(buffer);
|
|
52
87
|
if (buffer.byteLength === 0)
|
|
53
88
|
return null;
|
|
89
|
+
const view = new DataView(buffer);
|
|
54
90
|
const type = view.getUint8(0);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
try {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case BinaryMessageType.TICK: {
|
|
94
|
+
// Binary format: [type:1][frame:4][count:1][inputs...]
|
|
95
|
+
// Each input: [clientHash:4][seq:4][dataLen:2][data:dataLen]
|
|
96
|
+
// Data can be JSON (join/leave) or binary (game inputs)
|
|
97
|
+
const frame = view.getUint32(1, true);
|
|
98
|
+
let events = [];
|
|
99
|
+
if (buffer.byteLength > 5) {
|
|
100
|
+
const inputCount = view.getUint8(5);
|
|
101
|
+
let offset = 6;
|
|
102
|
+
for (let i = 0; i < inputCount && offset < buffer.byteLength; i++) {
|
|
103
|
+
const clientHash = view.getUint32(offset, true);
|
|
104
|
+
offset += 4;
|
|
105
|
+
const seq = view.getUint32(offset, true);
|
|
106
|
+
offset += 4; // UInt32 to support >65535 events
|
|
107
|
+
const dataLen = view.getUint16(offset, true);
|
|
108
|
+
offset += 2;
|
|
109
|
+
if (offset + dataLen > buffer.byteLength)
|
|
110
|
+
break;
|
|
111
|
+
const rawBytes = new Uint8Array(buffer, offset, dataLen);
|
|
112
|
+
offset += dataLen;
|
|
113
|
+
// Detect format: JSON starts with '{' (0x7B) or '[' (0x5B)
|
|
114
|
+
let data;
|
|
115
|
+
const firstByte = rawBytes[0];
|
|
116
|
+
if (firstByte === 0x7B || firstByte === 0x5B) {
|
|
117
|
+
// JSON format - decode for join/leave events
|
|
118
|
+
try {
|
|
119
|
+
const jsonStr = new TextDecoder().decode(rawBytes);
|
|
120
|
+
data = JSON.parse(jsonStr);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
data = rawBytes;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Binary format - pass raw bytes to game layer
|
|
128
|
+
data = rawBytes;
|
|
129
|
+
}
|
|
130
|
+
// Look up client ID from hash
|
|
131
|
+
let clientId;
|
|
132
|
+
if (typeof data === 'object' && !(data instanceof Uint8Array)) {
|
|
133
|
+
// Auto-register clientId if present (e.g., join events)
|
|
134
|
+
// This ensures hash lookups work for subsequent inputs
|
|
135
|
+
if (data.clientId && !lookupClientHash(clientHash)) {
|
|
136
|
+
registerClientId(data.clientId);
|
|
137
|
+
}
|
|
138
|
+
clientId = data.clientId || lookupClientHash(clientHash) || `hash_${clientHash.toString(16)}`;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
clientId = lookupClientHash(clientHash) || `hash_${clientHash.toString(16)}`;
|
|
142
|
+
}
|
|
143
|
+
events.push({ seq, data, clientId, clientHash });
|
|
144
|
+
}
|
|
64
145
|
}
|
|
146
|
+
return { type: 'TICK', frame, events };
|
|
65
147
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
148
|
+
case BinaryMessageType.INITIAL_STATE: {
|
|
149
|
+
let offset = 1;
|
|
150
|
+
const frame = view.getUint32(offset, true);
|
|
151
|
+
offset += 4;
|
|
152
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
153
|
+
offset += 2;
|
|
154
|
+
if (offset + roomIdLen > buffer.byteLength) {
|
|
155
|
+
console.error('[modd-network] Buffer overflow reading INITIAL_STATE roomId');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
|
|
159
|
+
offset += roomIdLen;
|
|
160
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
161
|
+
offset += 4;
|
|
162
|
+
if (offset + snapshotLen > buffer.byteLength) {
|
|
163
|
+
console.error('[modd-network] Buffer overflow reading INITIAL_STATE snapshot');
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
|
|
167
|
+
offset += snapshotLen;
|
|
168
|
+
const inputsLen = view.getUint32(offset, true);
|
|
169
|
+
offset += 4;
|
|
170
|
+
if (offset + inputsLen > buffer.byteLength) {
|
|
171
|
+
console.error('[modd-network] Buffer overflow reading INITIAL_STATE inputs');
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const inputsJson = new TextDecoder().decode(new Uint8Array(buffer, offset, inputsLen));
|
|
175
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
176
|
+
const events = JSON.parse(inputsJson);
|
|
177
|
+
return { type: 'INITIAL_STATE', frame, snapshot, snapshotHash, events };
|
|
178
|
+
}
|
|
179
|
+
case BinaryMessageType.ROOM_CREATED: {
|
|
180
|
+
let offset = 1;
|
|
181
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
182
|
+
offset += 2;
|
|
183
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
|
|
184
|
+
offset += roomIdLen;
|
|
185
|
+
const clientIdLen = view.getUint16(offset, true);
|
|
186
|
+
offset += 2;
|
|
187
|
+
const clientId = new TextDecoder().decode(new Uint8Array(buffer, offset, clientIdLen));
|
|
188
|
+
offset += clientIdLen;
|
|
189
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
190
|
+
offset += 4;
|
|
191
|
+
const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
|
|
192
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
193
|
+
return { type: 'ROOM_CREATED', roomId, clientId, snapshot, snapshotHash };
|
|
194
|
+
}
|
|
195
|
+
case BinaryMessageType.ROOM_JOINED: {
|
|
196
|
+
let offset = 1;
|
|
197
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
198
|
+
offset += 2;
|
|
199
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
|
|
200
|
+
offset += roomIdLen;
|
|
201
|
+
const clientIdLen = view.getUint16(offset, true);
|
|
202
|
+
offset += 2;
|
|
203
|
+
const clientId = new TextDecoder().decode(new Uint8Array(buffer, offset, clientIdLen));
|
|
204
|
+
return { type: 'ROOM_JOINED', roomId, clientId };
|
|
205
|
+
}
|
|
206
|
+
case BinaryMessageType.ERROR: {
|
|
207
|
+
const msgLen = view.getUint16(1, true);
|
|
208
|
+
const message = new TextDecoder().decode(new Uint8Array(buffer, 3, msgLen));
|
|
209
|
+
return { type: 'ERROR', message };
|
|
210
|
+
}
|
|
211
|
+
case BinaryMessageType.SNAPSHOT_UPDATE: {
|
|
212
|
+
let offset = 1;
|
|
213
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
214
|
+
offset += 2;
|
|
215
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
|
|
216
|
+
offset += roomIdLen;
|
|
217
|
+
const snapshotLen = view.getUint32(offset, true);
|
|
218
|
+
offset += 4;
|
|
219
|
+
const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
|
|
220
|
+
const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
|
|
221
|
+
return { type: 'SNAPSHOT_UPDATE', roomId, snapshot, snapshotHash };
|
|
222
|
+
}
|
|
223
|
+
case BinaryMessageType.ROOM_LEFT: {
|
|
224
|
+
const roomIdLen = view.getUint16(1, true);
|
|
225
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, 3, roomIdLen));
|
|
226
|
+
return { type: 'ROOM_LEFT', roomId };
|
|
227
|
+
}
|
|
228
|
+
case BinaryMessageType.CLIENT_LIST_UPDATE: {
|
|
229
|
+
let offset = 1;
|
|
230
|
+
const roomIdLen = view.getUint16(offset, true);
|
|
231
|
+
offset += 2;
|
|
232
|
+
const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
|
|
233
|
+
offset += roomIdLen;
|
|
234
|
+
const clientsLen = view.getUint32(offset, true);
|
|
235
|
+
offset += 4;
|
|
236
|
+
const clientsJson = new TextDecoder().decode(new Uint8Array(buffer, offset, clientsLen));
|
|
237
|
+
const clients = JSON.parse(clientsJson);
|
|
238
|
+
return { type: 'CLIENT_LIST_UPDATE', roomId, clients };
|
|
239
|
+
}
|
|
240
|
+
default:
|
|
241
|
+
return null;
|
|
125
242
|
}
|
|
126
|
-
|
|
127
|
-
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.error('[modd-network] Decode error:', err);
|
|
246
|
+
return null;
|
|
128
247
|
}
|
|
129
248
|
}
|
|
130
|
-
// Helper to normalize binary data to ArrayBuffer
|
|
131
249
|
async function toArrayBuffer(data) {
|
|
132
|
-
if (data instanceof ArrayBuffer)
|
|
250
|
+
if (data instanceof ArrayBuffer)
|
|
133
251
|
return data;
|
|
134
|
-
|
|
135
|
-
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
252
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob)
|
|
136
253
|
return await data.arrayBuffer();
|
|
137
|
-
}
|
|
138
|
-
// Fallback for Buffer, ArrayBufferView, or array-like
|
|
139
|
-
// creating a new Uint8Array(data) copies the data to a new ArrayBuffer
|
|
140
254
|
return new Uint8Array(data).buffer;
|
|
141
255
|
}
|
|
142
|
-
|
|
143
|
-
// Main Connect Function
|
|
144
|
-
// ============================================
|
|
145
|
-
async function connect(roomId, options = {}) {
|
|
146
|
-
const appId = options.appId || 'app';
|
|
256
|
+
async function connect(appId, roomId, options = {}) {
|
|
147
257
|
const initialSnapshot = options.snapshot || {};
|
|
148
258
|
const user = options.user || null;
|
|
149
259
|
const onConnect = options.onConnect || (() => { });
|
|
@@ -152,17 +262,17 @@ async function connect(roomId, options = {}) {
|
|
|
152
262
|
const onMessage = options.onMessage || (() => { });
|
|
153
263
|
const onTick = options.onTick || null;
|
|
154
264
|
const getStateHash = options.getStateHash || null;
|
|
155
|
-
// Determine central service URL
|
|
156
265
|
const centralServiceUrl = options.centralServiceUrl || (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
157
266
|
? 'http://localhost:9001'
|
|
158
267
|
: 'https://nodes.modd.io');
|
|
268
|
+
console.log('[modd-network] Central service URL:', centralServiceUrl);
|
|
159
269
|
let connected = false;
|
|
160
|
-
let initialStateReceived = null;
|
|
161
270
|
let deliveredSeqs = new Set();
|
|
162
271
|
let pendingTicks = [];
|
|
163
272
|
let ws = null;
|
|
164
273
|
let nodeUrl = null;
|
|
165
|
-
|
|
274
|
+
let nodeToken = null;
|
|
275
|
+
let connectionResolve = null;
|
|
166
276
|
let bytesIn = 0;
|
|
167
277
|
let bytesOut = 0;
|
|
168
278
|
let lastBytesIn = 0;
|
|
@@ -170,280 +280,315 @@ async function connect(roomId, options = {}) {
|
|
|
170
280
|
let bandwidthIn = 0;
|
|
171
281
|
let bandwidthOut = 0;
|
|
172
282
|
let bandwidthInterval = null;
|
|
173
|
-
// Hash sync tracking
|
|
174
283
|
let hashInterval = null;
|
|
175
284
|
let lastSyncSeq = 0;
|
|
176
285
|
let lastSyncFrame = 0;
|
|
177
286
|
let currentFrame = 0;
|
|
287
|
+
let myClientId = null;
|
|
288
|
+
// Process events to auto-register clientIds from join/leave events
|
|
289
|
+
// This is CRITICAL for binary TICK decoding - the hash lookup needs clientIds registered
|
|
290
|
+
function processEventsForClientIds(events) {
|
|
291
|
+
for (const event of events) {
|
|
292
|
+
const data = event.data || {};
|
|
293
|
+
const eventType = data.type || event.type;
|
|
294
|
+
if (eventType === 'join') {
|
|
295
|
+
const clientId = data.clientId || event.clientId;
|
|
296
|
+
if (clientId) {
|
|
297
|
+
registerClientId(clientId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (eventType === 'leave') {
|
|
301
|
+
const clientId = data.clientId || event.clientId;
|
|
302
|
+
if (clientId) {
|
|
303
|
+
unregisterClientId(clientId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
178
308
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
309
|
+
// Allow direct node URL for testing cross-node scenarios
|
|
310
|
+
if (options.nodeUrl) {
|
|
311
|
+
nodeUrl = options.nodeUrl;
|
|
312
|
+
console.log('[modd-network] Using direct Node URL:', nodeUrl);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const requestBody = {};
|
|
316
|
+
if (options.joinToken) {
|
|
317
|
+
requestBody.joinToken = options.joinToken;
|
|
318
|
+
}
|
|
319
|
+
const res = await fetch(`${centralServiceUrl}/api/apps/${appId}/rooms/${roomId}/connect`, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'Content-Type': 'application/json' },
|
|
322
|
+
body: JSON.stringify(requestBody)
|
|
323
|
+
});
|
|
324
|
+
if (!res.ok) {
|
|
325
|
+
const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
326
|
+
throw new Error(errorData.error || `Central service error: ${res.status}`);
|
|
327
|
+
}
|
|
328
|
+
const responseData = await res.json();
|
|
329
|
+
nodeUrl = responseData.url;
|
|
330
|
+
nodeToken = responseData.token;
|
|
331
|
+
console.log('[modd-network] Received Node URL:', nodeUrl);
|
|
186
332
|
}
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
bandwidthInterval = setInterval(() => {
|
|
198
|
-
bandwidthIn = bytesIn - lastBytesIn;
|
|
199
|
-
bandwidthOut = bytesOut - lastBytesOut;
|
|
200
|
-
lastBytesIn = bytesIn;
|
|
201
|
-
lastBytesOut = bytesOut;
|
|
202
|
-
}, 1000);
|
|
203
|
-
if (getStateHash) {
|
|
204
|
-
hashInterval = setInterval(() => {
|
|
333
|
+
const WS = (typeof globalThis !== 'undefined' && globalThis.WebSocket) ? globalThis.WebSocket : WebSocket;
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
connectionResolve = resolve;
|
|
336
|
+
// Append JWT token to WebSocket URL if provided by central service
|
|
337
|
+
const wsUrl = nodeToken ? `${nodeUrl}?token=${encodeURIComponent(nodeToken)}` : nodeUrl;
|
|
338
|
+
// @ts-ignore
|
|
339
|
+
ws = new WS(wsUrl);
|
|
340
|
+
ws.binaryType = 'arraybuffer';
|
|
341
|
+
const instance = {
|
|
342
|
+
send(data) {
|
|
205
343
|
if (!connected || !ws || ws.readyState !== 1)
|
|
206
344
|
return;
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
catch (err) {
|
|
216
|
-
console.warn('[modd-network] Error getting state hash:', err);
|
|
345
|
+
if (data?.type === 'playerState' && data.id) {
|
|
346
|
+
const binary = (0, state_codec_1.encodePlayerState)(data);
|
|
347
|
+
const wrapper = new Uint8Array(1 + binary.length);
|
|
348
|
+
wrapper[0] = 0x20;
|
|
349
|
+
wrapper.set(binary, 1);
|
|
350
|
+
ws.send(wrapper);
|
|
351
|
+
return;
|
|
217
352
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
ws.onclose = () => {
|
|
226
|
-
connected = false;
|
|
227
|
-
if (bandwidthInterval)
|
|
228
|
-
clearInterval(bandwidthInterval);
|
|
229
|
-
if (hashInterval)
|
|
230
|
-
clearInterval(hashInterval);
|
|
231
|
-
onDisconnect();
|
|
232
|
-
};
|
|
233
|
-
ws.onmessage = async (e) => {
|
|
234
|
-
// Handle various binary formats
|
|
235
|
-
let buffer;
|
|
236
|
-
try {
|
|
237
|
-
buffer = await toArrayBuffer(e.data);
|
|
238
|
-
}
|
|
239
|
-
catch (err) {
|
|
240
|
-
console.warn('[modd-network] Failed to read message data:', err);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
bytesIn += buffer.byteLength;
|
|
244
|
-
const msg = decodeBinaryMessage(buffer);
|
|
245
|
-
if (!msg) {
|
|
246
|
-
console.warn('[modd-network] Failed to decode binary message');
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
switch (msg.type) {
|
|
250
|
-
case 'TICK': {
|
|
251
|
-
if (!connected) {
|
|
252
|
-
pendingTicks.push(msg);
|
|
253
|
-
break;
|
|
353
|
+
if (data?.type === 'entityState' && data.id) {
|
|
354
|
+
const binary = (0, state_codec_1.encodeEntityState)(data);
|
|
355
|
+
const wrapper = new Uint8Array(1 + binary.length);
|
|
356
|
+
wrapper[0] = 0x20;
|
|
357
|
+
wrapper.set(binary, 1);
|
|
358
|
+
ws.send(wrapper);
|
|
359
|
+
return;
|
|
254
360
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
361
|
+
if (data?.type === 'inputState' && data.id) {
|
|
362
|
+
const binary = (0, state_codec_1.encodeInputState)(data);
|
|
363
|
+
const wrapper = new Uint8Array(1 + binary.length);
|
|
364
|
+
wrapper[0] = 0x20;
|
|
365
|
+
wrapper.set(binary, 1);
|
|
366
|
+
ws.send(wrapper);
|
|
367
|
+
return;
|
|
261
368
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
369
|
+
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data } });
|
|
370
|
+
bytesOut += msg.length;
|
|
371
|
+
ws.send(msg);
|
|
372
|
+
},
|
|
373
|
+
sendSnapshot(snapshot, hash) {
|
|
374
|
+
if (connected && ws) {
|
|
375
|
+
const msg = JSON.stringify({ type: 'SEND_SNAPSHOT', payload: { roomId, snapshot, hash } });
|
|
376
|
+
bytesOut += msg.length;
|
|
377
|
+
ws.send(msg);
|
|
266
378
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
payload: { roomId, appId, snapshot: initialSnapshot }
|
|
274
|
-
});
|
|
275
|
-
bytesOut += createMsg.length;
|
|
276
|
-
ws.send(createMsg);
|
|
379
|
+
},
|
|
380
|
+
leaveRoom() {
|
|
381
|
+
if (connected && user && ws) {
|
|
382
|
+
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
|
|
383
|
+
bytesOut += msg.length;
|
|
384
|
+
ws.send(msg);
|
|
277
385
|
}
|
|
278
|
-
|
|
279
|
-
|
|
386
|
+
if (ws)
|
|
387
|
+
ws.close();
|
|
388
|
+
},
|
|
389
|
+
getClients() {
|
|
390
|
+
if (connected && ws && ws.readyState === 1) {
|
|
391
|
+
const msg = JSON.stringify({ type: 'GET_CLIENTS', payload: { roomId } });
|
|
392
|
+
bytesOut += msg.length;
|
|
393
|
+
ws.send(msg);
|
|
280
394
|
}
|
|
281
|
-
|
|
395
|
+
},
|
|
396
|
+
close() {
|
|
397
|
+
if (ws)
|
|
398
|
+
ws.close();
|
|
399
|
+
},
|
|
400
|
+
get connected() { return connected; },
|
|
401
|
+
get clientId() { return myClientId; },
|
|
402
|
+
get node() { return nodeUrl ? (nodeUrl.match(/:(\d+)/)?.[1] || nodeUrl) : null; },
|
|
403
|
+
get bandwidthIn() { return bandwidthIn; },
|
|
404
|
+
get bandwidthOut() { return bandwidthOut; },
|
|
405
|
+
get totalBytesIn() { return bytesIn; },
|
|
406
|
+
get totalBytesOut() { return bytesOut; },
|
|
407
|
+
get frame() { return currentFrame; },
|
|
408
|
+
getLatency() { return '0'; }
|
|
409
|
+
};
|
|
410
|
+
ws.onopen = () => {
|
|
411
|
+
bandwidthInterval = setInterval(() => {
|
|
412
|
+
bandwidthIn = bytesIn - lastBytesIn;
|
|
413
|
+
bandwidthOut = bytesOut - lastBytesOut;
|
|
414
|
+
lastBytesIn = bytesIn;
|
|
415
|
+
lastBytesOut = bytesOut;
|
|
416
|
+
}, 1000);
|
|
417
|
+
if (getStateHash) {
|
|
418
|
+
hashInterval = setInterval(() => {
|
|
419
|
+
if (!connected || !ws || ws.readyState !== 1)
|
|
420
|
+
return;
|
|
421
|
+
try {
|
|
422
|
+
const hash = getStateHash();
|
|
423
|
+
if (hash) {
|
|
424
|
+
const hashMsg = encodeSyncHash(roomId, hash, lastSyncSeq, lastSyncFrame);
|
|
425
|
+
bytesOut += hashMsg.byteLength;
|
|
426
|
+
ws.send(hashMsg);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
console.warn('[modd-network] Error getting state hash:', err);
|
|
431
|
+
}
|
|
432
|
+
}, 1000);
|
|
282
433
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
434
|
+
// Include user metadata - server will generate the join event
|
|
435
|
+
const joinMsg = JSON.stringify({ type: 'JOIN_ROOM', payload: { roomId, user } });
|
|
436
|
+
bytesOut += joinMsg.length;
|
|
437
|
+
ws.send(joinMsg);
|
|
438
|
+
};
|
|
439
|
+
ws.onerror = (e) => {
|
|
440
|
+
const errMsg = `Failed to connect to ${nodeUrl}: ${e.message || 'Unknown error'}`;
|
|
441
|
+
onError(errMsg);
|
|
442
|
+
if (!connected)
|
|
443
|
+
reject(new Error(errMsg));
|
|
444
|
+
};
|
|
445
|
+
ws.onclose = () => {
|
|
446
|
+
connected = false;
|
|
447
|
+
if (bandwidthInterval)
|
|
448
|
+
clearInterval(bandwidthInterval);
|
|
449
|
+
if (hashInterval)
|
|
450
|
+
clearInterval(hashInterval);
|
|
451
|
+
onDisconnect();
|
|
452
|
+
};
|
|
453
|
+
ws.onmessage = async (e) => {
|
|
454
|
+
let buffer;
|
|
455
|
+
try {
|
|
456
|
+
buffer = await toArrayBuffer(e.data);
|
|
292
457
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
inputs: msg.inputs || [],
|
|
297
|
-
frame: msg.frame
|
|
298
|
-
};
|
|
299
|
-
lastSyncFrame = msg.frame;
|
|
300
|
-
if (msg.inputs && msg.inputs.length > 0) {
|
|
301
|
-
const maxSeq = Math.max(...msg.inputs.map(i => i.sequenceNumber || 0));
|
|
302
|
-
if (maxSeq > lastSyncSeq)
|
|
303
|
-
lastSyncSeq = maxSeq;
|
|
304
|
-
}
|
|
305
|
-
break;
|
|
458
|
+
catch (err) {
|
|
459
|
+
console.warn('[modd-network] Failed to read message data:', err);
|
|
460
|
+
return;
|
|
306
461
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
462
|
+
bytesIn += buffer.byteLength;
|
|
463
|
+
const msg = decodeBinaryMessage(buffer);
|
|
464
|
+
console.log("decoded msg: ", msg);
|
|
465
|
+
if (!msg)
|
|
466
|
+
return;
|
|
467
|
+
switch (msg.type) {
|
|
468
|
+
case 'TICK': {
|
|
469
|
+
if (!connected) {
|
|
470
|
+
pendingTicks.push(msg);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
currentFrame = msg.frame;
|
|
474
|
+
lastSyncFrame = msg.frame;
|
|
475
|
+
if (msg.events && msg.events.length > 0) {
|
|
476
|
+
const maxSeq = Math.max(...msg.events.map(e => e.seq || 0));
|
|
477
|
+
if (maxSeq > lastSyncSeq)
|
|
478
|
+
lastSyncSeq = maxSeq;
|
|
479
|
+
}
|
|
480
|
+
const newEvents = msg.events.filter(e => !deliveredSeqs.has(e.seq));
|
|
481
|
+
newEvents.forEach(e => deliveredSeqs.add(e.seq));
|
|
482
|
+
// Auto-register clientIds from join/leave events
|
|
483
|
+
if (newEvents.length > 0) {
|
|
484
|
+
processEventsForClientIds(newEvents);
|
|
485
|
+
}
|
|
486
|
+
if (onTick) {
|
|
487
|
+
onTick(msg.frame, newEvents);
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
313
490
|
}
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
491
|
+
case 'ERROR': {
|
|
492
|
+
if (msg.message === 'Room not found') {
|
|
493
|
+
// Include user metadata - server will generate the join event
|
|
494
|
+
const createMsg = JSON.stringify({
|
|
495
|
+
type: 'CREATE_ROOM',
|
|
496
|
+
payload: { roomId, snapshot: initialSnapshot, user }
|
|
497
|
+
});
|
|
498
|
+
bytesOut += createMsg.length;
|
|
499
|
+
ws.send(createMsg);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
onError(msg.message);
|
|
503
|
+
reject(new Error(msg.message));
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case 'ROOM_CREATED': {
|
|
508
|
+
connected = true;
|
|
509
|
+
currentFrame = 0;
|
|
510
|
+
// Server tells us our clientId - register it for hash lookup
|
|
511
|
+
if (msg.clientId) {
|
|
512
|
+
myClientId = msg.clientId;
|
|
513
|
+
registerClientId(msg.clientId);
|
|
514
|
+
console.log(`[modd-network] Assigned clientId: ${msg.clientId}`);
|
|
515
|
+
}
|
|
516
|
+
// Server generates join event - don't send one from client
|
|
517
|
+
onConnect(initialSnapshot, [], 0, nodeUrl);
|
|
518
|
+
if (connectionResolve)
|
|
519
|
+
connectionResolve(instance);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'INITIAL_STATE': {
|
|
523
|
+
console.log('[modd-network] Received INITIAL_STATE, connecting...');
|
|
524
|
+
const { snapshot, events, frame } = msg;
|
|
525
|
+
currentFrame = frame;
|
|
526
|
+
lastSyncFrame = frame;
|
|
527
|
+
if (events && events.length > 0) {
|
|
528
|
+
const maxSeq = Math.max(...events.map(e => e.seq || 0));
|
|
529
|
+
if (maxSeq > lastSyncSeq)
|
|
530
|
+
lastSyncSeq = maxSeq;
|
|
531
|
+
}
|
|
532
|
+
// Auto-register clientIds from join events in history
|
|
533
|
+
if (events && events.length > 0) {
|
|
534
|
+
processEventsForClientIds(events);
|
|
535
|
+
}
|
|
536
|
+
// Treat INITIAL_STATE as proof of connection and readiness
|
|
537
|
+
connected = true;
|
|
538
|
+
onConnect(snapshot, events || [], frame, nodeUrl);
|
|
539
|
+
// Process pending ticks
|
|
540
|
+
if (pendingTicks.length > 0) {
|
|
541
|
+
pendingTicks.sort((a, b) => a.frame - b.frame);
|
|
542
|
+
for (const tickMsg of pendingTicks) {
|
|
543
|
+
const tickEvents = tickMsg.events.filter(e => !deliveredSeqs.has(e.seq));
|
|
544
|
+
tickEvents.forEach(e => deliveredSeqs.add(e.seq));
|
|
545
|
+
// Auto-register clientIds from pending ticks too
|
|
546
|
+
if (tickEvents.length > 0) {
|
|
547
|
+
processEventsForClientIds(tickEvents);
|
|
548
|
+
}
|
|
549
|
+
if (onTick && (tickEvents.length > 0 || tickMsg.frame > frame)) {
|
|
550
|
+
onTick(tickMsg.frame, tickEvents);
|
|
338
551
|
}
|
|
339
|
-
pendingTicks = [];
|
|
340
552
|
}
|
|
553
|
+
pendingTicks = [];
|
|
341
554
|
}
|
|
342
|
-
|
|
343
|
-
|
|
555
|
+
if (connectionResolve)
|
|
556
|
+
connectionResolve(instance);
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 'ROOM_JOINED': {
|
|
560
|
+
// Server tells us our clientId - register it for hash lookup
|
|
561
|
+
connected = true;
|
|
562
|
+
if (msg.clientId) {
|
|
563
|
+
myClientId = msg.clientId;
|
|
564
|
+
registerClientId(msg.clientId);
|
|
565
|
+
console.log(`[modd-network] Assigned clientId: ${msg.clientId}`);
|
|
344
566
|
}
|
|
345
|
-
|
|
346
|
-
if (initialStateReceived) {
|
|
347
|
-
processInitialState();
|
|
567
|
+
break;
|
|
348
568
|
}
|
|
349
|
-
|
|
350
|
-
|
|
569
|
+
case 'SNAPSHOT_UPDATE': {
|
|
570
|
+
if (options.onSnapshot)
|
|
571
|
+
options.onSnapshot(msg.snapshot, msg.snapshotHash);
|
|
572
|
+
break;
|
|
351
573
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
574
|
+
case 'ROOM_LEFT': {
|
|
575
|
+
console.log(`[modd-network] Left room ${msg.roomId}`);
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case 'CLIENT_LIST_UPDATE': {
|
|
579
|
+
if (options.onClientsUpdate)
|
|
580
|
+
options.onClientsUpdate(msg.clients);
|
|
581
|
+
break;
|
|
357
582
|
}
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
case 'ROOM_LEFT': {
|
|
361
|
-
console.log(`[modd-network] Left room ${msg.roomId}`);
|
|
362
|
-
break;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
};
|
|
366
|
-
// Auto leave on page unload
|
|
367
|
-
if (user && typeof window !== 'undefined') {
|
|
368
|
-
window.addEventListener('beforeunload', () => {
|
|
369
|
-
if (connected && ws) {
|
|
370
|
-
const leaveMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
|
|
371
|
-
bytesOut += leaveMsg.length;
|
|
372
|
-
ws.send(leaveMsg);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
return {
|
|
377
|
-
send(data) {
|
|
378
|
-
if (connected && ws) {
|
|
379
|
-
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data } });
|
|
380
|
-
bytesOut += msg.length;
|
|
381
|
-
ws.send(msg);
|
|
382
|
-
}
|
|
383
|
-
},
|
|
384
|
-
sendSnapshot(snapshot, hash) {
|
|
385
|
-
if (connected && ws) {
|
|
386
|
-
const msg = JSON.stringify({ type: 'SEND_SNAPSHOT', payload: { roomId, snapshot, hash } });
|
|
387
|
-
bytesOut += msg.length;
|
|
388
|
-
ws.send(msg);
|
|
389
|
-
}
|
|
390
|
-
},
|
|
391
|
-
leaveRoom() {
|
|
392
|
-
if (connected && user && ws) {
|
|
393
|
-
const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
|
|
394
|
-
bytesOut += msg.length;
|
|
395
|
-
ws.send(msg);
|
|
396
583
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
},
|
|
400
|
-
close() {
|
|
401
|
-
if (ws)
|
|
402
|
-
ws.close();
|
|
403
|
-
},
|
|
404
|
-
get connected() {
|
|
405
|
-
return connected;
|
|
406
|
-
},
|
|
407
|
-
get node() {
|
|
408
|
-
return nodeUrl ? (nodeUrl.match(/:(\d+)/)?.[1] || nodeUrl) : null;
|
|
409
|
-
},
|
|
410
|
-
get bandwidthIn() {
|
|
411
|
-
return bandwidthIn;
|
|
412
|
-
},
|
|
413
|
-
get bandwidthOut() {
|
|
414
|
-
return bandwidthOut;
|
|
415
|
-
},
|
|
416
|
-
get totalBytesIn() {
|
|
417
|
-
return bytesIn;
|
|
418
|
-
},
|
|
419
|
-
get totalBytesOut() {
|
|
420
|
-
return bytesOut;
|
|
421
|
-
},
|
|
422
|
-
get frame() {
|
|
423
|
-
return currentFrame;
|
|
424
|
-
}
|
|
425
|
-
};
|
|
584
|
+
};
|
|
585
|
+
});
|
|
426
586
|
}
|
|
427
587
|
catch (err) {
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
send() { },
|
|
431
|
-
sendSnapshot() { },
|
|
432
|
-
leaveRoom() { },
|
|
433
|
-
close() { },
|
|
434
|
-
get connected() { return false; },
|
|
435
|
-
get node() { return null; },
|
|
436
|
-
get bandwidthIn() { return 0; },
|
|
437
|
-
get bandwidthOut() { return 0; },
|
|
438
|
-
get totalBytesIn() { return 0; },
|
|
439
|
-
get totalBytesOut() { return 0; },
|
|
440
|
-
get frame() { return 0; }
|
|
441
|
-
};
|
|
588
|
+
throw new Error(`Failed to get node assignment: ${err.message}`);
|
|
442
589
|
}
|
|
443
590
|
}
|
|
444
|
-
// Legacy alias for backwards compatibility
|
|
445
591
|
exports.modd = connect;
|
|
446
|
-
// Browser global fallback
|
|
447
592
|
if (typeof window !== 'undefined') {
|
|
448
593
|
window.moddNetwork = { connect, modd: exports.modd };
|
|
449
594
|
}
|