@phalanx-engine/client 0.1.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.
Files changed (94) hide show
  1. package/README.md +1037 -0
  2. package/dist/DesyncDetector.d.ts +80 -0
  3. package/dist/DesyncDetector.d.ts.map +1 -0
  4. package/dist/DesyncDetector.js +93 -0
  5. package/dist/DesyncDetector.js.map +1 -0
  6. package/dist/DeterministicRandom.d.ts +78 -0
  7. package/dist/DeterministicRandom.d.ts.map +1 -0
  8. package/dist/DeterministicRandom.js +122 -0
  9. package/dist/DeterministicRandom.js.map +1 -0
  10. package/dist/EventEmitter.d.ts +65 -0
  11. package/dist/EventEmitter.d.ts.map +1 -0
  12. package/dist/EventEmitter.js +102 -0
  13. package/dist/EventEmitter.js.map +1 -0
  14. package/dist/FixedMath.d.ts +22 -0
  15. package/dist/FixedMath.d.ts.map +1 -0
  16. package/dist/FixedMath.js +26 -0
  17. package/dist/FixedMath.js.map +1 -0
  18. package/dist/PhalanxClient.d.ts +335 -0
  19. package/dist/PhalanxClient.d.ts.map +1 -0
  20. package/dist/PhalanxClient.js +844 -0
  21. package/dist/PhalanxClient.js.map +1 -0
  22. package/dist/RenderLoop.d.ts +95 -0
  23. package/dist/RenderLoop.d.ts.map +1 -0
  24. package/dist/RenderLoop.js +192 -0
  25. package/dist/RenderLoop.js.map +1 -0
  26. package/dist/SocketManager.d.ts +228 -0
  27. package/dist/SocketManager.d.ts.map +1 -0
  28. package/dist/SocketManager.js +584 -0
  29. package/dist/SocketManager.js.map +1 -0
  30. package/dist/StateHasher.d.ts +76 -0
  31. package/dist/StateHasher.d.ts.map +1 -0
  32. package/dist/StateHasher.js +129 -0
  33. package/dist/StateHasher.js.map +1 -0
  34. package/dist/auth/AuthManager.d.ts +188 -0
  35. package/dist/auth/AuthManager.d.ts.map +1 -0
  36. package/dist/auth/AuthManager.js +462 -0
  37. package/dist/auth/AuthManager.js.map +1 -0
  38. package/dist/auth/adapters/GoogleOAuthAdapter.d.ts +164 -0
  39. package/dist/auth/adapters/GoogleOAuthAdapter.d.ts.map +1 -0
  40. package/dist/auth/adapters/GoogleOAuthAdapter.js +521 -0
  41. package/dist/auth/adapters/GoogleOAuthAdapter.js.map +1 -0
  42. package/dist/auth/index.d.ts +45 -0
  43. package/dist/auth/index.d.ts.map +1 -0
  44. package/dist/auth/index.js +54 -0
  45. package/dist/auth/index.js.map +1 -0
  46. package/dist/auth/storage.d.ts +56 -0
  47. package/dist/auth/storage.d.ts.map +1 -0
  48. package/dist/auth/storage.js +78 -0
  49. package/dist/auth/storage.js.map +1 -0
  50. package/dist/auth/types.d.ts +212 -0
  51. package/dist/auth/types.d.ts.map +1 -0
  52. package/dist/auth/types.js +7 -0
  53. package/dist/auth/types.js.map +1 -0
  54. package/dist/index.d.ts +70 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +83 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/recovery/BrowserLifecycle.d.ts +33 -0
  59. package/dist/recovery/BrowserLifecycle.d.ts.map +1 -0
  60. package/dist/recovery/BrowserLifecycle.js +62 -0
  61. package/dist/recovery/BrowserLifecycle.js.map +1 -0
  62. package/dist/recovery/GuestPlayerIdStore.d.ts +17 -0
  63. package/dist/recovery/GuestPlayerIdStore.d.ts.map +1 -0
  64. package/dist/recovery/GuestPlayerIdStore.js +31 -0
  65. package/dist/recovery/GuestPlayerIdStore.js.map +1 -0
  66. package/dist/recovery/KeyValueStorage.d.ts +32 -0
  67. package/dist/recovery/KeyValueStorage.d.ts.map +1 -0
  68. package/dist/recovery/KeyValueStorage.js +58 -0
  69. package/dist/recovery/KeyValueStorage.js.map +1 -0
  70. package/dist/recovery/MobileTransport.d.ts +12 -0
  71. package/dist/recovery/MobileTransport.d.ts.map +1 -0
  72. package/dist/recovery/MobileTransport.js +24 -0
  73. package/dist/recovery/MobileTransport.js.map +1 -0
  74. package/dist/recovery/NetworkQuality.d.ts +22 -0
  75. package/dist/recovery/NetworkQuality.d.ts.map +1 -0
  76. package/dist/recovery/NetworkQuality.js +35 -0
  77. package/dist/recovery/NetworkQuality.js.map +1 -0
  78. package/dist/recovery/RoomPersistence.d.ts +55 -0
  79. package/dist/recovery/RoomPersistence.d.ts.map +1 -0
  80. package/dist/recovery/RoomPersistence.js +68 -0
  81. package/dist/recovery/RoomPersistence.js.map +1 -0
  82. package/dist/recovery/RoomRecoveryController.d.ts +146 -0
  83. package/dist/recovery/RoomRecoveryController.d.ts.map +1 -0
  84. package/dist/recovery/RoomRecoveryController.js +348 -0
  85. package/dist/recovery/RoomRecoveryController.js.map +1 -0
  86. package/dist/recovery/index.d.ts +13 -0
  87. package/dist/recovery/index.d.ts.map +1 -0
  88. package/dist/recovery/index.js +8 -0
  89. package/dist/recovery/index.js.map +1 -0
  90. package/dist/types.d.ts +501 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +6 -0
  93. package/dist/types.js.map +1 -0
  94. package/package.json +66 -0
