@couch-kit/runtime 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/index.cjs +601 -0
- package/dist/index.js +570 -0
- package/lib/action-authorization.d.ts +28 -0
- package/lib/action-authorization.d.ts.map +1 -0
- package/lib/broadcast-scheduler.d.ts +30 -0
- package/lib/broadcast-scheduler.d.ts.map +1 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/message-validation.d.ts +35 -0
- package/lib/message-validation.d.ts.map +1 -0
- package/lib/rate-limiter.d.ts +32 -0
- package/lib/rate-limiter.d.ts.map +1 -0
- package/lib/runtime.d.ts +68 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/session-manager.d.ts +63 -0
- package/lib/session-manager.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/action-authorization.ts +55 -0
- package/src/broadcast-scheduler.ts +83 -0
- package/src/index.ts +6 -0
- package/src/message-validation.ts +94 -0
- package/src/rate-limiter.ts +66 -0
- package/src/runtime.ts +425 -0
- package/src/session-manager.ts +202 -0
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @couch-kit/runtime
|
|
2
|
+
|
|
3
|
+
Transport-neutral authoritative game runtime for Couch Kit hosts.
|
|
4
|
+
|
|
5
|
+
The runtime owns canonical game state, player sessions, action authorization,
|
|
6
|
+
rate limiting, and throttled state broadcasts. Platform packages provide the
|
|
7
|
+
connection transport, such as the React Native WebSocket host or a future
|
|
8
|
+
browser WebRTC host.
|
|
9
|
+
|
|
10
|
+
Most applications should use `@couch-kit/host` rather than constructing the
|
|
11
|
+
runtime directly.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
function __accessProp(key) {
|
|
6
|
+
return this[key];
|
|
7
|
+
}
|
|
8
|
+
var __toCommonJS = (from) => {
|
|
9
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
10
|
+
if (entry)
|
|
11
|
+
return entry;
|
|
12
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (var key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(entry, key))
|
|
16
|
+
__defProp(entry, key, {
|
|
17
|
+
get: __accessProp.bind(from, key),
|
|
18
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
__moduleCache.set(from, entry);
|
|
22
|
+
return entry;
|
|
23
|
+
};
|
|
24
|
+
var __moduleCache;
|
|
25
|
+
var __returnValue = (v) => v;
|
|
26
|
+
function __exportSetter(name, newValue) {
|
|
27
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
28
|
+
}
|
|
29
|
+
var __export = (target, all) => {
|
|
30
|
+
for (var name in all)
|
|
31
|
+
__defProp(target, name, {
|
|
32
|
+
get: all[name],
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
set: __exportSetter.bind(all, name)
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/index.ts
|
|
40
|
+
var exports_src = {};
|
|
41
|
+
__export(exports_src, {
|
|
42
|
+
isValidClientMessage: () => isValidClientMessage,
|
|
43
|
+
frameByteLength: () => frameByteLength,
|
|
44
|
+
createStateUpdateMessage: () => createStateUpdateMessage,
|
|
45
|
+
authorizeClientAction: () => authorizeClientAction,
|
|
46
|
+
RATE_LIMIT_WINDOW: () => RATE_LIMIT_WINDOW,
|
|
47
|
+
RATE_LIMIT_MAX: () => RATE_LIMIT_MAX,
|
|
48
|
+
HostSessionManager: () => HostSessionManager,
|
|
49
|
+
GameHostRuntime: () => GameHostRuntime,
|
|
50
|
+
DEFAULT_STATE_THROTTLE_MS: () => DEFAULT_STATE_THROTTLE_MS,
|
|
51
|
+
DEFAULT_MAX_MESSAGE_BYTES: () => DEFAULT_MAX_MESSAGE_BYTES,
|
|
52
|
+
BroadcastScheduler: () => BroadcastScheduler,
|
|
53
|
+
ActionRateLimiter: () => ActionRateLimiter
|
|
54
|
+
});
|
|
55
|
+
module.exports = __toCommonJS(exports_src);
|
|
56
|
+
|
|
57
|
+
// src/action-authorization.ts
|
|
58
|
+
var import_core = require("@couch-kit/core");
|
|
59
|
+
var INTERNAL_ACTION_TYPES = new Set([
|
|
60
|
+
import_core.InternalActionTypes.HYDRATE,
|
|
61
|
+
import_core.InternalActionTypes.PLAYER_JOINED,
|
|
62
|
+
import_core.InternalActionTypes.PLAYER_LEFT,
|
|
63
|
+
import_core.InternalActionTypes.PLAYER_RECONNECTED,
|
|
64
|
+
import_core.InternalActionTypes.PLAYER_REMOVED
|
|
65
|
+
]);
|
|
66
|
+
function authorizeClientAction(actionType, resolvedPlayerId) {
|
|
67
|
+
if (INTERNAL_ACTION_TYPES.has(actionType)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "reject",
|
|
70
|
+
code: "FORBIDDEN_ACTION",
|
|
71
|
+
message: "Internal action types cannot be dispatched by clients"
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!resolvedPlayerId) {
|
|
75
|
+
return {
|
|
76
|
+
kind: "reject",
|
|
77
|
+
code: "NOT_JOINED",
|
|
78
|
+
message: "You must JOIN before dispatching actions"
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { kind: "allow", playerId: resolvedPlayerId };
|
|
82
|
+
}
|
|
83
|
+
// src/broadcast-scheduler.ts
|
|
84
|
+
var import_core2 = require("@couch-kit/core");
|
|
85
|
+
var DEFAULT_STATE_THROTTLE_MS = 33;
|
|
86
|
+
var defaultTimerScheduler = {
|
|
87
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
88
|
+
clearTimeout: (timer) => clearTimeout(timer)
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
class BroadcastScheduler {
|
|
92
|
+
stateThrottleMs;
|
|
93
|
+
scheduler;
|
|
94
|
+
timer = null;
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
|
|
97
|
+
this.scheduler = options.scheduler ?? defaultTimerScheduler;
|
|
98
|
+
}
|
|
99
|
+
schedule(callback) {
|
|
100
|
+
this.cancel();
|
|
101
|
+
this.timer = this.scheduler.setTimeout(() => {
|
|
102
|
+
this.timer = null;
|
|
103
|
+
callback();
|
|
104
|
+
}, this.stateThrottleMs);
|
|
105
|
+
}
|
|
106
|
+
cancel() {
|
|
107
|
+
if (this.timer) {
|
|
108
|
+
this.scheduler.clearTimeout(this.timer);
|
|
109
|
+
this.timer = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
setStateThrottleMs(stateThrottleMs) {
|
|
113
|
+
this.stateThrottleMs = stateThrottleMs;
|
|
114
|
+
}
|
|
115
|
+
hasPendingBroadcast() {
|
|
116
|
+
return this.timer !== null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function createStateUpdateMessage(newState, actions, timestamp = Date.now()) {
|
|
120
|
+
return {
|
|
121
|
+
type: import_core2.MessageTypes.STATE_UPDATE,
|
|
122
|
+
payload: {
|
|
123
|
+
newState,
|
|
124
|
+
timestamp,
|
|
125
|
+
...actions.length === 1 ? { action: actions[0] } : actions.length > 1 ? { action: actions } : {}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// src/message-validation.ts
|
|
130
|
+
var import_core3 = require("@couch-kit/core");
|
|
131
|
+
var DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
|
|
132
|
+
function frameByteLength(data) {
|
|
133
|
+
if (typeof data !== "string")
|
|
134
|
+
return data.byteLength;
|
|
135
|
+
let bytes = 0;
|
|
136
|
+
for (let i = 0;i < data.length; i++) {
|
|
137
|
+
const code = data.charCodeAt(i);
|
|
138
|
+
if (code < 128) {
|
|
139
|
+
bytes += 1;
|
|
140
|
+
} else if (code < 2048) {
|
|
141
|
+
bytes += 2;
|
|
142
|
+
} else if (code >= 55296 && code <= 56319) {
|
|
143
|
+
bytes += 4;
|
|
144
|
+
i++;
|
|
145
|
+
} else {
|
|
146
|
+
bytes += 3;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return bytes;
|
|
150
|
+
}
|
|
151
|
+
function isValidClientMessage(msg) {
|
|
152
|
+
if (typeof msg !== "object" || msg === null)
|
|
153
|
+
return false;
|
|
154
|
+
const m = msg;
|
|
155
|
+
if (typeof m.type !== "string")
|
|
156
|
+
return false;
|
|
157
|
+
switch (m.type) {
|
|
158
|
+
case import_core3.MessageTypes.JOIN:
|
|
159
|
+
return typeof m.payload === "object" && m.payload !== null && typeof m.payload.name === "string";
|
|
160
|
+
case import_core3.MessageTypes.ACTION:
|
|
161
|
+
return typeof m.payload === "object" && m.payload !== null && typeof m.payload.type === "string";
|
|
162
|
+
case import_core3.MessageTypes.PING:
|
|
163
|
+
return typeof m.payload === "object" && m.payload !== null && typeof m.payload.id === "string" && typeof m.payload.timestamp === "number";
|
|
164
|
+
case import_core3.MessageTypes.ASSETS_LOADED:
|
|
165
|
+
return m.payload === true;
|
|
166
|
+
default:
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// src/rate-limiter.ts
|
|
171
|
+
var RATE_LIMIT_MAX = 60;
|
|
172
|
+
var RATE_LIMIT_WINDOW = 1000;
|
|
173
|
+
|
|
174
|
+
class ActionRateLimiter {
|
|
175
|
+
maxActions;
|
|
176
|
+
windowMs;
|
|
177
|
+
now;
|
|
178
|
+
limits = new Map;
|
|
179
|
+
constructor(options = {}) {
|
|
180
|
+
this.maxActions = options.maxActions ?? RATE_LIMIT_MAX;
|
|
181
|
+
this.windowMs = options.windowMs ?? RATE_LIMIT_WINDOW;
|
|
182
|
+
this.now = options.now ?? Date.now;
|
|
183
|
+
}
|
|
184
|
+
record(socketId) {
|
|
185
|
+
const now = this.now();
|
|
186
|
+
let rateInfo = this.limits.get(socketId);
|
|
187
|
+
if (!rateInfo || now - rateInfo.windowStart > this.windowMs) {
|
|
188
|
+
rateInfo = { count: 0, windowStart: now };
|
|
189
|
+
this.limits.set(socketId, rateInfo);
|
|
190
|
+
}
|
|
191
|
+
rateInfo.count++;
|
|
192
|
+
return {
|
|
193
|
+
...rateInfo,
|
|
194
|
+
allowed: rateInfo.count <= this.maxActions
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
reset(socketId) {
|
|
198
|
+
this.limits.delete(socketId);
|
|
199
|
+
}
|
|
200
|
+
clear() {
|
|
201
|
+
this.limits.clear();
|
|
202
|
+
}
|
|
203
|
+
get(socketId) {
|
|
204
|
+
return this.limits.get(socketId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// src/runtime.ts
|
|
208
|
+
var import_core5 = require("@couch-kit/core");
|
|
209
|
+
|
|
210
|
+
// src/session-manager.ts
|
|
211
|
+
var import_core4 = require("@couch-kit/core");
|
|
212
|
+
var defaultSessionTimerScheduler = {
|
|
213
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
214
|
+
clearTimeout: (timer) => clearTimeout(timer)
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
class HostSessionManager {
|
|
218
|
+
sessions = new Map;
|
|
219
|
+
reverseMap = new Map;
|
|
220
|
+
cleanupTimers = new Map;
|
|
221
|
+
socketIdToPlayerId = new Map;
|
|
222
|
+
scheduler;
|
|
223
|
+
getDisconnectTimeout;
|
|
224
|
+
derivePlayerIdFn;
|
|
225
|
+
derivePlayerIdLegacyFn;
|
|
226
|
+
constructor(options = {}) {
|
|
227
|
+
this.scheduler = options.scheduler ?? defaultSessionTimerScheduler;
|
|
228
|
+
this.getDisconnectTimeout = options.getDisconnectTimeout ?? (() => options.disconnectTimeout ?? import_core4.DEFAULT_DISCONNECT_TIMEOUT);
|
|
229
|
+
this.derivePlayerIdFn = options.derivePlayerId ?? import_core4.derivePlayerId;
|
|
230
|
+
this.derivePlayerIdLegacyFn = options.derivePlayerIdLegacy ?? import_core4.derivePlayerIdLegacy;
|
|
231
|
+
}
|
|
232
|
+
async handleJoin(socketId, payload, playersSource) {
|
|
233
|
+
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
234
|
+
const players = typeof playersSource === "function" ? playersSource() : playersSource;
|
|
235
|
+
let playerId = hashedId;
|
|
236
|
+
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
237
|
+
if (!players[playerId] && players[legacyId]) {
|
|
238
|
+
playerId = legacyId;
|
|
239
|
+
}
|
|
240
|
+
this.socketIdToPlayerId.set(socketId, playerId);
|
|
241
|
+
this.sessions.set(payload.secret, socketId);
|
|
242
|
+
this.reverseMap.set(socketId, payload.secret);
|
|
243
|
+
this.cancelRemoval(playerId);
|
|
244
|
+
const isReconnect = !!players[playerId];
|
|
245
|
+
const action = isReconnect ? {
|
|
246
|
+
type: import_core4.InternalActionTypes.PLAYER_RECONNECTED,
|
|
247
|
+
payload: { playerId }
|
|
248
|
+
} : {
|
|
249
|
+
type: import_core4.InternalActionTypes.PLAYER_JOINED,
|
|
250
|
+
payload: { id: playerId, name: payload.name, avatar: payload.avatar }
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
playerId,
|
|
254
|
+
socketId,
|
|
255
|
+
secret: payload.secret,
|
|
256
|
+
isReconnect,
|
|
257
|
+
action
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
handleDisconnect(socketId) {
|
|
261
|
+
const playerId = this.socketIdToPlayerId.get(socketId);
|
|
262
|
+
this.socketIdToPlayerId.delete(socketId);
|
|
263
|
+
const secret = this.reverseMap.get(socketId);
|
|
264
|
+
this.reverseMap.delete(socketId);
|
|
265
|
+
if (!playerId || !secret)
|
|
266
|
+
return { kind: "unknown" };
|
|
267
|
+
if (this.sessions.get(secret) !== socketId) {
|
|
268
|
+
return { kind: "stale", playerId, secret };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
kind: "left",
|
|
272
|
+
playerId,
|
|
273
|
+
secret,
|
|
274
|
+
action: {
|
|
275
|
+
type: import_core4.InternalActionTypes.PLAYER_LEFT,
|
|
276
|
+
payload: { playerId }
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
scheduleRemoval(playerId, secret, onRemove) {
|
|
281
|
+
const timer = this.scheduler.setTimeout(() => {
|
|
282
|
+
this.cleanupTimers.delete(playerId);
|
|
283
|
+
this.sessions.delete(secret);
|
|
284
|
+
onRemove(playerId);
|
|
285
|
+
}, this.getDisconnectTimeout());
|
|
286
|
+
this.cleanupTimers.set(playerId, timer);
|
|
287
|
+
}
|
|
288
|
+
cancelRemoval(playerId) {
|
|
289
|
+
const existingTimer = this.cleanupTimers.get(playerId);
|
|
290
|
+
if (existingTimer) {
|
|
291
|
+
this.scheduler.clearTimeout(existingTimer);
|
|
292
|
+
this.cleanupTimers.delete(playerId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
clearRemovalTimers() {
|
|
296
|
+
for (const timer of this.cleanupTimers.values()) {
|
|
297
|
+
this.scheduler.clearTimeout(timer);
|
|
298
|
+
}
|
|
299
|
+
this.cleanupTimers.clear();
|
|
300
|
+
}
|
|
301
|
+
getPlayerIdForSocket(socketId) {
|
|
302
|
+
const secret = this.reverseMap.get(socketId);
|
|
303
|
+
if (!secret || this.sessions.get(secret) !== socketId) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
return this.socketIdToPlayerId.get(socketId);
|
|
307
|
+
}
|
|
308
|
+
abandonConnection(socketId) {
|
|
309
|
+
const secret = this.reverseMap.get(socketId);
|
|
310
|
+
this.socketIdToPlayerId.delete(socketId);
|
|
311
|
+
this.reverseMap.delete(socketId);
|
|
312
|
+
if (secret && this.sessions.get(secret) === socketId) {
|
|
313
|
+
this.sessions.delete(secret);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
getSocketIdForSecret(secret) {
|
|
317
|
+
return this.sessions.get(secret);
|
|
318
|
+
}
|
|
319
|
+
hasPendingRemoval(playerId) {
|
|
320
|
+
return this.cleanupTimers.has(playerId);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/runtime.ts
|
|
325
|
+
class GameHostRuntime {
|
|
326
|
+
config;
|
|
327
|
+
reducer;
|
|
328
|
+
state;
|
|
329
|
+
transport;
|
|
330
|
+
listeners = new Set;
|
|
331
|
+
sessionManager;
|
|
332
|
+
rateLimiter = new ActionRateLimiter;
|
|
333
|
+
broadcastScheduler;
|
|
334
|
+
assetsLoaded = new Map;
|
|
335
|
+
activeConnections = new Map;
|
|
336
|
+
joinedConnections = new Set;
|
|
337
|
+
pendingJoins = new Set;
|
|
338
|
+
actionQueue = [];
|
|
339
|
+
stateDirty = false;
|
|
340
|
+
nextConnectionGeneration = 1;
|
|
341
|
+
constructor(config, transport = null) {
|
|
342
|
+
this.config = config;
|
|
343
|
+
this.state = config.initialState;
|
|
344
|
+
this.reducer = import_core5.createGameReducer(config.reducer);
|
|
345
|
+
this.transport = transport;
|
|
346
|
+
this.sessionManager = new HostSessionManager({
|
|
347
|
+
getDisconnectTimeout: () => this.config.disconnectTimeout ?? import_core5.DEFAULT_DISCONNECT_TIMEOUT
|
|
348
|
+
});
|
|
349
|
+
this.broadcastScheduler = new BroadcastScheduler({
|
|
350
|
+
stateThrottleMs: config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
getState = () => this.state;
|
|
354
|
+
subscribe = (listener) => {
|
|
355
|
+
this.listeners.add(listener);
|
|
356
|
+
return () => {
|
|
357
|
+
this.listeners.delete(listener);
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
setTransport(transport) {
|
|
361
|
+
this.transport = transport;
|
|
362
|
+
if (transport && this.stateDirty) {
|
|
363
|
+
this.broadcastScheduler.schedule(this.broadcastState);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
updateConfig(config) {
|
|
367
|
+
this.config = config;
|
|
368
|
+
this.broadcastScheduler.setStateThrottleMs(config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS);
|
|
369
|
+
}
|
|
370
|
+
dispatch = (action) => {
|
|
371
|
+
this.applyAction(action);
|
|
372
|
+
};
|
|
373
|
+
handleConnection(connectionId) {
|
|
374
|
+
this.activeConnections.set(connectionId, this.nextConnectionGeneration++);
|
|
375
|
+
this.log(`[GameRuntime] Client connected: ${connectionId}`);
|
|
376
|
+
}
|
|
377
|
+
async handleMessage(connectionId, rawMessage) {
|
|
378
|
+
const connectionGeneration = this.activeConnections.get(connectionId);
|
|
379
|
+
if (connectionGeneration === undefined) {
|
|
380
|
+
this.log(`[GameRuntime] Ignoring message from inactive connection: ${connectionId}`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!isValidClientMessage(rawMessage)) {
|
|
384
|
+
this.log(`[GameRuntime] Invalid message from ${connectionId}:`, rawMessage);
|
|
385
|
+
this.send(connectionId, {
|
|
386
|
+
type: import_core5.MessageTypes.ERROR,
|
|
387
|
+
payload: {
|
|
388
|
+
code: "INVALID_MESSAGE",
|
|
389
|
+
message: "Malformed message"
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const message = rawMessage;
|
|
395
|
+
this.log(`[GameRuntime] Msg from ${connectionId}:`, message);
|
|
396
|
+
switch (message.type) {
|
|
397
|
+
case import_core5.MessageTypes.JOIN: {
|
|
398
|
+
const { secret, ...payload } = message.payload;
|
|
399
|
+
if (!secret || typeof secret !== "string" || !import_core5.isValidSecret(secret)) {
|
|
400
|
+
this.send(connectionId, {
|
|
401
|
+
type: import_core5.MessageTypes.ERROR,
|
|
402
|
+
payload: {
|
|
403
|
+
code: "INVALID_SECRET",
|
|
404
|
+
message: "Invalid or missing session secret"
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (this.joinedConnections.has(connectionId) || this.pendingJoins.has(connectionId)) {
|
|
410
|
+
this.send(connectionId, {
|
|
411
|
+
type: import_core5.MessageTypes.ERROR,
|
|
412
|
+
payload: {
|
|
413
|
+
code: "ALREADY_JOINED",
|
|
414
|
+
message: "This connection has already joined the game"
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.pendingJoins.add(connectionId);
|
|
420
|
+
try {
|
|
421
|
+
const { playerId, isReconnect, action } = await this.sessionManager.handleJoin(connectionId, message.payload, () => this.state.players);
|
|
422
|
+
if (this.activeConnections.get(connectionId) !== connectionGeneration) {
|
|
423
|
+
this.sessionManager.abandonConnection(connectionId);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.applyAction(action);
|
|
427
|
+
this.joinedConnections.add(connectionId);
|
|
428
|
+
this.assetsLoaded.set(playerId, false);
|
|
429
|
+
this.invokeLifecycleCallback("onPlayerJoined", this.config.onPlayerJoined ? () => {
|
|
430
|
+
this.config.onPlayerJoined?.(playerId, payload.name);
|
|
431
|
+
} : undefined);
|
|
432
|
+
if (isReconnect) {
|
|
433
|
+
this.send(connectionId, {
|
|
434
|
+
type: import_core5.MessageTypes.RECONNECTED,
|
|
435
|
+
payload: {
|
|
436
|
+
playerId,
|
|
437
|
+
state: this.state
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
} else {
|
|
441
|
+
this.send(connectionId, {
|
|
442
|
+
type: import_core5.MessageTypes.WELCOME,
|
|
443
|
+
payload: {
|
|
444
|
+
playerId,
|
|
445
|
+
state: this.state,
|
|
446
|
+
serverTime: Date.now()
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
this.log("[GameRuntime] Failed to derive player ID:", error);
|
|
452
|
+
this.send(connectionId, {
|
|
453
|
+
type: import_core5.MessageTypes.ERROR,
|
|
454
|
+
payload: {
|
|
455
|
+
code: "JOIN_FAILED",
|
|
456
|
+
message: "Failed to process join request"
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
} finally {
|
|
460
|
+
this.pendingJoins.delete(connectionId);
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case import_core5.MessageTypes.ACTION: {
|
|
465
|
+
const actionPayload = message.payload;
|
|
466
|
+
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
467
|
+
const authorization = authorizeClientAction(actionPayload.type, playerId);
|
|
468
|
+
if (authorization.kind === "reject") {
|
|
469
|
+
this.log(`[GameRuntime] Rejected action from ${connectionId} ` + `(${authorization.code}):`, actionPayload.type);
|
|
470
|
+
this.send(connectionId, {
|
|
471
|
+
type: import_core5.MessageTypes.ERROR,
|
|
472
|
+
payload: {
|
|
473
|
+
code: authorization.code,
|
|
474
|
+
message: authorization.message
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!this.rateLimiter.record(connectionId).allowed) {
|
|
480
|
+
this.log(`[GameRuntime] Rate limited ${connectionId}`);
|
|
481
|
+
this.send(connectionId, {
|
|
482
|
+
type: import_core5.MessageTypes.ERROR,
|
|
483
|
+
payload: {
|
|
484
|
+
code: "RATE_LIMITED",
|
|
485
|
+
message: "Too many actions, slow down"
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.applyAction({
|
|
491
|
+
...actionPayload,
|
|
492
|
+
playerId: authorization.playerId
|
|
493
|
+
});
|
|
494
|
+
this.actionQueue.push(actionPayload);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case import_core5.MessageTypes.PING:
|
|
498
|
+
this.send(connectionId, {
|
|
499
|
+
type: import_core5.MessageTypes.PONG,
|
|
500
|
+
payload: {
|
|
501
|
+
id: message.payload.id,
|
|
502
|
+
origTimestamp: message.payload.timestamp,
|
|
503
|
+
serverTime: Date.now()
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
break;
|
|
507
|
+
case import_core5.MessageTypes.ASSETS_LOADED: {
|
|
508
|
+
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
509
|
+
if (playerId) {
|
|
510
|
+
this.assetsLoaded.set(playerId, true);
|
|
511
|
+
this.log(`[GameRuntime] Assets loaded for ${playerId}`);
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
handleDisconnect(connectionId) {
|
|
518
|
+
this.log(`[GameRuntime] Client disconnected: ${connectionId}`);
|
|
519
|
+
this.activeConnections.delete(connectionId);
|
|
520
|
+
this.joinedConnections.delete(connectionId);
|
|
521
|
+
this.rateLimiter.reset(connectionId);
|
|
522
|
+
const result = this.sessionManager.handleDisconnect(connectionId);
|
|
523
|
+
if (result.kind === "unknown")
|
|
524
|
+
return;
|
|
525
|
+
this.assetsLoaded.delete(result.playerId);
|
|
526
|
+
if (result.kind === "stale")
|
|
527
|
+
return;
|
|
528
|
+
this.applyAction(result.action);
|
|
529
|
+
this.invokeLifecycleCallback("onPlayerLeft", this.config.onPlayerLeft ? () => {
|
|
530
|
+
this.config.onPlayerLeft?.(result.playerId);
|
|
531
|
+
} : undefined);
|
|
532
|
+
this.sessionManager.scheduleRemoval(result.playerId, result.secret, (playerId) => {
|
|
533
|
+
this.applyAction({
|
|
534
|
+
type: import_core5.InternalActionTypes.PLAYER_REMOVED,
|
|
535
|
+
payload: { playerId }
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
handleError(error) {
|
|
540
|
+
this.log("[GameRuntime] Transport error:", error);
|
|
541
|
+
this.notifyError(error);
|
|
542
|
+
}
|
|
543
|
+
stop() {
|
|
544
|
+
this.broadcastScheduler.cancel();
|
|
545
|
+
this.sessionManager.clearRemovalTimers();
|
|
546
|
+
this.rateLimiter.clear();
|
|
547
|
+
this.activeConnections.clear();
|
|
548
|
+
this.joinedConnections.clear();
|
|
549
|
+
this.pendingJoins.clear();
|
|
550
|
+
this.transport = null;
|
|
551
|
+
}
|
|
552
|
+
applyAction(action) {
|
|
553
|
+
const nextState = this.reducer(this.state, action);
|
|
554
|
+
if (Object.is(nextState, this.state))
|
|
555
|
+
return;
|
|
556
|
+
this.state = nextState;
|
|
557
|
+
this.stateDirty = true;
|
|
558
|
+
for (const listener of this.listeners) {
|
|
559
|
+
listener();
|
|
560
|
+
}
|
|
561
|
+
this.broadcastScheduler.schedule(this.broadcastState);
|
|
562
|
+
}
|
|
563
|
+
broadcastState = () => {
|
|
564
|
+
if (!this.transport)
|
|
565
|
+
return;
|
|
566
|
+
const actions = this.actionQueue;
|
|
567
|
+
this.actionQueue = [];
|
|
568
|
+
this.stateDirty = false;
|
|
569
|
+
this.transport.broadcast(createStateUpdateMessage(this.state, actions));
|
|
570
|
+
};
|
|
571
|
+
send(connectionId, message) {
|
|
572
|
+
this.transport?.send(connectionId, message);
|
|
573
|
+
}
|
|
574
|
+
invokeLifecycleCallback(name, callback) {
|
|
575
|
+
if (!callback)
|
|
576
|
+
return;
|
|
577
|
+
try {
|
|
578
|
+
callback();
|
|
579
|
+
} catch (error) {
|
|
580
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
581
|
+
this.notifyError(new Error(`${name} callback failed: ${cause.message}`, { cause }));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
notifyError(error) {
|
|
585
|
+
const onError = this.config.onError;
|
|
586
|
+
if (!onError) {
|
|
587
|
+
console.error("[GameRuntime] Unhandled error:", error);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
onError(error);
|
|
592
|
+
} catch (callbackError) {
|
|
593
|
+
console.error("[GameRuntime] onError callback failed:", callbackError);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
log(...args) {
|
|
597
|
+
if (this.config.debug) {
|
|
598
|
+
console.log(...args);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|