@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/src/runtime.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DISCONNECT_TIMEOUT,
|
|
3
|
+
InternalActionTypes,
|
|
4
|
+
MessageTypes,
|
|
5
|
+
createGameReducer,
|
|
6
|
+
isValidSecret,
|
|
7
|
+
type GameReducer,
|
|
8
|
+
type HostMessage,
|
|
9
|
+
type IAction,
|
|
10
|
+
type IGameState,
|
|
11
|
+
type InternalAction,
|
|
12
|
+
} from "@couch-kit/core";
|
|
13
|
+
import { authorizeClientAction } from "./action-authorization.js";
|
|
14
|
+
import {
|
|
15
|
+
BroadcastScheduler,
|
|
16
|
+
DEFAULT_STATE_THROTTLE_MS,
|
|
17
|
+
createStateUpdateMessage,
|
|
18
|
+
} from "./broadcast-scheduler.js";
|
|
19
|
+
import { isValidClientMessage } from "./message-validation.js";
|
|
20
|
+
import { ActionRateLimiter } from "./rate-limiter.js";
|
|
21
|
+
import {
|
|
22
|
+
HostSessionManager,
|
|
23
|
+
type JoinSessionPayload,
|
|
24
|
+
} from "./session-manager.js";
|
|
25
|
+
|
|
26
|
+
/** Minimal message-delivery surface required by the authoritative runtime. */
|
|
27
|
+
export interface GameRuntimeTransport {
|
|
28
|
+
send(connectionId: string, message: HostMessage): void;
|
|
29
|
+
broadcast(message: HostMessage): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Configuration shared by every authoritative Couch Kit host transport. */
|
|
33
|
+
export interface GameHostRuntimeConfig<
|
|
34
|
+
S extends IGameState,
|
|
35
|
+
A extends IAction,
|
|
36
|
+
> {
|
|
37
|
+
initialState: S;
|
|
38
|
+
reducer: GameReducer<S, A>;
|
|
39
|
+
debug?: boolean;
|
|
40
|
+
/** Timeout before a disconnected player is permanently removed. */
|
|
41
|
+
disconnectTimeout?: number;
|
|
42
|
+
/** Minimum interval between full-state broadcasts. */
|
|
43
|
+
stateThrottleMs?: number;
|
|
44
|
+
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
45
|
+
onPlayerLeft?: (playerId: string) => void;
|
|
46
|
+
onError?: (error: Error) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Owns the canonical game state and protocol lifecycle independently of React
|
|
51
|
+
* Native, WebSockets, or any future WebRTC transport.
|
|
52
|
+
*/
|
|
53
|
+
export class GameHostRuntime<S extends IGameState, A extends IAction> {
|
|
54
|
+
private config: GameHostRuntimeConfig<S, A>;
|
|
55
|
+
private readonly reducer: GameReducer<S, A | InternalAction<S>>;
|
|
56
|
+
private state: S;
|
|
57
|
+
private transport: GameRuntimeTransport | null;
|
|
58
|
+
private readonly listeners = new Set<() => void>();
|
|
59
|
+
private readonly sessionManager: HostSessionManager;
|
|
60
|
+
private readonly rateLimiter = new ActionRateLimiter();
|
|
61
|
+
private readonly broadcastScheduler: BroadcastScheduler;
|
|
62
|
+
private readonly assetsLoaded = new Map<string, boolean>();
|
|
63
|
+
private readonly activeConnections = new Map<string, number>();
|
|
64
|
+
private readonly joinedConnections = new Set<string>();
|
|
65
|
+
private readonly pendingJoins = new Set<string>();
|
|
66
|
+
private actionQueue: unknown[] = [];
|
|
67
|
+
private stateDirty = false;
|
|
68
|
+
private nextConnectionGeneration = 1;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
config: GameHostRuntimeConfig<S, A>,
|
|
72
|
+
transport: GameRuntimeTransport | null = null,
|
|
73
|
+
) {
|
|
74
|
+
this.config = config;
|
|
75
|
+
this.state = config.initialState;
|
|
76
|
+
this.reducer = createGameReducer(config.reducer);
|
|
77
|
+
this.transport = transport;
|
|
78
|
+
this.sessionManager = new HostSessionManager({
|
|
79
|
+
getDisconnectTimeout: () =>
|
|
80
|
+
this.config.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT,
|
|
81
|
+
});
|
|
82
|
+
this.broadcastScheduler = new BroadcastScheduler({
|
|
83
|
+
stateThrottleMs: config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns the current canonical state snapshot. */
|
|
88
|
+
readonly getState = (): S => this.state;
|
|
89
|
+
|
|
90
|
+
/** Subscribes to canonical state changes. */
|
|
91
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
92
|
+
this.listeners.add(listener);
|
|
93
|
+
return () => {
|
|
94
|
+
this.listeners.delete(listener);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Replaces the active message transport without resetting game state. */
|
|
99
|
+
setTransport(transport: GameRuntimeTransport | null): void {
|
|
100
|
+
this.transport = transport;
|
|
101
|
+
if (transport && this.stateDirty) {
|
|
102
|
+
this.broadcastScheduler.schedule(this.broadcastState);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Updates mutable callbacks and timing options while preserving state. */
|
|
107
|
+
updateConfig(config: GameHostRuntimeConfig<S, A>): void {
|
|
108
|
+
this.config = config;
|
|
109
|
+
this.broadcastScheduler.setStateThrottleMs(
|
|
110
|
+
config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Dispatches a trusted action originating from the host display. */
|
|
115
|
+
readonly dispatch = (action: A): void => {
|
|
116
|
+
this.applyAction(action);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Records a newly opened transport connection for optional debug logging. */
|
|
120
|
+
handleConnection(connectionId: string): void {
|
|
121
|
+
this.activeConnections.set(connectionId, this.nextConnectionGeneration++);
|
|
122
|
+
this.log(`[GameRuntime] Client connected: ${connectionId}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Validates and processes one parsed client protocol message. */
|
|
126
|
+
async handleMessage(
|
|
127
|
+
connectionId: string,
|
|
128
|
+
rawMessage: unknown,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const connectionGeneration = this.activeConnections.get(connectionId);
|
|
131
|
+
if (connectionGeneration === undefined) {
|
|
132
|
+
this.log(
|
|
133
|
+
`[GameRuntime] Ignoring message from inactive connection: ${connectionId}`,
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isValidClientMessage(rawMessage)) {
|
|
139
|
+
this.log(
|
|
140
|
+
`[GameRuntime] Invalid message from ${connectionId}:`,
|
|
141
|
+
rawMessage,
|
|
142
|
+
);
|
|
143
|
+
this.send(connectionId, {
|
|
144
|
+
type: MessageTypes.ERROR,
|
|
145
|
+
payload: {
|
|
146
|
+
code: "INVALID_MESSAGE",
|
|
147
|
+
message: "Malformed message",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const message = rawMessage;
|
|
154
|
+
this.log(`[GameRuntime] Msg from ${connectionId}:`, message);
|
|
155
|
+
|
|
156
|
+
switch (message.type) {
|
|
157
|
+
case MessageTypes.JOIN: {
|
|
158
|
+
const { secret, ...payload } = message.payload;
|
|
159
|
+
|
|
160
|
+
if (!secret || typeof secret !== "string" || !isValidSecret(secret)) {
|
|
161
|
+
this.send(connectionId, {
|
|
162
|
+
type: MessageTypes.ERROR,
|
|
163
|
+
payload: {
|
|
164
|
+
code: "INVALID_SECRET",
|
|
165
|
+
message: "Invalid or missing session secret",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
this.joinedConnections.has(connectionId) ||
|
|
173
|
+
this.pendingJoins.has(connectionId)
|
|
174
|
+
) {
|
|
175
|
+
this.send(connectionId, {
|
|
176
|
+
type: MessageTypes.ERROR,
|
|
177
|
+
payload: {
|
|
178
|
+
code: "ALREADY_JOINED",
|
|
179
|
+
message: "This connection has already joined the game",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.pendingJoins.add(connectionId);
|
|
186
|
+
try {
|
|
187
|
+
const { playerId, isReconnect, action } =
|
|
188
|
+
await this.sessionManager.handleJoin<S>(
|
|
189
|
+
connectionId,
|
|
190
|
+
message.payload as JoinSessionPayload,
|
|
191
|
+
() => this.state.players,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
this.activeConnections.get(connectionId) !== connectionGeneration
|
|
196
|
+
) {
|
|
197
|
+
this.sessionManager.abandonConnection(connectionId);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.applyAction(action);
|
|
202
|
+
this.joinedConnections.add(connectionId);
|
|
203
|
+
this.assetsLoaded.set(playerId, false);
|
|
204
|
+
this.invokeLifecycleCallback(
|
|
205
|
+
"onPlayerJoined",
|
|
206
|
+
this.config.onPlayerJoined
|
|
207
|
+
? () => {
|
|
208
|
+
this.config.onPlayerJoined?.(playerId, payload.name);
|
|
209
|
+
}
|
|
210
|
+
: undefined,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (isReconnect) {
|
|
214
|
+
this.send(connectionId, {
|
|
215
|
+
type: MessageTypes.RECONNECTED,
|
|
216
|
+
payload: {
|
|
217
|
+
playerId,
|
|
218
|
+
state: this.state,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
this.send(connectionId, {
|
|
223
|
+
type: MessageTypes.WELCOME,
|
|
224
|
+
payload: {
|
|
225
|
+
playerId,
|
|
226
|
+
state: this.state,
|
|
227
|
+
serverTime: Date.now(),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.log("[GameRuntime] Failed to derive player ID:", error);
|
|
233
|
+
this.send(connectionId, {
|
|
234
|
+
type: MessageTypes.ERROR,
|
|
235
|
+
payload: {
|
|
236
|
+
code: "JOIN_FAILED",
|
|
237
|
+
message: "Failed to process join request",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
this.pendingJoins.delete(connectionId);
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case MessageTypes.ACTION: {
|
|
247
|
+
const actionPayload = message.payload as A;
|
|
248
|
+
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
249
|
+
const authorization = authorizeClientAction(
|
|
250
|
+
actionPayload.type,
|
|
251
|
+
playerId,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (authorization.kind === "reject") {
|
|
255
|
+
this.log(
|
|
256
|
+
`[GameRuntime] Rejected action from ${connectionId} ` +
|
|
257
|
+
`(${authorization.code}):`,
|
|
258
|
+
actionPayload.type,
|
|
259
|
+
);
|
|
260
|
+
this.send(connectionId, {
|
|
261
|
+
type: MessageTypes.ERROR,
|
|
262
|
+
payload: {
|
|
263
|
+
code: authorization.code,
|
|
264
|
+
message: authorization.message,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!this.rateLimiter.record(connectionId).allowed) {
|
|
271
|
+
this.log(`[GameRuntime] Rate limited ${connectionId}`);
|
|
272
|
+
this.send(connectionId, {
|
|
273
|
+
type: MessageTypes.ERROR,
|
|
274
|
+
payload: {
|
|
275
|
+
code: "RATE_LIMITED",
|
|
276
|
+
message: "Too many actions, slow down",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.applyAction({
|
|
283
|
+
...actionPayload,
|
|
284
|
+
playerId: authorization.playerId,
|
|
285
|
+
} as A);
|
|
286
|
+
this.actionQueue.push(actionPayload);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case MessageTypes.PING:
|
|
291
|
+
this.send(connectionId, {
|
|
292
|
+
type: MessageTypes.PONG,
|
|
293
|
+
payload: {
|
|
294
|
+
id: message.payload.id,
|
|
295
|
+
origTimestamp: message.payload.timestamp,
|
|
296
|
+
serverTime: Date.now(),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
break;
|
|
300
|
+
|
|
301
|
+
case MessageTypes.ASSETS_LOADED: {
|
|
302
|
+
const playerId = this.sessionManager.getPlayerIdForSocket(connectionId);
|
|
303
|
+
if (playerId) {
|
|
304
|
+
this.assetsLoaded.set(playerId, true);
|
|
305
|
+
this.log(`[GameRuntime] Assets loaded for ${playerId}`);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Processes a transport disconnect and starts session recovery cleanup. */
|
|
313
|
+
handleDisconnect(connectionId: string): void {
|
|
314
|
+
this.log(`[GameRuntime] Client disconnected: ${connectionId}`);
|
|
315
|
+
this.activeConnections.delete(connectionId);
|
|
316
|
+
this.joinedConnections.delete(connectionId);
|
|
317
|
+
this.rateLimiter.reset(connectionId);
|
|
318
|
+
|
|
319
|
+
const result = this.sessionManager.handleDisconnect<S>(connectionId);
|
|
320
|
+
if (result.kind === "unknown") return;
|
|
321
|
+
|
|
322
|
+
this.assetsLoaded.delete(result.playerId);
|
|
323
|
+
if (result.kind === "stale") return;
|
|
324
|
+
|
|
325
|
+
this.applyAction(result.action);
|
|
326
|
+
this.invokeLifecycleCallback(
|
|
327
|
+
"onPlayerLeft",
|
|
328
|
+
this.config.onPlayerLeft
|
|
329
|
+
? () => {
|
|
330
|
+
this.config.onPlayerLeft?.(result.playerId);
|
|
331
|
+
}
|
|
332
|
+
: undefined,
|
|
333
|
+
);
|
|
334
|
+
this.sessionManager.scheduleRemoval(
|
|
335
|
+
result.playerId,
|
|
336
|
+
result.secret,
|
|
337
|
+
(playerId) => {
|
|
338
|
+
this.applyAction({
|
|
339
|
+
type: InternalActionTypes.PLAYER_REMOVED,
|
|
340
|
+
payload: { playerId },
|
|
341
|
+
} as InternalAction<S>);
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Surfaces transport failures through the configured host callback. */
|
|
347
|
+
handleError(error: Error): void {
|
|
348
|
+
this.log("[GameRuntime] Transport error:", error);
|
|
349
|
+
this.notifyError(error);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Cancels runtime timers and releases transport-specific state. */
|
|
353
|
+
stop(): void {
|
|
354
|
+
this.broadcastScheduler.cancel();
|
|
355
|
+
this.sessionManager.clearRemovalTimers();
|
|
356
|
+
this.rateLimiter.clear();
|
|
357
|
+
this.activeConnections.clear();
|
|
358
|
+
this.joinedConnections.clear();
|
|
359
|
+
this.pendingJoins.clear();
|
|
360
|
+
this.transport = null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private applyAction(action: A | InternalAction<S>): void {
|
|
364
|
+
const nextState = this.reducer(this.state, action);
|
|
365
|
+
if (Object.is(nextState, this.state)) return;
|
|
366
|
+
|
|
367
|
+
this.state = nextState;
|
|
368
|
+
this.stateDirty = true;
|
|
369
|
+
|
|
370
|
+
for (const listener of this.listeners) {
|
|
371
|
+
listener();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.broadcastScheduler.schedule(this.broadcastState);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private readonly broadcastState = (): void => {
|
|
378
|
+
if (!this.transport) return;
|
|
379
|
+
|
|
380
|
+
const actions = this.actionQueue;
|
|
381
|
+
this.actionQueue = [];
|
|
382
|
+
this.stateDirty = false;
|
|
383
|
+
this.transport.broadcast(createStateUpdateMessage(this.state, actions));
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
private send(connectionId: string, message: HostMessage): void {
|
|
387
|
+
this.transport?.send(connectionId, message);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private invokeLifecycleCallback(
|
|
391
|
+
name: string,
|
|
392
|
+
callback: (() => void) | undefined,
|
|
393
|
+
): void {
|
|
394
|
+
if (!callback) return;
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
callback();
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
400
|
+
this.notifyError(
|
|
401
|
+
new Error(`${name} callback failed: ${cause.message}`, { cause }),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private notifyError(error: Error): void {
|
|
407
|
+
const onError = this.config.onError;
|
|
408
|
+
if (!onError) {
|
|
409
|
+
console.error("[GameRuntime] Unhandled error:", error);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
onError(error);
|
|
415
|
+
} catch (callbackError) {
|
|
416
|
+
console.error("[GameRuntime] onError callback failed:", callbackError);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private log(...args: unknown[]): void {
|
|
421
|
+
if (this.config.debug) {
|
|
422
|
+
console.log(...args);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DISCONNECT_TIMEOUT,
|
|
3
|
+
InternalActionTypes,
|
|
4
|
+
derivePlayerId,
|
|
5
|
+
derivePlayerIdLegacy,
|
|
6
|
+
type IGameState,
|
|
7
|
+
type InternalAction,
|
|
8
|
+
} from "@couch-kit/core";
|
|
9
|
+
|
|
10
|
+
export interface SessionTimerScheduler<TTimer> {
|
|
11
|
+
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
12
|
+
clearTimeout(timer: TTimer): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultSessionTimerScheduler: SessionTimerScheduler<
|
|
16
|
+
ReturnType<typeof setTimeout>
|
|
17
|
+
> = {
|
|
18
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
19
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface JoinSessionPayload {
|
|
23
|
+
name: string;
|
|
24
|
+
avatar?: string;
|
|
25
|
+
secret: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HostSessionManagerOptions<TTimer> {
|
|
29
|
+
disconnectTimeout?: number;
|
|
30
|
+
getDisconnectTimeout?: () => number;
|
|
31
|
+
scheduler?: SessionTimerScheduler<TTimer>;
|
|
32
|
+
derivePlayerId?: (secret: string) => Promise<string>;
|
|
33
|
+
derivePlayerIdLegacy?: (secret: string) => string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type JoinSessionResult<S extends IGameState> = {
|
|
37
|
+
playerId: string;
|
|
38
|
+
socketId: string;
|
|
39
|
+
secret: string;
|
|
40
|
+
isReconnect: boolean;
|
|
41
|
+
action: InternalAction<S>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DisconnectSessionResult<S extends IGameState> =
|
|
45
|
+
| { kind: "unknown" }
|
|
46
|
+
| { kind: "stale"; playerId: string; secret: string }
|
|
47
|
+
| {
|
|
48
|
+
kind: "left";
|
|
49
|
+
playerId: string;
|
|
50
|
+
secret: string;
|
|
51
|
+
action: InternalAction<S>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PlayersSource<S extends IGameState> = S["players"] | (() => S["players"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tracks transport-neutral player sessions by secret and connection ID,
|
|
58
|
+
* including reconnects and delayed cleanup of disconnected players.
|
|
59
|
+
*/
|
|
60
|
+
export class HostSessionManager<TTimer = ReturnType<typeof setTimeout>> {
|
|
61
|
+
private readonly sessions = new Map<string, string>();
|
|
62
|
+
private readonly reverseMap = new Map<string, string>();
|
|
63
|
+
private readonly cleanupTimers = new Map<string, TTimer>();
|
|
64
|
+
private readonly socketIdToPlayerId = new Map<string, string>();
|
|
65
|
+
private readonly scheduler: SessionTimerScheduler<TTimer>;
|
|
66
|
+
private readonly getDisconnectTimeout: () => number;
|
|
67
|
+
private readonly derivePlayerIdFn: (secret: string) => Promise<string>;
|
|
68
|
+
private readonly derivePlayerIdLegacyFn: (secret: string) => string;
|
|
69
|
+
|
|
70
|
+
constructor(options: HostSessionManagerOptions<TTimer> = {}) {
|
|
71
|
+
this.scheduler =
|
|
72
|
+
options.scheduler ??
|
|
73
|
+
(defaultSessionTimerScheduler as unknown as SessionTimerScheduler<TTimer>);
|
|
74
|
+
this.getDisconnectTimeout =
|
|
75
|
+
options.getDisconnectTimeout ??
|
|
76
|
+
(() => options.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT);
|
|
77
|
+
this.derivePlayerIdFn = options.derivePlayerId ?? derivePlayerId;
|
|
78
|
+
this.derivePlayerIdLegacyFn =
|
|
79
|
+
options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async handleJoin<S extends IGameState>(
|
|
83
|
+
socketId: string,
|
|
84
|
+
payload: JoinSessionPayload,
|
|
85
|
+
playersSource: PlayersSource<S>,
|
|
86
|
+
): Promise<JoinSessionResult<S>> {
|
|
87
|
+
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
88
|
+
const players =
|
|
89
|
+
typeof playersSource === "function" ? playersSource() : playersSource;
|
|
90
|
+
let playerId = hashedId;
|
|
91
|
+
|
|
92
|
+
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
93
|
+
if (!players[playerId] && players[legacyId]) {
|
|
94
|
+
playerId = legacyId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.socketIdToPlayerId.set(socketId, playerId);
|
|
98
|
+
this.sessions.set(payload.secret, socketId);
|
|
99
|
+
this.reverseMap.set(socketId, payload.secret);
|
|
100
|
+
this.cancelRemoval(playerId);
|
|
101
|
+
|
|
102
|
+
const isReconnect = !!players[playerId];
|
|
103
|
+
const action = isReconnect
|
|
104
|
+
? ({
|
|
105
|
+
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
106
|
+
payload: { playerId },
|
|
107
|
+
} as InternalAction<S>)
|
|
108
|
+
: ({
|
|
109
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
110
|
+
payload: { id: playerId, name: payload.name, avatar: payload.avatar },
|
|
111
|
+
} as InternalAction<S>);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
playerId,
|
|
115
|
+
socketId,
|
|
116
|
+
secret: payload.secret,
|
|
117
|
+
isReconnect,
|
|
118
|
+
action,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
handleDisconnect<S extends IGameState>(
|
|
123
|
+
socketId: string,
|
|
124
|
+
): DisconnectSessionResult<S> {
|
|
125
|
+
const playerId = this.socketIdToPlayerId.get(socketId);
|
|
126
|
+
this.socketIdToPlayerId.delete(socketId);
|
|
127
|
+
|
|
128
|
+
const secret = this.reverseMap.get(socketId);
|
|
129
|
+
this.reverseMap.delete(socketId);
|
|
130
|
+
|
|
131
|
+
if (!playerId || !secret) return { kind: "unknown" };
|
|
132
|
+
|
|
133
|
+
if (this.sessions.get(secret) !== socketId) {
|
|
134
|
+
return { kind: "stale", playerId, secret };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
kind: "left",
|
|
139
|
+
playerId,
|
|
140
|
+
secret,
|
|
141
|
+
action: {
|
|
142
|
+
type: InternalActionTypes.PLAYER_LEFT,
|
|
143
|
+
payload: { playerId },
|
|
144
|
+
} as InternalAction<S>,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
scheduleRemoval(
|
|
149
|
+
playerId: string,
|
|
150
|
+
secret: string,
|
|
151
|
+
onRemove: (playerId: string) => void,
|
|
152
|
+
): void {
|
|
153
|
+
const timer = this.scheduler.setTimeout(() => {
|
|
154
|
+
this.cleanupTimers.delete(playerId);
|
|
155
|
+
this.sessions.delete(secret);
|
|
156
|
+
onRemove(playerId);
|
|
157
|
+
}, this.getDisconnectTimeout());
|
|
158
|
+
|
|
159
|
+
this.cleanupTimers.set(playerId, timer);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cancelRemoval(playerId: string): void {
|
|
163
|
+
const existingTimer = this.cleanupTimers.get(playerId);
|
|
164
|
+
if (existingTimer) {
|
|
165
|
+
this.scheduler.clearTimeout(existingTimer);
|
|
166
|
+
this.cleanupTimers.delete(playerId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clearRemovalTimers(): void {
|
|
171
|
+
for (const timer of this.cleanupTimers.values()) {
|
|
172
|
+
this.scheduler.clearTimeout(timer);
|
|
173
|
+
}
|
|
174
|
+
this.cleanupTimers.clear();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getPlayerIdForSocket(socketId: string): string | undefined {
|
|
178
|
+
const secret = this.reverseMap.get(socketId);
|
|
179
|
+
if (!secret || this.sessions.get(secret) !== socketId) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
return this.socketIdToPlayerId.get(socketId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
abandonConnection(socketId: string): void {
|
|
186
|
+
const secret = this.reverseMap.get(socketId);
|
|
187
|
+
this.socketIdToPlayerId.delete(socketId);
|
|
188
|
+
this.reverseMap.delete(socketId);
|
|
189
|
+
|
|
190
|
+
if (secret && this.sessions.get(secret) === socketId) {
|
|
191
|
+
this.sessions.delete(secret);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getSocketIdForSecret(secret: string): string | undefined {
|
|
196
|
+
return this.sessions.get(secret);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
hasPendingRemoval(playerId: string): boolean {
|
|
200
|
+
return this.cleanupTimers.has(playerId);
|
|
201
|
+
}
|
|
202
|
+
}
|