fix: periodic player count refresh via probe shard connection
Some Jackbox games (e.g. Trivia Murder Party 2) do not send client/connected events to shard connections and lack textDescriptions, leaving the player count stuck at 0 if the shard connects before players join. Fix by opening a lightweight probe shard every 20s to read the fresh here map. Also fix bc:room entity lookup in handleWelcome and a WebSocket close handler race condition. Made-with: Cursor
This commit is contained in:
@@ -113,10 +113,70 @@ class EcastShardClient {
|
|||||||
startStatusBroadcast() {
|
startStatusBroadcast() {
|
||||||
this.stopStatusBroadcast();
|
this.stopStatusBroadcast();
|
||||||
this.statusInterval = setInterval(() => {
|
this.statusInterval = setInterval(() => {
|
||||||
this.onEvent('game.status', this.getSnapshot());
|
this._refreshPlayerCount().finally(() => {
|
||||||
|
this.onEvent('game.status', this.getSnapshot());
|
||||||
|
});
|
||||||
}, 20000);
|
}, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_refreshPlayerCount() {
|
||||||
|
if (!this.host || this.gameFinished || this.manuallyStopped) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`;
|
||||||
|
let resolved = false;
|
||||||
|
const done = () => { if (!resolved) { resolved = true; resolve(); } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const probe = new WebSocket(url, ['ecast-v0'], {
|
||||||
|
headers: { Origin: 'https://jackbox.tv' },
|
||||||
|
handshakeTimeout: 8000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
try { probe.close(); } catch (_) {}
|
||||||
|
done();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
probe.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.opcode === 'client/welcome') {
|
||||||
|
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here);
|
||||||
|
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
|
||||||
|
this.playerCount = playerCount;
|
||||||
|
this.playerNames = playerNames;
|
||||||
|
this.onEvent('lobby.player-joined', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerName: playerNames[playerNames.length - 1] || '',
|
||||||
|
playerCount,
|
||||||
|
players: [...playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
|
} else if (playerCount !== this.playerCount) {
|
||||||
|
this.playerCount = playerCount;
|
||||||
|
this.playerNames = playerNames;
|
||||||
|
}
|
||||||
|
} else if (msg.opcode === 'error' && msg.result?.code === 2027) {
|
||||||
|
this.gameFinished = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try { probe.close(); } catch (_) {}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
probe.on('error', () => { clearTimeout(timeout); done(); });
|
||||||
|
probe.on('close', () => { clearTimeout(timeout); done(); });
|
||||||
|
} catch (_) {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
stopStatusBroadcast() {
|
stopStatusBroadcast() {
|
||||||
if (this.statusInterval) {
|
if (this.statusInterval) {
|
||||||
clearInterval(this.statusInterval);
|
clearInterval(this.statusInterval);
|
||||||
@@ -157,8 +217,8 @@ class EcastShardClient {
|
|||||||
this.playerCount = playerCount;
|
this.playerCount = playerCount;
|
||||||
this.playerNames = playerNames;
|
this.playerNames = playerNames;
|
||||||
|
|
||||||
if (result.entities?.room) {
|
const roomEntity = result.entities?.room || result.entities?.['bc:room'];
|
||||||
const roomEntity = result.entities.room;
|
if (roomEntity) {
|
||||||
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
|
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
|
||||||
if (roomVal) {
|
if (roomVal) {
|
||||||
const roomState = EcastShardClient.parseRoomEntity(roomVal);
|
const roomState = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
@@ -343,11 +403,14 @@ class EcastShardClient {
|
|||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const thisWs = this.ws;
|
||||||
this.ws.on('close', (code, reason) => {
|
this.ws.on('close', (code, reason) => {
|
||||||
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
|
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
|
||||||
this.ws = null;
|
if (this.ws === thisWs) {
|
||||||
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
|
this.ws = null;
|
||||||
void this.reconnectWithBackoff();
|
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
|
||||||
|
void this.reconnectWithBackoff();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,32 @@ describe('EcastShardClient', () => {
|
|||||||
expect(client.lobbyState).toBe('CanStart');
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
expect(client.gameStarted).toBe(false);
|
expect(client.gameStarted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses bc:room entity when room key is absent', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 5,
|
||||||
|
secret: 'xyz-789',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {
|
||||||
|
'bc:room': ['object', { key: 'bc:room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }],
|
||||||
|
audience: ['crdt/pn-counter', [], { locked: false }],
|
||||||
|
},
|
||||||
|
here: {
|
||||||
|
'1': { id: 1, roles: { host: {} } },
|
||||||
|
'3': { id: 3, roles: { player: { name: 'HÂM' } } },
|
||||||
|
'4': { id: 4, roles: { player: { name: 'FGHFGHY' } } },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.playerCount).toBe(2);
|
||||||
|
expect(client.playerNames).toEqual(['HÂM', 'FGHFGHY']);
|
||||||
|
expect(client.gameState).toBe('Lobby');
|
||||||
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleEntityUpdate', () => {
|
describe('handleEntityUpdate', () => {
|
||||||
@@ -424,12 +450,17 @@ describe('EcastShardClient', () => {
|
|||||||
beforeEach(() => jest.useFakeTimers());
|
beforeEach(() => jest.useFakeTimers());
|
||||||
afterEach(() => jest.useRealTimers());
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
test('broadcasts game.status every 20 seconds', () => {
|
function stubRefresh(client) {
|
||||||
|
client._refreshPlayerCount = () => Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('broadcasts game.status every 20 seconds', async () => {
|
||||||
const events = [];
|
const events = [];
|
||||||
const client = new EcastShardClient({
|
const client = new EcastShardClient({
|
||||||
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
onEvent: (type, data) => events.push({ type, data }),
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
});
|
});
|
||||||
|
stubRefresh(client);
|
||||||
client.playerCount = 2;
|
client.playerCount = 2;
|
||||||
client.playerNames = ['A', 'B'];
|
client.playerNames = ['A', 'B'];
|
||||||
client.gameState = 'Lobby';
|
client.gameState = 'Lobby';
|
||||||
@@ -437,43 +468,50 @@ describe('EcastShardClient', () => {
|
|||||||
client.startStatusBroadcast();
|
client.startStatusBroadcast();
|
||||||
|
|
||||||
jest.advanceTimersByTime(20000);
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0].type).toBe('game.status');
|
expect(events[0].type).toBe('game.status');
|
||||||
expect(events[0].data.monitoring).toBe(true);
|
expect(events[0].data.monitoring).toBe(true);
|
||||||
|
|
||||||
jest.advanceTimersByTime(20000);
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
expect(events).toHaveLength(2);
|
expect(events).toHaveLength(2);
|
||||||
|
|
||||||
client.stopStatusBroadcast();
|
client.stopStatusBroadcast();
|
||||||
|
|
||||||
jest.advanceTimersByTime(40000);
|
jest.advanceTimersByTime(40000);
|
||||||
|
await Promise.resolve();
|
||||||
expect(events).toHaveLength(2);
|
expect(events).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disconnect stops the status broadcast', () => {
|
test('disconnect stops the status broadcast', async () => {
|
||||||
const events = [];
|
const events = [];
|
||||||
const client = new EcastShardClient({
|
const client = new EcastShardClient({
|
||||||
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
onEvent: (type, data) => events.push({ type, data }),
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
});
|
});
|
||||||
|
stubRefresh(client);
|
||||||
|
|
||||||
client.startStatusBroadcast();
|
client.startStatusBroadcast();
|
||||||
|
|
||||||
jest.advanceTimersByTime(20000);
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
|
||||||
jest.advanceTimersByTime(40000);
|
jest.advanceTimersByTime(40000);
|
||||||
|
await Promise.resolve();
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleWelcome starts status broadcast', () => {
|
test('handleWelcome starts status broadcast', async () => {
|
||||||
const events = [];
|
const events = [];
|
||||||
const client = new EcastShardClient({
|
const client = new EcastShardClient({
|
||||||
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
onEvent: (type, data) => events.push({ type, data }),
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
});
|
});
|
||||||
|
stubRefresh(client);
|
||||||
|
|
||||||
client.handleWelcome({
|
client.handleWelcome({
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -484,6 +522,7 @@ describe('EcastShardClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jest.advanceTimersByTime(20000);
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
const statusEvents = events.filter(e => e.type === 'game.status');
|
const statusEvents = events.filter(e => e.type === 'game.status');
|
||||||
expect(statusEvents).toHaveLength(1);
|
expect(statusEvents).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user