@@ -0,0 +1,844 @@
1
+ /**
2
+ * Phalanx Client
3
+ * Client library for connecting to Phalanx Engine servers
4
+ */
5
+ import { EventEmitter } from './EventEmitter.js';
6
+ import { RenderLoop } from './RenderLoop.js';
7
+ import { SocketManager } from './SocketManager.js';
8
+ import { DesyncDetector } from './DesyncDetector.js';
9
+ import { AuthManager } from './auth/AuthManager.js';
10
+ import { RoomRecoveryController, } from './recovery/index.js';
11
+ import { pickMobileFriendlyTransports, loadOrCreateGuestPlayerId } from './recovery/index.js';
12
+ /**
13
+ * PhalanxClient - Main client class for connecting to Phalanx Engine servers
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const client = await PhalanxClient.create({
18
+ * serverUrl: 'http://localhost:3000',
19
+ * playerId: 'player-123',
20
+ * username: 'MyPlayer',
21
+ * });
22
+ *
23
+ * client.on('matchFound', (data) => console.log('Match found!'));
24
+ * client.on('gameStart', () => console.log('Game started!'));
25
+ *
26
+ * await client.joinQueue();
27
+ *
28
+ * client.onTick((tick, commands) => {
29
+ * // Process commands and run simulation
30
+ * });
31
+ *
32
+ * client.onFrame((alpha, dt) => {
33
+ * // Interpolate and render
34
+ * });
35
+ * ```
36
+ */
37
+ export class PhalanxClient extends EventEmitter {
38
+ config;
39
+ socketManager;
40
+ renderLoop;
41
+ desyncDetector;
42
+ authManager = null;
43
+ _roomRecovery = null;
44
+ // State
45
+ clientState = 'idle';
46
+ currentMatchId = null;
47
+ currentTick = 0;
48
+ // Auth state
49
+ authState = {
50
+ isAuthenticated: false,
51
+ isLoading: true,
52
+ user: null,
53
+ };
54
+ // Pending commands queue
55
+ pendingCommands = [];
56
+ // ITickFrameProvider pause/resume handler arrays
57
+ pauseHandlers = [];
58
+ resumeHandlers = [];
59
+ constructor(config) {
60
+ super();
61
+ // Generate default player ID if not provided
62
+ const defaultPlayerId = `player-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
63
+ // Persist guest player id (across reloads) if requested. Auth, when
64
+ // enabled, will overwrite this with the real user id once the auth
65
+ // flow resolves; until then (and for guests forever) this stays
66
+ // stable across reloads — required for cold-start room recovery.
67
+ let resolvedPlayerId = config.playerId;
68
+ if (!resolvedPlayerId && config.persistGuestPlayerId) {
69
+ const key = typeof config.persistGuestPlayerId === 'string'
70
+ ? config.persistGuestPlayerId
71
+ : 'phalanx:guestPlayerId:v1';
72
+ resolvedPlayerId = loadOrCreateGuestPlayerId(key);
73
+ }
74
+ // Resolve socket transports: explicit `socketTransports` wins; else
75
+ // the opt-in `mobileFriendlyTransports` flag picks polling-on-mobile;
76
+ // else fall back to the historic websocket-only default.
77
+ const resolvedSocketTransports = config.socketTransports ??
78
+ (config.mobileFriendlyTransports
79
+ ? pickMobileFriendlyTransports()
80
+ : ['websocket']);
81
+ this.config = {
82
+ autoReconnect: true,
83
+ maxReconnectAttempts: 5,
84
+ reconnectDelayMs: 1000,
85
+ connectionTimeoutMs: 10000,
86
+ recoverRoomTimeoutMs: 10000,
87
+ mobileFriendlyTransports: false,
88
+ persistGuestPlayerId: false,
89
+ tickRate: 20,
90
+ debug: false,
91
+ ...config,
92
+ socketTransports: resolvedSocketTransports,
93
+ playerId: resolvedPlayerId || defaultPlayerId,
94
+ username: config.username || `Player-${(resolvedPlayerId || defaultPlayerId).slice(-6)}`,
95
+ };
96
+ // Initialize auth if configured
97
+ if (config.auth) {
98
+ this.initializeAuth(config.auth);
99
+ }
100
+ else {
101
+ this.authState.isLoading = false;
102
+ }
103
+ // Initialize SocketManager with callbacks
104
+ this.socketManager = new SocketManager({
105
+ serverUrl: this.config.serverUrl,
106
+ playerId: this.config.playerId,
107
+ username: this.config.username,
108
+ authToken: this.config.authToken,
109
+ connectionTimeoutMs: this.config.connectionTimeoutMs,
110
+ recoverRoomTimeoutMs: this.config.recoverRoomTimeoutMs,
111
+ socketTransports: this.config.socketTransports,
112
+ autoReconnect: this.config.autoReconnect,
113
+ maxReconnectAttempts: this.config.maxReconnectAttempts,
114
+ reconnectDelayMs: this.config.reconnectDelayMs,
115
+ debug: this.config.debug,
116
+ }, {
117
+ // Connection events
118
+ onConnected: () => this.emit('connected'),
119
+ onDisconnected: () => {
120
+ this.clientState = 'idle';
121
+ this.emit('disconnected');
122
+ },
123
+ onReconnecting: (attempt) => this.emit('reconnecting', attempt),
124
+ onReconnectFailed: () => this.emit('reconnectFailed'),
125
+ onError: (error) => this.emit('error', error),
126
+ // Match lifecycle events
127
+ onMatchFound: (data) => {
128
+ this.currentMatchId = data.matchId;
129
+ this.clientState = 'match-found';
130
+ this.emit('matchFound', data);
131
+ },
132
+ onCountdown: (data) => this.emit('countdown', data),
133
+ onGameStart: (data) => {
134
+ this.clientState = 'playing';
135
+ this.currentTick = 0;
136
+ this.emit('gameStart', data);
137
+ },
138
+ onMatchEnd: (data) => {
139
+ this.clientState = 'finished';
140
+ this.emit('matchEnd', data);
141
+ },
142
+ // Tick events
143
+ onTickSync: (data) => {
144
+ this.currentTick = data.tick;
145
+ this.renderLoop.updateTickTime();
146
+ this.emit('tick', data);
147
+ },
148
+ onCommandsBatch: (data) => {
149
+ this.currentTick = data.tick;
150
+ this.renderLoop.processTick(data.tick, data.commands);
151
+ this.emit('commands', data);
152
+ },
153
+ // Player events
154
+ onPlayerDisconnected: (data) => this.emit('playerDisconnected', data),
155
+ onPlayerReconnected: (data) => this.emit('playerReconnected', data),
156
+ onPlayerReady: (data) => this.emit('playerReady', data),
157
+ // Reconnection events
158
+ onReconnectState: (data) => {
159
+ this.currentMatchId = data.matchId;
160
+ this.currentTick = data.currentTick;
161
+ if (data.state === 'paused') {
162
+ this.clientState = 'paused';
163
+ }
164
+ else if (data.state === 'playing') {
165
+ this.clientState = 'playing';
166
+ }
167
+ else {
168
+ this.clientState = 'idle';
169
+ }
170
+ this.emit('reconnectState', data);
171
+ },
172
+ onReconnectStatus: (data) => this.emit('reconnectStatus', data),
173
+ // Pause events
174
+ onGamePaused: (data) => {
175
+ this.clientState = 'paused';
176
+ this.emit('gamePaused', data);
177
+ // Notify ITickFrameProvider subscribers (GameWorld listens here)
178
+ for (const handler of this.pauseHandlers)
179
+ handler();
180
+ },
181
+ onGameResumed: (data) => {
182
+ this.clientState = 'playing';
183
+ this.emit('gameResumed', data);
184
+ // Notify ITickFrameProvider subscribers (GameWorld listens here)
185
+ for (const handler of this.resumeHandlers)
186
+ handler();
187
+ },
188
+ // Desync detection events
189
+ onHashComparison: (data) => {
190
+ if (!this.desyncDetector.isEnabled())
191
+ return;
192
+ const hasDesync = !this.desyncDetector.compareWithRemote(data.tick, data.hashes);
193
+ if (hasDesync) {
194
+ const localHash = this.desyncDetector.getLocalHash(data.tick);
195
+ this.emit('desync', {
196
+ tick: data.tick,
197
+ localHash: localHash ?? 'unknown',
198
+ remoteHashes: data.hashes,
199
+ });
200
+ }
201
+ },
202
+ // State queries
203
+ isPlaying: () => this.clientState === 'playing',
204
+ getCurrentMatchId: () => this.currentMatchId,
205
+ // Private room events
206
+ onRoomError: (data) => this.emit('roomError', data),
207
+ onRoomExpired: (data) => this.emit('roomExpired', data),
208
+ onRoomCancelled: (data) => {
209
+ this.clientState = 'idle';
210
+ this.emit('roomCancelled', data);
211
+ },
212
+ onRoomRecovered: (data) => this.emit('roomRecovered', data),
213
+ });
214
+ // Initialize DesyncDetector
215
+ this.desyncDetector = new DesyncDetector();
216
+ // Initialize RenderLoop
217
+ this.renderLoop = new RenderLoop({
218
+ tickRate: this.config.tickRate,
219
+ debug: this.config.debug,
220
+ });
221
+ // Set up command flushing
222
+ this.renderLoop.setCommandFlushCallback(() => this.flushPendingCommands());
223
+ // Handle OAuth callback if present in URL
224
+ if (config.auth && typeof window !== 'undefined') {
225
+ void this.handleAuthCallback();
226
+ }
227
+ // Optionally construct the room recovery controller. Built lazily
228
+ // (after socketManager exists) because it routes through this very
229
+ // client's `connect`/`disconnect`/`recoverRoom`/`on(...)` surface.
230
+ if (config.roomRecovery?.enabled) {
231
+ this._roomRecovery = this.createRoomRecoveryController(config.roomRecovery);
232
+ }
233
+ }
234
+ createRoomRecoveryController(cfg) {
235
+ const port = {
236
+ connect: () => this.connect(),
237
+ disconnect: () => this.socketManager.disconnect(),
238
+ isConnected: () => this.isConnected(),
239
+ recoverRoom: (code, timeoutMs) => this.recoverRoom(code, timeoutMs),
240
+ getPlayerId: () => this.getPlayerId(),
241
+ on: (event, handler) => this.on(event, handler),
242
+ };
243
+ return new RoomRecoveryController(port, {
244
+ storageKey: cfg.storageKey ?? 'phalanx:activeRoom:v1',
245
+ roomTtlMs: cfg.roomTtlMs ?? 5 * 60 * 1000,
246
+ storage: cfg.storage,
247
+ recoverTimeoutBudget: cfg.recoverTimeoutBudget,
248
+ maxRecoverAttempts: cfg.maxRecoverAttempts,
249
+ preGameStallWatchdog: cfg.preGameStallWatchdog,
250
+ preGameStallMs: cfg.preGameStallMs,
251
+ },
252
+ // Forward controller events through the client emitter so games
253
+ // discover them alongside the existing roomExpired/roomCancelled.
254
+ (event, ...args) => {
255
+ // The controller's event map is a strict subset of
256
+ // PhalanxClientEvents; cast through `unknown` for ts to accept.
257
+ this.emit(event, ...args);
258
+ });
259
+ }
260
+ /**
261
+ * Mobile-friendly room recovery controller. `null` unless
262
+ * `roomRecovery.enabled` was set in the config. See
263
+ * `RoomRecoveryController` for the surface — typically you only
264
+ * need `startTrackingHost(code)` after creating a private room and
265
+ * `loadColdStartCode()` on app startup.
266
+ */
267
+ get roomRecovery() {
268
+ return this._roomRecovery;
269
+ }
270
+ // ============================================
271
+ // AUTHENTICATION
272
+ // ============================================
273
+ /**
274
+ * Initialize authentication manager
275
+ */
276
+ initializeAuth(authConfig) {
277
+ if (authConfig.provider !== 'google' || !authConfig.google) {
278
+ console.warn('[PhalanxClient] Invalid auth config - only Google is supported');
279
+ this.authState.isLoading = false;
280
+ return;
281
+ }
282
+ this.authManager = new AuthManager({
283
+ provider: 'google',
284
+ google: {
285
+ clientId: authConfig.google.clientId,
286
+ scopes: authConfig.google.scopes || ['openid', 'profile', 'email'],
287
+ redirectUri: authConfig.google.redirectUri || (typeof window !== 'undefined' ? window.location.origin : undefined),
288
+ tokenExchangeUrl: authConfig.google.tokenExchangeUrl,
289
+ },
290
+ debug: this.config.debug,
291
+ onAuthStateChange: (state) => {
292
+ this.handleAuthStateChange(state);
293
+ },
294
+ onAuthError: (error) => {
295
+ this.emit('authError', { message: error.message });
296
+ },
297
+ });
298
+ // Check for existing session
299
+ void this.authManager.checkSession().then(() => {
300
+ // Session check complete - state already updated via onAuthStateChange
301
+ });
302
+ }
303
+ /**
304
+ * Handle auth state changes from AuthManager
305
+ */
306
+ handleAuthStateChange(state) {
307
+ const user = state.user ? {
308
+ id: state.user.id,
309
+ username: state.user.username,
310
+ email: state.user.email,
311
+ avatarUrl: state.user.avatarUrl,
312
+ provider: state.provider || 'google',
313
+ } : null;
314
+ this.authState = {
315
+ isAuthenticated: state.isAuthenticated,
316
+ isLoading: state.isLoading,
317
+ user,
318
+ };
319
+ // Update config with auth user info
320
+ if (user) {
321
+ this.config.playerId = user.id;
322
+ this.config.username = user.username || user.email || `Player-${user.id.slice(-6)}`;
323
+ this.config.authToken = state.token || undefined;
324
+ // Update socket manager with new credentials
325
+ this.socketManager.updateCredentials(user.id, this.config.username, state.token || undefined);
326
+ }
327
+ this.emit('authStateChanged', this.authState);
328
+ }
329
+ /**
330
+ * Handle OAuth callback from redirect
331
+ */
332
+ async handleAuthCallback() {
333
+ if (!this.authManager)
334
+ return;
335
+ const params = new URLSearchParams(window.location.search);
336
+ const code = params.get('code');
337
+ const error = params.get('error');
338
+ if (!code && !error)
339
+ return;
340
+ this.log('Handling OAuth callback...');
341
+ try {
342
+ const result = await this.authManager.handleCallback({
343
+ code: code || undefined,
344
+ state: params.get('state') || undefined,
345
+ error: error || undefined,
346
+ errorDescription: params.get('error_description') || undefined,
347
+ url: window.location.href,
348
+ });
349
+ if (!result.valid) {
350
+ this.emit('authError', { message: result.error || 'Authentication failed' });
351
+ }
352
+ }
353
+ catch (err) {
354
+ this.emit('authError', {
355
+ message: err instanceof Error ? err.message : 'Authentication failed'
356
+ });
357
+ }
358
+ // Clean up URL
359
+ const cleanUrl = window.location.origin + window.location.pathname;
360
+ window.history.replaceState({}, document.title, cleanUrl);
361
+ }
362
+ /**
363
+ * Start login flow (redirects to OAuth provider)
364
+ */
365
+ login() {
366
+ if (!this.authManager) {
367
+ this.emit('authError', { message: 'Authentication not configured' });
368
+ return;
369
+ }
370
+ this.authManager.login();
371
+ }
372
+ /**
373
+ * Log out the current user
374
+ */
375
+ async logout() {
376
+ if (!this.authManager)
377
+ return;
378
+ await this.authManager.logout();
379
+ // Reset to anonymous player
380
+ const defaultPlayerId = `player-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
381
+ this.config.playerId = defaultPlayerId;
382
+ this.config.username = `Player-${defaultPlayerId.slice(-6)}`;
383
+ this.config.authToken = undefined;
384
+ }
385
+ /**
386
+ * Get current authentication state
387
+ */
388
+ getAuthState() {
389
+ return { ...this.authState };
390
+ }
391
+ /**
392
+ * Check if user is authenticated
393
+ */
394
+ isAuthenticated() {
395
+ return this.authState.isAuthenticated;
396
+ }
397
+ /**
398
+ * Get current user info
399
+ */
400
+ getUser() {
401
+ return this.authState.user;
402
+ }
403
+ // ============================================
404
+ // STATIC FACTORY
405
+ // ============================================
406
+ /**
407
+ * Create and connect a new PhalanxClient
408
+ * @param config Client configuration
409
+ * @returns Connected PhalanxClient instance
410
+ */
411
+ static async create(config) {
412
+ const client = new PhalanxClient(config);
413
+ await client.connect();
414
+ return client;
415
+ }
416
+ // ============================================
417
+ // CONNECTION MANAGEMENT
418
+ // ============================================
419
+ /**
420
+ * Connect to the Phalanx server
421
+ * @returns Promise that resolves when connected
422
+ * @throws Error if connection fails or times out
423
+ */
424
+ async connect() {
425
+ return this.socketManager.connect();
426
+ }
427
+ /**
428
+ * Disconnect from the server
429
+ */
430
+ disconnect() {
431
+ this.renderLoop.stop();
432
+ this.socketManager.disconnect();
433
+ this.clientState = 'idle';
434
+ this.currentMatchId = null;
435
+ this.currentTick = 0;
436
+ this.pendingCommands = [];
437
+ }
438
+ /**
439
+ * Destroy the client and clean up all resources
440
+ */
441
+ destroy() {
442
+ this._roomRecovery?.stop();
443
+ this.renderLoop.dispose();
444
+ this.disconnect();
445
+ this.removeAllListeners();
446
+ this.pendingCommands = [];
447
+ }
448
+ /**
449
+ * Check if client is connected to the server
450
+ */
451
+ isConnected() {
452
+ return this.socketManager.isConnected();
453
+ }
454
+ /**
455
+ * Get current connection state
456
+ */
457
+ getConnectionState() {
458
+ return this.socketManager.getConnectionState();
459
+ }
460
+ /**
461
+ * Get current client state
462
+ */
463
+ getClientState() {
464
+ return this.clientState;
465
+ }
466
+ // ============================================
467
+ // QUEUE MANAGEMENT
468
+ // ============================================
469
+ /**
470
+ * Join the matchmaking queue
471
+ * @returns Promise that resolves with queue status
472
+ */
473
+ async joinQueue() {
474
+ const status = await this.socketManager.joinQueue();
475
+ this.clientState = 'in-queue';
476
+ this.emit('queueJoined', status);
477
+ return status;
478
+ }
479
+ /**
480
+ * Leave the matchmaking queue
481
+ */
482
+ leaveQueue() {
483
+ this.socketManager.leaveQueue();
484
+ this.clientState = 'idle';
485
+ this.emit('queueLeft');
486
+ }
487
+ // ============================================
488
+ // PRIVATE ROOMS
489
+ // ============================================
490
+ /**
491
+ * Create a private room and wait for the room code.
492
+ * After creation, the host should wait for match-found (when another player joins).
493
+ */
494
+ async createRoom(gameType) {
495
+ this.ensureConnected();
496
+ const event = await this.socketManager.createRoom(gameType);
497
+ this.clientState = 'in-queue'; // waiting for opponent
498
+ this.emit('roomCreated', event);
499
+ return event;
500
+ }
501
+ /**
502
+ * Join a private room by code.
503
+ * The server will create a match and emit match-found to both players.
504
+ */
505
+ joinRoom(code) {
506
+ this.ensureConnected();
507
+ this.socketManager.joinRoom(code);
508
+ }
509
+ /**
510
+ * Cancel a previously created private room.
511
+ */
512
+ cancelRoom() {
513
+ this.socketManager.cancelRoom();
514
+ }
515
+ /**
516
+ * Reclaim a private room after a transient socket disconnect.
517
+ *
518
+ * Call this after `connect()` has re-established the underlying socket
519
+ * (typically inside a `visibilitychange` listener, or the first thing
520
+ * on `pageshow` from bfcache) to tell the server to re-bind the still-
521
+ * living room / in-flight match to this new socket.
522
+ *
523
+ * On success the server emits `match-found` (if a guest had already
524
+ * joined) followed by `reconnect-state` carrying a countdown snapshot,
525
+ * which `SocketManager`'s global `reconnect-state` handler fans out
526
+ * through the local `countdown` and `game-start` listeners so any
527
+ * `waitForCountdown` / `waitForGameStart` promises pending since the
528
+ * original flow resume naturally.
529
+ *
530
+ * Rejects with the server's `room-error` message (e.g. `'Room expired'`)
531
+ * or `'Recover timeout'` if the server doesn't answer before the configured
532
+ * recovery timeout.
533
+ */
534
+ async recoverRoom(code, timeoutMs) {
535
+ this.ensureConnected();
536
+ const event = await this.socketManager.recoverRoom(code, timeoutMs);
537
+ // Host is back in the "waiting for opponent / countdown" flow —
538
+ // mirror the state createRoom would have left us in so downstream
539
+ // isPlaying() checks stay consistent. The server may also have just
540
+ // emitted match-found / reconnect-state (pending-recover path), which
541
+ // will have already advanced `clientState` past 'in-queue' on its
542
+ // own — so only bump it if we're still in a pre-match phase.
543
+ if (this.clientState === 'idle') {
544
+ this.clientState = 'in-queue';
545
+ }
546
+ return event;
547
+ }
548
+ // ============================================
549
+ // PAUSE / RESUME
550
+ // ============================================
551
+ /**
552
+ * Request the server to pause the game.
553
+ * The game will not freeze immediately — it freezes only when the server
554
+ * broadcasts the 'game-paused' event back to all clients, ensuring
555
+ * every client pauses at the same deterministic point.
556
+ */
557
+ pauseGame() {
558
+ this.ensureConnected();
559
+ this.socketManager.sendPauseGame();
560
+ }
561
+ /**
562
+ * Request the server to resume the game.
563
+ * Same as pause — the actual resume occurs when the server broadcasts
564
+ * 'game-resumed' to all clients.
565
+ */
566
+ resumeGame() {
567
+ this.ensureConnected();
568
+ this.socketManager.sendResumeGame();
569
+ }
570
+ /**
571
+ * Notify the server that this client has finished loading and is ready to receive ticks.
572
+ * Must be called after assets are loaded and game systems are initialized.
573
+ * The server will not start the tick loop until all clients report ready.
574
+ */
575
+ sendReady() {
576
+ this.ensureConnected();
577
+ this.socketManager.sendReady();
578
+ }
579
+ /**
580
+ * Wait for a match to be found
581
+ * @returns Promise that resolves with match found event
582
+ */
583
+ async waitForMatch() {
584
+ const data = await this.socketManager.waitForMatch();
585
+ this.currentMatchId = data.matchId;
586
+ this.clientState = 'match-found';
587
+ this.emit('matchFound', data);
588
+ return data;
589
+ }
590
+ /**
591
+ * Join queue and wait for match in one call
592
+ * @returns Promise that resolves with match found event
593
+ */
594
+ async joinQueueAndWaitForMatch() {
595
+ await this.joinQueue();
596
+ return this.waitForMatch();
597
+ }
598
+ // ============================================
599
+ // GAME LIFECYCLE
600
+ // ============================================
601
+ /**
602
+ * Wait for countdown to complete (listening to countdown events)
603
+ * @param onCountdown Optional callback for each countdown tick
604
+ * @returns Promise that resolves when countdown reaches 0
605
+ */
606
+ async waitForCountdown(onCountdown) {
607
+ this.clientState = 'countdown';
608
+ return this.socketManager.waitForCountdown(onCountdown);
609
+ }
610
+ /**
611
+ * Wait for the game to start
612
+ * @returns Promise that resolves with game start event
613
+ */
614
+ async waitForGameStart() {
615
+ const data = await this.socketManager.waitForGameStart();
616
+ this.clientState = 'playing';
617
+ this.currentTick = 0;
618
+ this.emit('gameStart', data);
619
+ return data;
620
+ }
621
+ // ============================================
622
+ // COMMANDS
623
+ // ============================================
624
+ /**
625
+ * Submit commands for a specific tick
626
+ * @param tick The tick number these commands are for
627
+ * @param commands Array of commands to submit
628
+ * @returns Promise that resolves with acknowledgment
629
+ */
630
+ async submitCommands(tick, commands) {
631
+ this.ensurePlaying();
632
+ return this.socketManager.submitCommands(tick, commands);
633
+ }
634
+ /**
635
+ * Submit commands without waiting for acknowledgment (fire and forget)
636
+ * @param tick The tick number these commands are for
637
+ * @param commands Array of commands to submit
638
+ */
639
+ submitCommandsAsync(tick, commands) {
640
+ this.ensurePlaying();
641
+ this.socketManager.submitCommandsAsync(tick, commands);
642
+ }
643
+ /**
644
+ * Send a command to the server
645
+ * Commands are buffered and sent automatically each frame
646
+ *
647
+ * @param type Command type (e.g., 'move', 'attack')
648
+ * @param data Command payload
649
+ */
650
+ sendCommand(type, data) {
651
+ this.pendingCommands.push({ type, data });
652
+ }
653
+ // ============================================
654
+ // SIMPLIFIED API - TICK & FRAME HANDLERS
655
+ // ============================================
656
+ /**
657
+ * Register a callback for simulation ticks
658
+ * Called when the server sends a tick with commands from all players
659
+ *
660
+ * @param handler Callback receiving tick number and commands grouped by player
661
+ * @returns Unsubscribe function
662
+ */
663
+ onTick(handler) {
664
+ return this.renderLoop.onTick(handler);
665
+ }
666
+ /**
667
+ * Register a callback for render frames
668
+ * Called every animation frame (~60fps) with interpolation alpha
669
+ * Automatically starts the render loop when first handler is added
670
+ *
671
+ * @param handler Callback receiving alpha (0-1) and delta time in seconds
672
+ * @returns Unsubscribe function
673
+ */
674
+ onFrame(handler) {
675
+ return this.renderLoop.onFrame(handler);
676
+ }
677
+ // ============================================
678
+ // ITickFrameProvider PAUSE / RESUME
679
+ // ============================================
680
+ /**
681
+ * Request the server to pause the game (ITickFrameProvider contract).
682
+ * The actual pause is signalled asynchronously via onPause when the
683
+ * server broadcasts 'game-paused' to all clients.
684
+ */
685
+ requestPause() {
686
+ this.pauseGame();
687
+ }
688
+ /**
689
+ * Request the server to resume the game (ITickFrameProvider contract).
690
+ * The actual resume is signalled asynchronously via onResume when the
691
+ * server broadcasts 'game-resumed' to all clients.
692
+ */
693
+ requestResume() {
694
+ this.resumeGame();
695
+ }
696
+ /**
697
+ * Subscribe to the "paused" signal (ITickFrameProvider contract).
698
+ * Fired when the server confirms the pause and broadcasts it.
699
+ */
700
+ onPause(handler) {
701
+ this.pauseHandlers.push(handler);
702
+ return () => {
703
+ const idx = this.pauseHandlers.indexOf(handler);
704
+ if (idx !== -1)
705
+ this.pauseHandlers.splice(idx, 1);
706
+ };
707
+ }
708
+ /**
709
+ * Subscribe to the "resumed" signal (ITickFrameProvider contract).
710
+ * Fired when the server confirms the resume and broadcasts it.
711
+ */
712
+ onResume(handler) {
713
+ this.resumeHandlers.push(handler);
714
+ return () => {
715
+ const idx = this.resumeHandlers.indexOf(handler);
716
+ if (idx !== -1)
717
+ this.resumeHandlers.splice(idx, 1);
718
+ };
719
+ }
720
+ // ============================================
721
+ // RECONNECTION
722
+ // ============================================
723
+ /**
724
+ * Attempt to reconnect to a match after disconnection
725
+ * @param matchId The match ID to reconnect to
726
+ * @returns Promise that resolves with reconnection state
727
+ */
728
+ async reconnectToMatch(matchId) {
729
+ this.clientState = 'reconnecting';
730
+ return this.socketManager.reconnectToMatch(matchId);
731
+ }
732
+ /**
733
+ * Attempt automatic reconnection with retries
734
+ * @returns Promise that resolves when reconnected, rejects if all attempts fail
735
+ */
736
+ async attemptReconnection() {
737
+ return this.socketManager.attemptReconnection();
738
+ }
739
+ // ============================================
740
+ // DESYNC DETECTION
741
+ // ============================================
742
+ /**
743
+ * Submit state hash for desync detection
744
+ * Call this after each simulation tick (or every N ticks based on your preference)
745
+ *
746
+ * The game is responsible for computing the hash using StateHasher or
747
+ * a custom implementation. The SDK just handles transport and comparison.
748
+ *
749
+ * @param tick - The tick this hash is for
750
+ * @param hash - Hash computed by game (any string)
751
+ *
752
+ * @example
753
+ * ```typescript
754
+ * client.onTick((tick, commands) => {
755
+ * simulation.processTick(tick, commands);
756
+ *
757
+ * // Submit hash every 20 ticks (once per second at 20 TPS)
758
+ * if (tick % 20 === 0) {
759
+ * const hash = computeGameStateHash(tick);
760
+ * client.submitStateHash(tick, hash);
761
+ * }
762
+ * });
763
+ * ```
764
+ */
765
+ submitStateHash(tick, hash) {
766
+ this.desyncDetector.recordLocalHash(tick, hash);
767
+ if (this.isConnected()) {
768
+ this.socketManager.sendStateHash(tick, hash);
769
+ }
770
+ }
771
+ /**
772
+ * Configure desync detection
773
+ * @param config - Configuration options for desync detection
774
+ *
775
+ * @example
776
+ * ```typescript
777
+ * // Disable desync detection
778
+ * client.configureDesyncDetection({ enabled: false });
779
+ *
780
+ * // Limit stored hashes
781
+ * client.configureDesyncDetection({ maxStoredHashes: 50 });
782
+ * ```
783
+ */
784
+ configureDesyncDetection(config) {
785
+ this.desyncDetector.configure(config);
786
+ }
787
+ // ============================================
788
+ // STATE GETTERS
789
+ // ============================================
790
+ /**
791
+ * Get current tick number
792
+ */
793
+ getCurrentTick() {
794
+ return this.currentTick;
795
+ }
796
+ /**
797
+ * Get current match ID
798
+ */
799
+ getMatchId() {
800
+ return this.currentMatchId;
801
+ }
802
+ /**
803
+ * Get player ID configured for this client
804
+ */
805
+ getPlayerId() {
806
+ return this.config.playerId || '';
807
+ }
808
+ /**
809
+ * Get username configured for this client
810
+ */
811
+ getUsername() {
812
+ return this.config.username || '';
813
+ }
814
+ // ============================================
815
+ // PRIVATE METHODS
816
+ // ============================================
817
+ flushPendingCommands() {
818
+ if (this.pendingCommands.length > 0 &&
819
+ this.isConnected() &&
820
+ this.clientState === 'playing') {
821
+ this.submitCommandsAsync(this.currentTick, this.pendingCommands);
822
+ this.pendingCommands = [];
823
+ }
824
+ }
825
+ ensureConnected() {
826
+ if (!this.isConnected()) {
827
+ throw new Error('Not connected to server. Call connect() first.');
828
+ }
829
+ }
830
+ ensurePlaying() {
831
+ this.ensureConnected();
832
+ if (this.clientState !== 'playing' &&
833
+ this.clientState !== 'paused' &&
834
+ this.clientState !== 'reconnecting') {
835
+ throw new Error('Not in a game. Join a match first.');
836
+ }
837
+ }
838
+ log(...args) {
839
+ if (this.config.debug) {
840
+ console.log('[PhalanxClient]', ...args);
841
+ }
842
+ }
843
+ }
844
+ //# sourceMappingURL=PhalanxClient.js.map