rollback-netcode 0.0.4
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 +21 -0
- package/README.md +140 -0
- package/dist/debug.d.ts +29 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +56 -0
- package/dist/debug.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/encoding.d.ts +80 -0
- package/dist/protocol/encoding.d.ts.map +1 -0
- package/dist/protocol/encoding.js +992 -0
- package/dist/protocol/encoding.js.map +1 -0
- package/dist/protocol/messages.d.ts +271 -0
- package/dist/protocol/messages.d.ts.map +1 -0
- package/dist/protocol/messages.js +114 -0
- package/dist/protocol/messages.js.map +1 -0
- package/dist/rollback/engine.d.ts +261 -0
- package/dist/rollback/engine.d.ts.map +1 -0
- package/dist/rollback/engine.js +543 -0
- package/dist/rollback/engine.js.map +1 -0
- package/dist/rollback/input-buffer.d.ts +225 -0
- package/dist/rollback/input-buffer.d.ts.map +1 -0
- package/dist/rollback/input-buffer.js +483 -0
- package/dist/rollback/input-buffer.js.map +1 -0
- package/dist/rollback/snapshot-buffer.d.ts +119 -0
- package/dist/rollback/snapshot-buffer.d.ts.map +1 -0
- package/dist/rollback/snapshot-buffer.js +256 -0
- package/dist/rollback/snapshot-buffer.js.map +1 -0
- package/dist/session/desync-manager.d.ts +106 -0
- package/dist/session/desync-manager.d.ts.map +1 -0
- package/dist/session/desync-manager.js +136 -0
- package/dist/session/desync-manager.js.map +1 -0
- package/dist/session/lag-monitor.d.ts +69 -0
- package/dist/session/lag-monitor.d.ts.map +1 -0
- package/dist/session/lag-monitor.js +74 -0
- package/dist/session/lag-monitor.js.map +1 -0
- package/dist/session/message-builders.d.ts +86 -0
- package/dist/session/message-builders.d.ts.map +1 -0
- package/dist/session/message-builders.js +199 -0
- package/dist/session/message-builders.js.map +1 -0
- package/dist/session/message-router.d.ts +61 -0
- package/dist/session/message-router.d.ts.map +1 -0
- package/dist/session/message-router.js +105 -0
- package/dist/session/message-router.js.map +1 -0
- package/dist/session/player-manager.d.ts +100 -0
- package/dist/session/player-manager.d.ts.map +1 -0
- package/dist/session/player-manager.js +160 -0
- package/dist/session/player-manager.js.map +1 -0
- package/dist/session/session.d.ts +379 -0
- package/dist/session/session.d.ts.map +1 -0
- package/dist/session/session.js +1294 -0
- package/dist/session/session.js.map +1 -0
- package/dist/session/topology.d.ts +66 -0
- package/dist/session/topology.d.ts.map +1 -0
- package/dist/session/topology.js +72 -0
- package/dist/session/topology.js.map +1 -0
- package/dist/transport/adapter.d.ts +99 -0
- package/dist/transport/adapter.d.ts.map +1 -0
- package/dist/transport/adapter.js +8 -0
- package/dist/transport/adapter.js.map +1 -0
- package/dist/transport/local.d.ts +192 -0
- package/dist/transport/local.d.ts.map +1 -0
- package/dist/transport/local.js +435 -0
- package/dist/transport/local.js.map +1 -0
- package/dist/transport/transforming.d.ts +177 -0
- package/dist/transport/transforming.d.ts.map +1 -0
- package/dist/transport/transforming.js +407 -0
- package/dist/transport/transforming.js.map +1 -0
- package/dist/transport/webrtc.d.ts +285 -0
- package/dist/transport/webrtc.d.ts.map +1 -0
- package/dist/transport/webrtc.js +734 -0
- package/dist/transport/webrtc.js.map +1 -0
- package/dist/types.d.ts +394 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +256 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +59 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +93 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary encoding and decoding utilities for protocol messages.
|
|
3
|
+
*
|
|
4
|
+
* Message format:
|
|
5
|
+
* - Byte 0: Message type
|
|
6
|
+
* - Bytes 1+: Message payload (varies by type)
|
|
7
|
+
*/
|
|
8
|
+
import { PauseReason, PlayerRole, asPlayerId, asTick, } from "../types.js";
|
|
9
|
+
import { MessageType, } from "./messages.js";
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Decode Error
|
|
12
|
+
// =============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Error thrown when decoding a message fails.
|
|
15
|
+
*/
|
|
16
|
+
export class DecodeError extends Error {
|
|
17
|
+
messageType;
|
|
18
|
+
offset;
|
|
19
|
+
expected;
|
|
20
|
+
actual;
|
|
21
|
+
constructor(message, messageType, offset, expected, actual) {
|
|
22
|
+
super(`${message} at offset ${offset}: expected ${expected} bytes, got ${actual}${messageType !== undefined ? ` (message type: ${messageType})` : ""}`);
|
|
23
|
+
this.messageType = messageType;
|
|
24
|
+
this.offset = offset;
|
|
25
|
+
this.expected = expected;
|
|
26
|
+
this.actual = actual;
|
|
27
|
+
this.name = "DecodeError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Error thrown when encoding a message fails due to invalid values.
|
|
32
|
+
*/
|
|
33
|
+
export class EncodeError extends Error {
|
|
34
|
+
field;
|
|
35
|
+
maxValue;
|
|
36
|
+
actualValue;
|
|
37
|
+
constructor(message, field, maxValue, actualValue) {
|
|
38
|
+
super(`${message}: ${field} must be <= ${maxValue}, got ${actualValue}`);
|
|
39
|
+
this.field = field;
|
|
40
|
+
this.maxValue = maxValue;
|
|
41
|
+
this.actualValue = actualValue;
|
|
42
|
+
this.name = "EncodeError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensure the buffer has enough bytes to read.
|
|
47
|
+
* @throws DecodeError if insufficient bytes
|
|
48
|
+
*/
|
|
49
|
+
function ensureBytes(view, offset, needed, messageType) {
|
|
50
|
+
const available = view.byteLength - offset;
|
|
51
|
+
if (available < needed) {
|
|
52
|
+
throw new DecodeError("Insufficient bytes in buffer", messageType, offset, needed, available);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Text Encoder/Decoder (shared instances)
|
|
57
|
+
// =============================================================================
|
|
58
|
+
const textEncoder = new TextEncoder();
|
|
59
|
+
const textDecoder = new TextDecoder();
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Protocol Limits
|
|
62
|
+
// =============================================================================
|
|
63
|
+
/**
|
|
64
|
+
* Maximum size of a single input frame in bytes.
|
|
65
|
+
* This prevents malicious peers from sending excessively large inputs.
|
|
66
|
+
* 1KB is more than sufficient for any reasonable game input.
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_INPUT_SIZE_PER_FRAME = 1024;
|
|
69
|
+
/**
|
|
70
|
+
* Maximum total size of an InputMessage in bytes.
|
|
71
|
+
* This prevents memory exhaustion from malicious messages.
|
|
72
|
+
* 64KB allows for 255 frames × ~250 bytes each with overhead.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_INPUT_MESSAGE_SIZE = 65536;
|
|
75
|
+
/**
|
|
76
|
+
* Default protocol limits.
|
|
77
|
+
* These values are suitable for most games and provide DoS protection.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_PROTOCOL_LIMITS = {
|
|
80
|
+
maxStringLength: 1024,
|
|
81
|
+
maxPlayerCount: 256,
|
|
82
|
+
maxStateSize: 1_000_000, // 1MB
|
|
83
|
+
};
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Enum Encoding Helpers
|
|
86
|
+
// =============================================================================
|
|
87
|
+
/** Encode PlayerRole as a single byte (enum is already numeric) */
|
|
88
|
+
function encodePlayerRole(role) {
|
|
89
|
+
return role;
|
|
90
|
+
}
|
|
91
|
+
/** Decode PlayerRole from a single byte */
|
|
92
|
+
function decodePlayerRole(value) {
|
|
93
|
+
if (value === PlayerRole.Spectator) {
|
|
94
|
+
return PlayerRole.Spectator;
|
|
95
|
+
}
|
|
96
|
+
return PlayerRole.Player;
|
|
97
|
+
}
|
|
98
|
+
/** Encode PauseReason as a single byte (enum is already numeric) */
|
|
99
|
+
function encodePauseReason(reason) {
|
|
100
|
+
return reason;
|
|
101
|
+
}
|
|
102
|
+
/** Decode PauseReason from a single byte */
|
|
103
|
+
function decodePauseReason(value) {
|
|
104
|
+
if (value === PauseReason.PlayerDisconnect) {
|
|
105
|
+
return PauseReason.PlayerDisconnect;
|
|
106
|
+
}
|
|
107
|
+
if (value === PauseReason.ExcessiveLag) {
|
|
108
|
+
return PauseReason.ExcessiveLag;
|
|
109
|
+
}
|
|
110
|
+
return PauseReason.PlayerRequest;
|
|
111
|
+
}
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Encoding Helpers
|
|
114
|
+
// =============================================================================
|
|
115
|
+
function writeString(view, offset, str) {
|
|
116
|
+
const bytes = textEncoder.encode(str);
|
|
117
|
+
view.setUint16(offset, bytes.length);
|
|
118
|
+
const uint8View = new Uint8Array(view.buffer, view.byteOffset + offset + 2);
|
|
119
|
+
uint8View.set(bytes);
|
|
120
|
+
return 2 + bytes.length;
|
|
121
|
+
}
|
|
122
|
+
function readString(view, offset, messageType, maxLength) {
|
|
123
|
+
ensureBytes(view, offset, 2, messageType);
|
|
124
|
+
const length = view.getUint16(offset);
|
|
125
|
+
// Validate string length against limit if provided
|
|
126
|
+
if (maxLength !== undefined && length > maxLength) {
|
|
127
|
+
throw new DecodeError(`String length exceeds maximum of ${maxLength} bytes`, messageType, offset, maxLength, length);
|
|
128
|
+
}
|
|
129
|
+
ensureBytes(view, offset + 2, length, messageType);
|
|
130
|
+
const bytes = new Uint8Array(view.buffer, view.byteOffset + offset + 2, length);
|
|
131
|
+
return [textDecoder.decode(bytes), 2 + length];
|
|
132
|
+
}
|
|
133
|
+
function writeBytes(view, offset, data) {
|
|
134
|
+
view.setUint32(offset, data.length);
|
|
135
|
+
const uint8View = new Uint8Array(view.buffer, view.byteOffset + offset + 4);
|
|
136
|
+
uint8View.set(data);
|
|
137
|
+
return 4 + data.length;
|
|
138
|
+
}
|
|
139
|
+
function readBytes(view, offset, messageType, maxSize) {
|
|
140
|
+
ensureBytes(view, offset, 4, messageType);
|
|
141
|
+
const length = view.getUint32(offset);
|
|
142
|
+
// Validate byte array size against limit if provided
|
|
143
|
+
if (maxSize !== undefined && length > maxSize) {
|
|
144
|
+
throw new DecodeError(`Byte array size exceeds maximum of ${maxSize} bytes`, messageType, offset, maxSize, length);
|
|
145
|
+
}
|
|
146
|
+
ensureBytes(view, offset + 4, length, messageType);
|
|
147
|
+
const data = new Uint8Array(length);
|
|
148
|
+
data.set(new Uint8Array(view.buffer, view.byteOffset + offset + 4, length));
|
|
149
|
+
return [data, 4 + length];
|
|
150
|
+
}
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Message Encoding
|
|
153
|
+
// =============================================================================
|
|
154
|
+
/**
|
|
155
|
+
* Encode a message to a binary format.
|
|
156
|
+
*/
|
|
157
|
+
export function encodeMessage(message) {
|
|
158
|
+
switch (message.type) {
|
|
159
|
+
case MessageType.Input:
|
|
160
|
+
return encodeInputMessage(message);
|
|
161
|
+
case MessageType.InputAck:
|
|
162
|
+
return encodeInputAckMessage(message);
|
|
163
|
+
case MessageType.Hash:
|
|
164
|
+
return encodeHashMessage(message);
|
|
165
|
+
case MessageType.Sync:
|
|
166
|
+
return encodeSyncMessage(message);
|
|
167
|
+
case MessageType.SyncRequest:
|
|
168
|
+
return encodeSyncRequestMessage(message);
|
|
169
|
+
case MessageType.Pause:
|
|
170
|
+
return encodePauseMessage(message);
|
|
171
|
+
case MessageType.Resume:
|
|
172
|
+
return encodeResumeMessage(message);
|
|
173
|
+
case MessageType.JoinRequest:
|
|
174
|
+
return encodeJoinRequestMessage(message);
|
|
175
|
+
case MessageType.JoinAccept:
|
|
176
|
+
return encodeJoinAcceptMessage(message);
|
|
177
|
+
case MessageType.JoinReject:
|
|
178
|
+
return encodeJoinRejectMessage(message);
|
|
179
|
+
case MessageType.StateSync:
|
|
180
|
+
return encodeStateSyncMessage(message);
|
|
181
|
+
case MessageType.PlayerJoined:
|
|
182
|
+
return encodePlayerJoinedMessage(message);
|
|
183
|
+
case MessageType.PlayerLeft:
|
|
184
|
+
return encodePlayerLeftMessage(message);
|
|
185
|
+
case MessageType.Ping:
|
|
186
|
+
return encodePingMessage(message);
|
|
187
|
+
case MessageType.Pong:
|
|
188
|
+
return encodePongMessage(message);
|
|
189
|
+
case MessageType.LagReport:
|
|
190
|
+
return encodeLagReportMessage(message);
|
|
191
|
+
case MessageType.DisconnectReport:
|
|
192
|
+
return encodeDisconnectReportMessage(message);
|
|
193
|
+
case MessageType.ResumeCountdown:
|
|
194
|
+
return encodeResumeCountdownMessage(message);
|
|
195
|
+
case MessageType.DropPlayer:
|
|
196
|
+
return encodeDropPlayerMessage(message);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Decode a binary message.
|
|
201
|
+
* @param data - The binary data to decode
|
|
202
|
+
* @param limits - Optional protocol limits for DoS protection (defaults to DEFAULT_PROTOCOL_LIMITS)
|
|
203
|
+
* @throws DecodeError if the message is malformed, truncated, or exceeds limits
|
|
204
|
+
*/
|
|
205
|
+
export function decodeMessage(data, limits = DEFAULT_PROTOCOL_LIMITS) {
|
|
206
|
+
if (data.length === 0) {
|
|
207
|
+
throw new DecodeError("Empty message", undefined, 0, 1, 0);
|
|
208
|
+
}
|
|
209
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
210
|
+
const type = view.getUint8(0);
|
|
211
|
+
switch (type) {
|
|
212
|
+
case MessageType.Input:
|
|
213
|
+
return decodeInputMessage(view, limits);
|
|
214
|
+
case MessageType.InputAck:
|
|
215
|
+
return decodeInputAckMessage(view, limits);
|
|
216
|
+
case MessageType.Hash:
|
|
217
|
+
return decodeHashMessage(view, limits);
|
|
218
|
+
case MessageType.Sync:
|
|
219
|
+
return decodeSyncMessage(view, limits);
|
|
220
|
+
case MessageType.SyncRequest:
|
|
221
|
+
return decodeSyncRequestMessage(view, limits);
|
|
222
|
+
case MessageType.Pause:
|
|
223
|
+
return decodePauseMessage(view, limits);
|
|
224
|
+
case MessageType.Resume:
|
|
225
|
+
return decodeResumeMessage(view, limits);
|
|
226
|
+
case MessageType.JoinRequest:
|
|
227
|
+
return decodeJoinRequestMessage(view, limits);
|
|
228
|
+
case MessageType.JoinAccept:
|
|
229
|
+
return decodeJoinAcceptMessage(view, limits);
|
|
230
|
+
case MessageType.JoinReject:
|
|
231
|
+
return decodeJoinRejectMessage(view, limits);
|
|
232
|
+
case MessageType.StateSync:
|
|
233
|
+
return decodeStateSyncMessage(view, limits);
|
|
234
|
+
case MessageType.PlayerJoined:
|
|
235
|
+
return decodePlayerJoinedMessage(view, limits);
|
|
236
|
+
case MessageType.PlayerLeft:
|
|
237
|
+
return decodePlayerLeftMessage(view, limits);
|
|
238
|
+
case MessageType.Ping:
|
|
239
|
+
return decodePingMessage(view);
|
|
240
|
+
case MessageType.Pong:
|
|
241
|
+
return decodePongMessage(view);
|
|
242
|
+
case MessageType.LagReport:
|
|
243
|
+
return decodeLagReportMessage(view, limits);
|
|
244
|
+
case MessageType.DisconnectReport:
|
|
245
|
+
return decodeDisconnectReportMessage(view, limits);
|
|
246
|
+
case MessageType.ResumeCountdown:
|
|
247
|
+
return decodeResumeCountdownMessage(view);
|
|
248
|
+
case MessageType.DropPlayer:
|
|
249
|
+
return decodeDropPlayerMessage(view, limits);
|
|
250
|
+
default:
|
|
251
|
+
throw new DecodeError(`Unknown message type: ${type}`, type, 0, 0, data.length);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// =============================================================================
|
|
255
|
+
// Input Message
|
|
256
|
+
// Format: type(1) + playerId(2+N) + inputCount(1) + [tick(4) + inputLen(2) + input(N)]*
|
|
257
|
+
// =============================================================================
|
|
258
|
+
function encodeInputMessage(msg) {
|
|
259
|
+
// Validate input count fits in Uint8
|
|
260
|
+
if (msg.inputs.length > 255) {
|
|
261
|
+
throw new EncodeError("Input count exceeds maximum", "inputs.length", 255, msg.inputs.length);
|
|
262
|
+
}
|
|
263
|
+
// Validate individual input sizes
|
|
264
|
+
for (let i = 0; i < msg.inputs.length; i++) {
|
|
265
|
+
const entry = msg.inputs[i];
|
|
266
|
+
if (entry && entry.input.length > MAX_INPUT_SIZE_PER_FRAME) {
|
|
267
|
+
throw new EncodeError("Individual input size exceeds maximum", `inputs[${i}].input.length`, MAX_INPUT_SIZE_PER_FRAME, entry.input.length);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
271
|
+
let totalSize = 1 + // type
|
|
272
|
+
2 +
|
|
273
|
+
playerIdBytes.length + // playerId
|
|
274
|
+
1; // input count
|
|
275
|
+
for (const entry of msg.inputs) {
|
|
276
|
+
totalSize += 4 + 2 + entry.input.length; // tick + inputLen + input
|
|
277
|
+
}
|
|
278
|
+
// Validate total message size
|
|
279
|
+
if (totalSize > MAX_INPUT_MESSAGE_SIZE) {
|
|
280
|
+
throw new EncodeError("Total input message size exceeds maximum", "totalSize", MAX_INPUT_MESSAGE_SIZE, totalSize);
|
|
281
|
+
}
|
|
282
|
+
const buffer = new Uint8Array(totalSize);
|
|
283
|
+
const view = new DataView(buffer.buffer);
|
|
284
|
+
let offset = 0;
|
|
285
|
+
view.setUint8(offset++, MessageType.Input);
|
|
286
|
+
offset += writeString(view, offset, msg.playerId);
|
|
287
|
+
view.setUint8(offset++, msg.inputs.length);
|
|
288
|
+
for (const entry of msg.inputs) {
|
|
289
|
+
view.setInt32(offset, entry.tick);
|
|
290
|
+
offset += 4;
|
|
291
|
+
view.setUint16(offset, entry.input.length);
|
|
292
|
+
offset += 2;
|
|
293
|
+
buffer.set(entry.input, offset);
|
|
294
|
+
offset += entry.input.length;
|
|
295
|
+
}
|
|
296
|
+
return buffer;
|
|
297
|
+
}
|
|
298
|
+
function decodeInputMessage(view, limits) {
|
|
299
|
+
const msgType = MessageType.Input;
|
|
300
|
+
// Validate total message size first to prevent memory exhaustion
|
|
301
|
+
if (view.byteLength > MAX_INPUT_MESSAGE_SIZE) {
|
|
302
|
+
throw new DecodeError(`Input message exceeds maximum size of ${MAX_INPUT_MESSAGE_SIZE} bytes`, msgType, 0, MAX_INPUT_MESSAGE_SIZE, view.byteLength);
|
|
303
|
+
}
|
|
304
|
+
let offset = 1;
|
|
305
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
306
|
+
offset += playerIdLen;
|
|
307
|
+
ensureBytes(view, offset, 1, msgType);
|
|
308
|
+
const inputCount = view.getUint8(offset++);
|
|
309
|
+
const inputs = [];
|
|
310
|
+
for (let i = 0; i < inputCount; i++) {
|
|
311
|
+
ensureBytes(view, offset, 4, msgType);
|
|
312
|
+
const tick = asTick(view.getInt32(offset));
|
|
313
|
+
offset += 4;
|
|
314
|
+
ensureBytes(view, offset, 2, msgType);
|
|
315
|
+
const inputLen = view.getUint16(offset);
|
|
316
|
+
offset += 2;
|
|
317
|
+
// Validate individual input size before allocating
|
|
318
|
+
if (inputLen > MAX_INPUT_SIZE_PER_FRAME) {
|
|
319
|
+
throw new DecodeError(`Input frame ${i} exceeds maximum size of ${MAX_INPUT_SIZE_PER_FRAME} bytes`, msgType, offset - 2, MAX_INPUT_SIZE_PER_FRAME, inputLen);
|
|
320
|
+
}
|
|
321
|
+
ensureBytes(view, offset, inputLen, msgType);
|
|
322
|
+
const input = new Uint8Array(inputLen);
|
|
323
|
+
input.set(new Uint8Array(view.buffer, view.byteOffset + offset, inputLen));
|
|
324
|
+
offset += inputLen;
|
|
325
|
+
inputs.push({ tick, input });
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
type: MessageType.Input,
|
|
329
|
+
playerId: asPlayerId(playerId),
|
|
330
|
+
inputs,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// =============================================================================
|
|
334
|
+
// InputAck Message
|
|
335
|
+
// Format: type(1) + playerId(2+N) + ackedTick(4)
|
|
336
|
+
// =============================================================================
|
|
337
|
+
function encodeInputAckMessage(msg) {
|
|
338
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
339
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4);
|
|
340
|
+
const view = new DataView(buffer.buffer);
|
|
341
|
+
let offset = 0;
|
|
342
|
+
view.setUint8(offset++, MessageType.InputAck);
|
|
343
|
+
offset += writeString(view, offset, msg.playerId);
|
|
344
|
+
view.setInt32(offset, msg.ackedTick);
|
|
345
|
+
return buffer;
|
|
346
|
+
}
|
|
347
|
+
function decodeInputAckMessage(view, limits) {
|
|
348
|
+
const msgType = MessageType.InputAck;
|
|
349
|
+
let offset = 1;
|
|
350
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
351
|
+
offset += playerIdLen;
|
|
352
|
+
ensureBytes(view, offset, 4, msgType);
|
|
353
|
+
const ackedTick = asTick(view.getInt32(offset));
|
|
354
|
+
return {
|
|
355
|
+
type: MessageType.InputAck,
|
|
356
|
+
playerId: asPlayerId(playerId),
|
|
357
|
+
ackedTick,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// =============================================================================
|
|
361
|
+
// Hash Message
|
|
362
|
+
// Format: type(1) + playerId(2+N) + tick(4) + hash(4)
|
|
363
|
+
// =============================================================================
|
|
364
|
+
function encodeHashMessage(msg) {
|
|
365
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
366
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4 + 4);
|
|
367
|
+
const view = new DataView(buffer.buffer);
|
|
368
|
+
let offset = 0;
|
|
369
|
+
view.setUint8(offset++, MessageType.Hash);
|
|
370
|
+
offset += writeString(view, offset, msg.playerId);
|
|
371
|
+
view.setInt32(offset, msg.tick);
|
|
372
|
+
offset += 4;
|
|
373
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
374
|
+
view.setUint32(offset, msg.hash);
|
|
375
|
+
return buffer;
|
|
376
|
+
}
|
|
377
|
+
function decodeHashMessage(view, limits) {
|
|
378
|
+
const msgType = MessageType.Hash;
|
|
379
|
+
let offset = 1;
|
|
380
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
381
|
+
offset += playerIdLen;
|
|
382
|
+
ensureBytes(view, offset, 4, msgType);
|
|
383
|
+
const tick = asTick(view.getInt32(offset));
|
|
384
|
+
offset += 4;
|
|
385
|
+
ensureBytes(view, offset, 4, msgType);
|
|
386
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
387
|
+
const hash = view.getUint32(offset);
|
|
388
|
+
return {
|
|
389
|
+
type: MessageType.Hash,
|
|
390
|
+
playerId: asPlayerId(playerId),
|
|
391
|
+
tick,
|
|
392
|
+
hash,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// =============================================================================
|
|
396
|
+
// Sync Message
|
|
397
|
+
// Format: type(1) + tick(4) + hash(4) + state(4+N) + playerTimeline(...)
|
|
398
|
+
// =============================================================================
|
|
399
|
+
function encodeSyncMessage(msg) {
|
|
400
|
+
// Calculate size for player timeline
|
|
401
|
+
let timelineSize = 2; // player count
|
|
402
|
+
for (const entry of msg.playerTimeline) {
|
|
403
|
+
const idBytes = textEncoder.encode(entry.playerId);
|
|
404
|
+
timelineSize +=
|
|
405
|
+
2 + idBytes.length + 4 + 1 + (entry.leaveTick !== null ? 4 : 0);
|
|
406
|
+
}
|
|
407
|
+
const buffer = new Uint8Array(1 + 4 + 4 + 4 + msg.state.length + timelineSize);
|
|
408
|
+
const view = new DataView(buffer.buffer);
|
|
409
|
+
let offset = 0;
|
|
410
|
+
view.setUint8(offset++, MessageType.Sync);
|
|
411
|
+
view.setInt32(offset, msg.tick);
|
|
412
|
+
offset += 4;
|
|
413
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
414
|
+
view.setUint32(offset, msg.hash);
|
|
415
|
+
offset += 4;
|
|
416
|
+
offset += writeBytes(view, offset, msg.state);
|
|
417
|
+
// Write player timeline
|
|
418
|
+
view.setUint16(offset, msg.playerTimeline.length);
|
|
419
|
+
offset += 2;
|
|
420
|
+
for (const entry of msg.playerTimeline) {
|
|
421
|
+
offset += writeString(view, offset, entry.playerId);
|
|
422
|
+
view.setInt32(offset, entry.joinTick);
|
|
423
|
+
offset += 4;
|
|
424
|
+
if (entry.leaveTick !== null) {
|
|
425
|
+
view.setUint8(offset++, 1);
|
|
426
|
+
view.setInt32(offset, entry.leaveTick);
|
|
427
|
+
offset += 4;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
view.setUint8(offset++, 0);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return buffer;
|
|
434
|
+
}
|
|
435
|
+
function decodeSyncMessage(view, limits) {
|
|
436
|
+
const msgType = MessageType.Sync;
|
|
437
|
+
let offset = 1;
|
|
438
|
+
ensureBytes(view, offset, 4, msgType);
|
|
439
|
+
const tick = asTick(view.getInt32(offset));
|
|
440
|
+
offset += 4;
|
|
441
|
+
ensureBytes(view, offset, 4, msgType);
|
|
442
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
443
|
+
const hash = view.getUint32(offset);
|
|
444
|
+
offset += 4;
|
|
445
|
+
const [state, stateLen] = readBytes(view, offset, msgType, limits.maxStateSize);
|
|
446
|
+
offset += stateLen;
|
|
447
|
+
ensureBytes(view, offset, 2, msgType);
|
|
448
|
+
const playerCount = view.getUint16(offset);
|
|
449
|
+
offset += 2;
|
|
450
|
+
// Validate player count against limit
|
|
451
|
+
if (playerCount > limits.maxPlayerCount) {
|
|
452
|
+
throw new DecodeError(`Player count exceeds maximum of ${limits.maxPlayerCount}`, msgType, offset - 2, limits.maxPlayerCount, playerCount);
|
|
453
|
+
}
|
|
454
|
+
const playerTimeline = [];
|
|
455
|
+
for (let i = 0; i < playerCount; i++) {
|
|
456
|
+
const [playerId, idLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
457
|
+
offset += idLen;
|
|
458
|
+
ensureBytes(view, offset, 4, msgType);
|
|
459
|
+
const joinTick = asTick(view.getInt32(offset));
|
|
460
|
+
offset += 4;
|
|
461
|
+
ensureBytes(view, offset, 1, msgType);
|
|
462
|
+
const hasLeaveTick = view.getUint8(offset++) === 1;
|
|
463
|
+
if (hasLeaveTick) {
|
|
464
|
+
ensureBytes(view, offset, 4, msgType);
|
|
465
|
+
}
|
|
466
|
+
const leaveTick = hasLeaveTick ? asTick(view.getInt32(offset)) : null;
|
|
467
|
+
if (hasLeaveTick)
|
|
468
|
+
offset += 4;
|
|
469
|
+
playerTimeline.push({
|
|
470
|
+
playerId: asPlayerId(playerId),
|
|
471
|
+
joinTick,
|
|
472
|
+
leaveTick,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
type: MessageType.Sync,
|
|
477
|
+
tick,
|
|
478
|
+
state,
|
|
479
|
+
hash,
|
|
480
|
+
playerTimeline,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// =============================================================================
|
|
484
|
+
// SyncRequest Message
|
|
485
|
+
// Format: type(1) + playerId(2+N) + desyncTick(4) + localHash(4)
|
|
486
|
+
// =============================================================================
|
|
487
|
+
function encodeSyncRequestMessage(msg) {
|
|
488
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
489
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4 + 4);
|
|
490
|
+
const view = new DataView(buffer.buffer);
|
|
491
|
+
let offset = 0;
|
|
492
|
+
view.setUint8(offset++, MessageType.SyncRequest);
|
|
493
|
+
offset += writeString(view, offset, msg.playerId);
|
|
494
|
+
view.setInt32(offset, msg.desyncTick);
|
|
495
|
+
offset += 4;
|
|
496
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
497
|
+
view.setUint32(offset, msg.localHash);
|
|
498
|
+
return buffer;
|
|
499
|
+
}
|
|
500
|
+
function decodeSyncRequestMessage(view, limits) {
|
|
501
|
+
const msgType = MessageType.SyncRequest;
|
|
502
|
+
let offset = 1;
|
|
503
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
504
|
+
offset += playerIdLen;
|
|
505
|
+
ensureBytes(view, offset, 4, msgType);
|
|
506
|
+
const desyncTick = asTick(view.getInt32(offset));
|
|
507
|
+
offset += 4;
|
|
508
|
+
ensureBytes(view, offset, 4, msgType);
|
|
509
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
510
|
+
const localHash = view.getUint32(offset);
|
|
511
|
+
return {
|
|
512
|
+
type: MessageType.SyncRequest,
|
|
513
|
+
playerId: asPlayerId(playerId),
|
|
514
|
+
desyncTick,
|
|
515
|
+
localHash,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// =============================================================================
|
|
519
|
+
// Pause Message
|
|
520
|
+
// Format: type(1) + playerId(2+N) + pauseTick(4)
|
|
521
|
+
// =============================================================================
|
|
522
|
+
function encodePauseMessage(msg) {
|
|
523
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
524
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4 + 1);
|
|
525
|
+
const view = new DataView(buffer.buffer);
|
|
526
|
+
let offset = 0;
|
|
527
|
+
view.setUint8(offset++, MessageType.Pause);
|
|
528
|
+
offset += writeString(view, offset, msg.playerId);
|
|
529
|
+
view.setInt32(offset, msg.pauseTick);
|
|
530
|
+
offset += 4;
|
|
531
|
+
view.setUint8(offset, encodePauseReason(msg.reason));
|
|
532
|
+
return buffer;
|
|
533
|
+
}
|
|
534
|
+
function decodePauseMessage(view, limits) {
|
|
535
|
+
const msgType = MessageType.Pause;
|
|
536
|
+
let offset = 1;
|
|
537
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
538
|
+
offset += playerIdLen;
|
|
539
|
+
ensureBytes(view, offset, 5, msgType); // 4 for tick + 1 for reason
|
|
540
|
+
const pauseTick = asTick(view.getInt32(offset));
|
|
541
|
+
offset += 4;
|
|
542
|
+
const reason = decodePauseReason(view.getUint8(offset));
|
|
543
|
+
return {
|
|
544
|
+
type: MessageType.Pause,
|
|
545
|
+
playerId: asPlayerId(playerId),
|
|
546
|
+
pauseTick,
|
|
547
|
+
reason,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// =============================================================================
|
|
551
|
+
// Resume Message
|
|
552
|
+
// Format: type(1) + playerId(2+N) + resumeTick(4)
|
|
553
|
+
// =============================================================================
|
|
554
|
+
function encodeResumeMessage(msg) {
|
|
555
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
556
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4);
|
|
557
|
+
const view = new DataView(buffer.buffer);
|
|
558
|
+
let offset = 0;
|
|
559
|
+
view.setUint8(offset++, MessageType.Resume);
|
|
560
|
+
offset += writeString(view, offset, msg.playerId);
|
|
561
|
+
view.setInt32(offset, msg.resumeTick);
|
|
562
|
+
return buffer;
|
|
563
|
+
}
|
|
564
|
+
function decodeResumeMessage(view, limits) {
|
|
565
|
+
const msgType = MessageType.Resume;
|
|
566
|
+
let offset = 1;
|
|
567
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
568
|
+
offset += playerIdLen;
|
|
569
|
+
ensureBytes(view, offset, 4, msgType);
|
|
570
|
+
const resumeTick = asTick(view.getInt32(offset));
|
|
571
|
+
return {
|
|
572
|
+
type: MessageType.Resume,
|
|
573
|
+
playerId: asPlayerId(playerId),
|
|
574
|
+
resumeTick,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
// =============================================================================
|
|
578
|
+
// JoinRequest Message
|
|
579
|
+
// Format: type(1) + playerId(2+N)
|
|
580
|
+
// =============================================================================
|
|
581
|
+
function encodeJoinRequestMessage(msg) {
|
|
582
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
583
|
+
// Add 1 byte for role (0xFF means no role specified)
|
|
584
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 1);
|
|
585
|
+
const view = new DataView(buffer.buffer);
|
|
586
|
+
let offset = 0;
|
|
587
|
+
view.setUint8(offset++, MessageType.JoinRequest);
|
|
588
|
+
offset += writeString(view, offset, msg.playerId);
|
|
589
|
+
// Encode role: 0xFF = not specified, otherwise use encodePlayerRole
|
|
590
|
+
view.setUint8(offset, msg.role !== undefined ? encodePlayerRole(msg.role) : 0xff);
|
|
591
|
+
return buffer;
|
|
592
|
+
}
|
|
593
|
+
function decodeJoinRequestMessage(view, limits) {
|
|
594
|
+
const msgType = MessageType.JoinRequest;
|
|
595
|
+
let offset = 1;
|
|
596
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
597
|
+
offset += playerIdLen;
|
|
598
|
+
// Read role if present (backwards compatible: if not enough bytes, default to undefined)
|
|
599
|
+
let role;
|
|
600
|
+
if (view.byteLength > offset) {
|
|
601
|
+
const roleValue = view.getUint8(offset);
|
|
602
|
+
if (roleValue !== 0xff) {
|
|
603
|
+
role = decodePlayerRole(roleValue);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Build result with optional role only if defined
|
|
607
|
+
const result = {
|
|
608
|
+
type: MessageType.JoinRequest,
|
|
609
|
+
playerId: asPlayerId(playerId),
|
|
610
|
+
};
|
|
611
|
+
if (role !== undefined) {
|
|
612
|
+
result.role = role;
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
// =============================================================================
|
|
617
|
+
// JoinAccept Message
|
|
618
|
+
// Format: type(1) + playerId(2+N) + roomId(2+N) + tickRate(2) + maxPlayers(1) + playerCount(1) + players(2+N)*
|
|
619
|
+
// =============================================================================
|
|
620
|
+
function encodeJoinAcceptMessage(msg) {
|
|
621
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
622
|
+
const roomIdBytes = textEncoder.encode(msg.roomId);
|
|
623
|
+
let playersSize = 1;
|
|
624
|
+
for (const p of msg.players) {
|
|
625
|
+
playersSize += 2 + textEncoder.encode(p).length;
|
|
626
|
+
}
|
|
627
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 2 + roomIdBytes.length + 2 + 1 + playersSize);
|
|
628
|
+
const view = new DataView(buffer.buffer);
|
|
629
|
+
let offset = 0;
|
|
630
|
+
view.setUint8(offset++, MessageType.JoinAccept);
|
|
631
|
+
offset += writeString(view, offset, msg.playerId);
|
|
632
|
+
offset += writeString(view, offset, msg.roomId);
|
|
633
|
+
view.setUint16(offset, msg.config.tickRate);
|
|
634
|
+
offset += 2;
|
|
635
|
+
view.setUint8(offset++, msg.config.maxPlayers);
|
|
636
|
+
view.setUint8(offset++, msg.players.length);
|
|
637
|
+
for (const p of msg.players) {
|
|
638
|
+
offset += writeString(view, offset, p);
|
|
639
|
+
}
|
|
640
|
+
return buffer;
|
|
641
|
+
}
|
|
642
|
+
function decodeJoinAcceptMessage(view, limits) {
|
|
643
|
+
const msgType = MessageType.JoinAccept;
|
|
644
|
+
let offset = 1;
|
|
645
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
646
|
+
offset += playerIdLen;
|
|
647
|
+
const [roomId, roomIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
648
|
+
offset += roomIdLen;
|
|
649
|
+
ensureBytes(view, offset, 2, msgType);
|
|
650
|
+
const tickRate = view.getUint16(offset);
|
|
651
|
+
offset += 2;
|
|
652
|
+
ensureBytes(view, offset, 2, msgType);
|
|
653
|
+
const maxPlayers = view.getUint8(offset++);
|
|
654
|
+
const playerCount = view.getUint8(offset++);
|
|
655
|
+
// Validate player count against limit
|
|
656
|
+
if (playerCount > limits.maxPlayerCount) {
|
|
657
|
+
throw new DecodeError(`Player count exceeds maximum of ${limits.maxPlayerCount}`, msgType, offset - 1, limits.maxPlayerCount, playerCount);
|
|
658
|
+
}
|
|
659
|
+
const players = [];
|
|
660
|
+
for (let i = 0; i < playerCount; i++) {
|
|
661
|
+
const [p, pLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
662
|
+
offset += pLen;
|
|
663
|
+
players.push(asPlayerId(p));
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
type: MessageType.JoinAccept,
|
|
667
|
+
playerId: asPlayerId(playerId),
|
|
668
|
+
roomId,
|
|
669
|
+
config: { tickRate, maxPlayers },
|
|
670
|
+
players,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// =============================================================================
|
|
674
|
+
// JoinReject Message
|
|
675
|
+
// Format: type(1) + playerId(2+N) + reason(2+N)
|
|
676
|
+
// =============================================================================
|
|
677
|
+
function encodeJoinRejectMessage(msg) {
|
|
678
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
679
|
+
const reasonBytes = textEncoder.encode(msg.reason);
|
|
680
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 2 + reasonBytes.length);
|
|
681
|
+
const view = new DataView(buffer.buffer);
|
|
682
|
+
let offset = 0;
|
|
683
|
+
view.setUint8(offset++, MessageType.JoinReject);
|
|
684
|
+
offset += writeString(view, offset, msg.playerId);
|
|
685
|
+
offset += writeString(view, offset, msg.reason);
|
|
686
|
+
return buffer;
|
|
687
|
+
}
|
|
688
|
+
function decodeJoinRejectMessage(view, limits) {
|
|
689
|
+
const msgType = MessageType.JoinReject;
|
|
690
|
+
let offset = 1;
|
|
691
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
692
|
+
offset += playerIdLen;
|
|
693
|
+
const [reason] = readString(view, offset, msgType, limits.maxStringLength);
|
|
694
|
+
return {
|
|
695
|
+
type: MessageType.JoinReject,
|
|
696
|
+
playerId: asPlayerId(playerId),
|
|
697
|
+
reason,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
// =============================================================================
|
|
701
|
+
// StateSync Message
|
|
702
|
+
// Format: type(1) + tick(4) + hash(4) + state(4+N) + playerTimeline(...)
|
|
703
|
+
// =============================================================================
|
|
704
|
+
function encodeStateSyncMessage(msg) {
|
|
705
|
+
// Calculate size for player timeline
|
|
706
|
+
let timelineSize = 2;
|
|
707
|
+
for (const entry of msg.playerTimeline) {
|
|
708
|
+
const idBytes = textEncoder.encode(entry.playerId);
|
|
709
|
+
timelineSize +=
|
|
710
|
+
2 + idBytes.length + 4 + 1 + (entry.leaveTick !== null ? 4 : 0);
|
|
711
|
+
}
|
|
712
|
+
const buffer = new Uint8Array(1 + 4 + 4 + 4 + msg.state.length + timelineSize);
|
|
713
|
+
const view = new DataView(buffer.buffer);
|
|
714
|
+
let offset = 0;
|
|
715
|
+
view.setUint8(offset++, MessageType.StateSync);
|
|
716
|
+
view.setInt32(offset, msg.tick);
|
|
717
|
+
offset += 4;
|
|
718
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
719
|
+
view.setUint32(offset, msg.hash);
|
|
720
|
+
offset += 4;
|
|
721
|
+
offset += writeBytes(view, offset, msg.state);
|
|
722
|
+
view.setUint16(offset, msg.playerTimeline.length);
|
|
723
|
+
offset += 2;
|
|
724
|
+
for (const entry of msg.playerTimeline) {
|
|
725
|
+
offset += writeString(view, offset, entry.playerId);
|
|
726
|
+
view.setInt32(offset, entry.joinTick);
|
|
727
|
+
offset += 4;
|
|
728
|
+
if (entry.leaveTick !== null) {
|
|
729
|
+
view.setUint8(offset++, 1);
|
|
730
|
+
view.setInt32(offset, entry.leaveTick);
|
|
731
|
+
offset += 4;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
view.setUint8(offset++, 0);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return buffer;
|
|
738
|
+
}
|
|
739
|
+
function decodeStateSyncMessage(view, limits) {
|
|
740
|
+
const msgType = MessageType.StateSync;
|
|
741
|
+
let offset = 1;
|
|
742
|
+
ensureBytes(view, offset, 4, msgType);
|
|
743
|
+
const tick = asTick(view.getInt32(offset));
|
|
744
|
+
offset += 4;
|
|
745
|
+
ensureBytes(view, offset, 4, msgType);
|
|
746
|
+
// Hash is unsigned (game.hash() returns h >>> 0)
|
|
747
|
+
const hash = view.getUint32(offset);
|
|
748
|
+
offset += 4;
|
|
749
|
+
const [state, stateLen] = readBytes(view, offset, msgType, limits.maxStateSize);
|
|
750
|
+
offset += stateLen;
|
|
751
|
+
ensureBytes(view, offset, 2, msgType);
|
|
752
|
+
const playerCount = view.getUint16(offset);
|
|
753
|
+
offset += 2;
|
|
754
|
+
// Validate player count against limit
|
|
755
|
+
if (playerCount > limits.maxPlayerCount) {
|
|
756
|
+
throw new DecodeError(`Player count exceeds maximum of ${limits.maxPlayerCount}`, msgType, offset - 2, limits.maxPlayerCount, playerCount);
|
|
757
|
+
}
|
|
758
|
+
const playerTimeline = [];
|
|
759
|
+
for (let i = 0; i < playerCount; i++) {
|
|
760
|
+
const [playerId, idLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
761
|
+
offset += idLen;
|
|
762
|
+
ensureBytes(view, offset, 4, msgType);
|
|
763
|
+
const joinTick = asTick(view.getInt32(offset));
|
|
764
|
+
offset += 4;
|
|
765
|
+
ensureBytes(view, offset, 1, msgType);
|
|
766
|
+
const hasLeaveTick = view.getUint8(offset++) === 1;
|
|
767
|
+
if (hasLeaveTick) {
|
|
768
|
+
ensureBytes(view, offset, 4, msgType);
|
|
769
|
+
}
|
|
770
|
+
const leaveTick = hasLeaveTick ? asTick(view.getInt32(offset)) : null;
|
|
771
|
+
if (hasLeaveTick)
|
|
772
|
+
offset += 4;
|
|
773
|
+
playerTimeline.push({
|
|
774
|
+
playerId: asPlayerId(playerId),
|
|
775
|
+
joinTick,
|
|
776
|
+
leaveTick,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
type: MessageType.StateSync,
|
|
781
|
+
tick,
|
|
782
|
+
state,
|
|
783
|
+
hash,
|
|
784
|
+
playerTimeline,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// =============================================================================
|
|
788
|
+
// PlayerJoined Message
|
|
789
|
+
// Format: type(1) + playerId(2+N) + joinTick(4)
|
|
790
|
+
// =============================================================================
|
|
791
|
+
function encodePlayerJoinedMessage(msg) {
|
|
792
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
793
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4 + 1);
|
|
794
|
+
const view = new DataView(buffer.buffer);
|
|
795
|
+
let offset = 0;
|
|
796
|
+
view.setUint8(offset++, MessageType.PlayerJoined);
|
|
797
|
+
offset += writeString(view, offset, msg.playerId);
|
|
798
|
+
view.setInt32(offset, msg.joinTick);
|
|
799
|
+
offset += 4;
|
|
800
|
+
view.setUint8(offset, encodePlayerRole(msg.role));
|
|
801
|
+
return buffer;
|
|
802
|
+
}
|
|
803
|
+
function decodePlayerJoinedMessage(view, limits) {
|
|
804
|
+
const msgType = MessageType.PlayerJoined;
|
|
805
|
+
let offset = 1;
|
|
806
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
807
|
+
offset += playerIdLen;
|
|
808
|
+
ensureBytes(view, offset, 5, msgType); // 4 for tick + 1 for role
|
|
809
|
+
const joinTick = asTick(view.getInt32(offset));
|
|
810
|
+
offset += 4;
|
|
811
|
+
const role = decodePlayerRole(view.getUint8(offset));
|
|
812
|
+
return {
|
|
813
|
+
type: MessageType.PlayerJoined,
|
|
814
|
+
playerId: asPlayerId(playerId),
|
|
815
|
+
joinTick,
|
|
816
|
+
role,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
// =============================================================================
|
|
820
|
+
// PlayerLeft Message
|
|
821
|
+
// Format: type(1) + playerId(2+N) + leaveTick(4)
|
|
822
|
+
// =============================================================================
|
|
823
|
+
function encodePlayerLeftMessage(msg) {
|
|
824
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
825
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4);
|
|
826
|
+
const view = new DataView(buffer.buffer);
|
|
827
|
+
let offset = 0;
|
|
828
|
+
view.setUint8(offset++, MessageType.PlayerLeft);
|
|
829
|
+
offset += writeString(view, offset, msg.playerId);
|
|
830
|
+
view.setInt32(offset, msg.leaveTick);
|
|
831
|
+
return buffer;
|
|
832
|
+
}
|
|
833
|
+
function decodePlayerLeftMessage(view, limits) {
|
|
834
|
+
const msgType = MessageType.PlayerLeft;
|
|
835
|
+
let offset = 1;
|
|
836
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
837
|
+
offset += playerIdLen;
|
|
838
|
+
ensureBytes(view, offset, 4, msgType);
|
|
839
|
+
const leaveTick = asTick(view.getInt32(offset));
|
|
840
|
+
return {
|
|
841
|
+
type: MessageType.PlayerLeft,
|
|
842
|
+
playerId: asPlayerId(playerId),
|
|
843
|
+
leaveTick,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
// =============================================================================
|
|
847
|
+
// Ping Message
|
|
848
|
+
// Format: type(1) + timestamp(8)
|
|
849
|
+
// =============================================================================
|
|
850
|
+
function encodePingMessage(msg) {
|
|
851
|
+
const buffer = new Uint8Array(1 + 8);
|
|
852
|
+
const view = new DataView(buffer.buffer);
|
|
853
|
+
view.setUint8(0, MessageType.Ping);
|
|
854
|
+
view.setBigUint64(1, BigInt(msg.timestamp));
|
|
855
|
+
return buffer;
|
|
856
|
+
}
|
|
857
|
+
function decodePingMessage(view) {
|
|
858
|
+
const msgType = MessageType.Ping;
|
|
859
|
+
ensureBytes(view, 1, 8, msgType);
|
|
860
|
+
const timestamp = Number(view.getBigUint64(1));
|
|
861
|
+
return {
|
|
862
|
+
type: MessageType.Ping,
|
|
863
|
+
timestamp,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
// =============================================================================
|
|
867
|
+
// Pong Message
|
|
868
|
+
// Format: type(1) + timestamp(8)
|
|
869
|
+
// =============================================================================
|
|
870
|
+
function encodePongMessage(msg) {
|
|
871
|
+
const buffer = new Uint8Array(1 + 8);
|
|
872
|
+
const view = new DataView(buffer.buffer);
|
|
873
|
+
view.setUint8(0, MessageType.Pong);
|
|
874
|
+
view.setBigUint64(1, BigInt(msg.timestamp));
|
|
875
|
+
return buffer;
|
|
876
|
+
}
|
|
877
|
+
function decodePongMessage(view) {
|
|
878
|
+
const msgType = MessageType.Pong;
|
|
879
|
+
ensureBytes(view, 1, 8, msgType);
|
|
880
|
+
const timestamp = Number(view.getBigUint64(1));
|
|
881
|
+
return {
|
|
882
|
+
type: MessageType.Pong,
|
|
883
|
+
timestamp,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
// =============================================================================
|
|
887
|
+
// LagReport Message
|
|
888
|
+
// Format: type(1) + laggyPlayerId(2+N) + ticksBehind(4)
|
|
889
|
+
// =============================================================================
|
|
890
|
+
function encodeLagReportMessage(msg) {
|
|
891
|
+
const playerIdBytes = textEncoder.encode(msg.laggyPlayerId);
|
|
892
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 4);
|
|
893
|
+
const view = new DataView(buffer.buffer);
|
|
894
|
+
let offset = 0;
|
|
895
|
+
view.setUint8(offset++, MessageType.LagReport);
|
|
896
|
+
offset += writeString(view, offset, msg.laggyPlayerId);
|
|
897
|
+
view.setInt32(offset, msg.ticksBehind);
|
|
898
|
+
return buffer;
|
|
899
|
+
}
|
|
900
|
+
function decodeLagReportMessage(view, limits) {
|
|
901
|
+
const msgType = MessageType.LagReport;
|
|
902
|
+
let offset = 1;
|
|
903
|
+
const [laggyPlayerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
904
|
+
offset += playerIdLen;
|
|
905
|
+
ensureBytes(view, offset, 4, msgType);
|
|
906
|
+
const ticksBehind = view.getInt32(offset);
|
|
907
|
+
return {
|
|
908
|
+
type: MessageType.LagReport,
|
|
909
|
+
laggyPlayerId: asPlayerId(laggyPlayerId),
|
|
910
|
+
ticksBehind,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
// =============================================================================
|
|
914
|
+
// DisconnectReport Message
|
|
915
|
+
// Format: type(1) + disconnectedPeerId(2+N)
|
|
916
|
+
// =============================================================================
|
|
917
|
+
function encodeDisconnectReportMessage(msg) {
|
|
918
|
+
const playerIdBytes = textEncoder.encode(msg.disconnectedPeerId);
|
|
919
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length);
|
|
920
|
+
const view = new DataView(buffer.buffer);
|
|
921
|
+
let offset = 0;
|
|
922
|
+
view.setUint8(offset++, MessageType.DisconnectReport);
|
|
923
|
+
offset += writeString(view, offset, msg.disconnectedPeerId);
|
|
924
|
+
return buffer;
|
|
925
|
+
}
|
|
926
|
+
function decodeDisconnectReportMessage(view, limits) {
|
|
927
|
+
const msgType = MessageType.DisconnectReport;
|
|
928
|
+
const [disconnectedPeerId] = readString(view, 1, msgType, limits.maxStringLength);
|
|
929
|
+
return {
|
|
930
|
+
type: MessageType.DisconnectReport,
|
|
931
|
+
disconnectedPeerId: asPlayerId(disconnectedPeerId),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
// =============================================================================
|
|
935
|
+
// ResumeCountdown Message
|
|
936
|
+
// Format: type(1) + secondsRemaining(2)
|
|
937
|
+
// =============================================================================
|
|
938
|
+
function encodeResumeCountdownMessage(msg) {
|
|
939
|
+
const buffer = new Uint8Array(1 + 2);
|
|
940
|
+
const view = new DataView(buffer.buffer);
|
|
941
|
+
view.setUint8(0, MessageType.ResumeCountdown);
|
|
942
|
+
view.setUint16(1, msg.secondsRemaining);
|
|
943
|
+
return buffer;
|
|
944
|
+
}
|
|
945
|
+
function decodeResumeCountdownMessage(view) {
|
|
946
|
+
const msgType = MessageType.ResumeCountdown;
|
|
947
|
+
ensureBytes(view, 1, 2, msgType);
|
|
948
|
+
const secondsRemaining = view.getUint16(1);
|
|
949
|
+
return {
|
|
950
|
+
type: MessageType.ResumeCountdown,
|
|
951
|
+
secondsRemaining,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
// =============================================================================
|
|
955
|
+
// DropPlayer Message
|
|
956
|
+
// Format: type(1) + playerId(2+N) + hasMetadata(1) + [metadata(4+N)]
|
|
957
|
+
// =============================================================================
|
|
958
|
+
function encodeDropPlayerMessage(msg) {
|
|
959
|
+
const playerIdBytes = textEncoder.encode(msg.playerId);
|
|
960
|
+
const hasMetadata = msg.metadata !== undefined;
|
|
961
|
+
const metadataSize = hasMetadata && msg.metadata ? 4 + msg.metadata.length : 0;
|
|
962
|
+
const buffer = new Uint8Array(1 + 2 + playerIdBytes.length + 1 + metadataSize);
|
|
963
|
+
const view = new DataView(buffer.buffer);
|
|
964
|
+
let offset = 0;
|
|
965
|
+
view.setUint8(offset++, MessageType.DropPlayer);
|
|
966
|
+
offset += writeString(view, offset, msg.playerId);
|
|
967
|
+
view.setUint8(offset++, hasMetadata ? 1 : 0);
|
|
968
|
+
if (hasMetadata && msg.metadata) {
|
|
969
|
+
offset += writeBytes(view, offset, msg.metadata);
|
|
970
|
+
}
|
|
971
|
+
return buffer;
|
|
972
|
+
}
|
|
973
|
+
function decodeDropPlayerMessage(view, limits) {
|
|
974
|
+
const msgType = MessageType.DropPlayer;
|
|
975
|
+
let offset = 1;
|
|
976
|
+
const [playerId, playerIdLen] = readString(view, offset, msgType, limits.maxStringLength);
|
|
977
|
+
offset += playerIdLen;
|
|
978
|
+
ensureBytes(view, offset, 1, msgType);
|
|
979
|
+
const hasMetadata = view.getUint8(offset++) === 1;
|
|
980
|
+
// Build result with optional metadata only if present
|
|
981
|
+
const result = {
|
|
982
|
+
type: MessageType.DropPlayer,
|
|
983
|
+
playerId: asPlayerId(playerId),
|
|
984
|
+
};
|
|
985
|
+
if (hasMetadata) {
|
|
986
|
+
// Use maxStateSize for metadata as well since it's arbitrary game data
|
|
987
|
+
const [data] = readBytes(view, offset, msgType, limits.maxStateSize);
|
|
988
|
+
result.metadata = data;
|
|
989
|
+
}
|
|
990
|
+
return result;
|
|
991
|
+
}
|
|
992
|
+
//# sourceMappingURL=encoding.js.map
|