diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js index 9829707..ef51244 100644 --- a/backend/utils/ecast-shard-client.js +++ b/backend/utils/ecast-shard-client.js @@ -1,5 +1,7 @@ const WebSocket = require('ws'); +const { getRoomInfo } = require('./jackbox-api'); + class EcastShardClient { static parsePlayersFromHere(here) { if (here == null || typeof here !== 'object') { @@ -85,6 +87,11 @@ class EcastShardClient { this.manuallyStopped = false; this.seq = 0; this.appTag = null; + this.reconnecting = false; + } + + buildReconnectUrl() { + return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`; } handleMessage(message) { @@ -241,16 +248,27 @@ class EcastShardClient { handleError(result) { console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`); + if (result?.code === 2027) { + this.gameFinished = true; + this.onEvent('game.ended', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + playerCount: this.playerCount, + players: [...this.playerNames], + }); + this.onEvent('room.disconnected', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + reason: 'room_closed', + finalPlayerCount: this.playerCount, + }); + this.disconnect(); + } } - async connect(roomInfo) { - this.disconnect(); - this.host = roomInfo.host; - this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers; - this.appTag = roomInfo.appTag; - - const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`; - + _openWebSocket(url) { return new Promise((resolve, reject) => { let welcomeTimeoutId = null; @@ -291,6 +309,10 @@ class EcastShardClient { this.ws.on('close', (code, reason) => { console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`); + this.ws = null; + if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) { + void this.reconnectWithBackoff(); + } }); welcomeTimeoutId = setTimeout(() => { @@ -303,6 +325,82 @@ class EcastShardClient { }); } + async connect(roomInfo, reconnectUrl) { + this.disconnect(); + this.shardId = null; + this.secret = null; + this.host = roomInfo.host; + this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers; + this.appTag = roomInfo.appTag; + + const url = + reconnectUrl || + `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`; + + return this._openWebSocket(url); + } + + async reconnect() { + const url = this.buildReconnectUrl(); + this.disconnect(); + this.shardId = null; + return this._openWebSocket(url); + } + + async reconnectWithBackoff() { + if (this.reconnecting || this.manuallyStopped || this.gameFinished) { + return false; + } + this.reconnecting = true; + const delays = [2000, 4000, 8000]; + + try { + for (let i = 0; i < delays.length; i++) { + await new Promise((r) => setTimeout(r, delays[i])); + + const roomInfo = await getRoomInfo(this.roomCode); + + if (!roomInfo.exists) { + this.gameFinished = true; + this.onEvent('game.ended', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + playerCount: this.playerCount, + players: [...this.playerNames], + }); + this.onEvent('room.disconnected', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + reason: 'room_closed', + finalPlayerCount: this.playerCount, + }); + return false; + } + + try { + await this.reconnect(); + console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`); + return true; + } catch (e) { + console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message); + } + } + + this.onEvent('room.disconnected', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + reason: 'connection_failed', + finalPlayerCount: this.playerCount, + }); + return false; + } finally { + this.reconnecting = false; + } + } + disconnect() { if (this.ws) { try { diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js index 7515765..5d15d54 100644 --- a/tests/api/ecast-shard-client.test.js +++ b/tests/api/ecast-shard-client.test.js @@ -354,4 +354,46 @@ describe('EcastShardClient', () => { }); }); }); + + describe('buildReconnectUrl', () => { + test('uses stored secret and id', () => { + const client = new EcastShardClient({ + sessionId: 1, + gameId: 1, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: () => {}, + }); + client.secret = 'abc-123'; + client.shardId = 5; + client.host = 'ecast-prod-use2.jackboxgames.com'; + + const url = client.buildReconnectUrl(); + expect(url).toContain('secret=abc-123'); + expect(url).toContain('id=5'); + expect(url).toContain('role=shard'); + expect(url).toContain('ecast-prod-use2.jackboxgames.com'); + }); + }); + + describe('handleError with code 2027', () => { + test('marks game as finished and emits events on room-closed error', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, + gameId: 5, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.playerCount = 4; + client.playerNames = ['A', 'B', 'C', 'D']; + + client.handleError({ code: 2027, msg: 'the room has already been closed' }); + + expect(client.gameFinished).toBe(true); + expect(events.some(e => e.type === 'game.ended')).toBe(true); + expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true); + }); + }); });