Compare commits

...

3 Commits

Author SHA1 Message Date
cottongin
171303a6f9 fix: enforce single playing game and clean up stale shard monitors
Mark games as 'played' when shard detects game.ended or room_closed.
Stop old shard monitors before demoting previous playing games on new
game add or status change. Sync frontend playingGame state with the
games list on every refresh to prevent stale UI. Use terminate() for
probe connections to prevent shard connection leaks.

Made-with: Cursor
2026-03-20 23:34:22 -04:00
cottongin
4999060970 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
2026-03-20 21:29:58 -04:00
cottongin
34637d6d2c feat: add periodic game.status broadcast and live status REST endpoint
Add 20-second game.status WebSocket heartbeat from active shard monitors
containing full game state, and GET /status-live REST endpoint for on-demand
polling. Fix missing token destructuring in SessionInfo causing crash.
Relax frontend polling from 3s to 60s since WebSocket events now cover
real-time updates. Bump version to 0.6.0.

Made-with: Cursor
2026-03-20 21:05:19 -04:00
8 changed files with 500 additions and 26 deletions

View File

@@ -4,7 +4,7 @@ const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager');
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
const router = express.Router();
@@ -314,6 +314,14 @@ router.post('/:id/games', authenticateToken, (req, res) => {
return res.status(404).json({ error: 'Game not found' });
}
// Stop monitors for currently-playing games before demoting them
const previouslyPlaying = db.prepare(
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
).all(req.params.id, 'playing');
for (const prev of previouslyPlaying) {
try { stopMonitor(req.params.id, prev.id); } catch (_) {}
}
// Set all current 'playing' games to 'played' (except skipped ones)
db.prepare(`
UPDATE session_games
@@ -590,8 +598,17 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
return res.status(400).json({ error: 'Invalid status. Must be playing, played, or skipped' });
}
// If setting to 'playing', first set all other games in session to 'played' or keep as 'skipped'
// If setting to 'playing', first stop monitors and demote other playing games
if (status === 'playing') {
const previouslyPlaying = db.prepare(
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
).all(sessionId, 'playing');
for (const prev of previouslyPlaying) {
if (String(prev.id) !== String(gameId)) {
try { stopMonitor(sessionId, prev.id); } catch (_) {}
}
}
db.prepare(`
UPDATE session_games
SET status = CASE
@@ -838,6 +855,55 @@ router.get('/:id/export', authenticateToken, (req, res) => {
}
});
// Get live game status from shard monitor or DB fallback
router.get('/:sessionId/games/:gameId/status-live', (req, res) => {
try {
const { sessionId, gameId } = req.params;
const snapshot = getMonitorSnapshot(sessionId, gameId);
if (snapshot) {
return res.json(snapshot);
}
const game = db.prepare(`
SELECT
sg.room_code,
sg.player_count,
sg.player_count_check_status,
g.title,
g.pack_name,
g.max_players
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ? AND sg.id = ?
`).get(sessionId, gameId);
if (!game) {
return res.status(404).json({ error: 'Session game not found' });
}
res.json({
sessionId: parseInt(sessionId, 10),
gameId: parseInt(gameId, 10),
roomCode: game.room_code,
appTag: null,
maxPlayers: game.max_players,
playerCount: game.player_count,
players: [],
lobbyState: null,
gameState: null,
gameStarted: false,
gameFinished: game.player_count_check_status === 'completed',
monitoring: false,
title: game.title,
packName: game.pack_name,
status: game.player_count_check_status,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Start player count check for a session game (admin only)
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
try {

View File

@@ -90,6 +90,105 @@ class EcastShardClient {
this.seq = 0;
this.appTag = null;
this.reconnecting = false;
this.statusInterval = null;
}
getSnapshot() {
return {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
appTag: this.appTag,
maxPlayers: this.maxPlayers,
playerCount: this.playerCount,
players: [...this.playerNames],
lobbyState: this.lobbyState,
gameState: this.gameState,
gameStarted: this.gameStarted,
gameFinished: this.gameFinished,
monitoring: true,
};
}
startStatusBroadcast() {
this.stopStatusBroadcast();
this.statusInterval = setInterval(() => {
this._refreshPlayerCount().finally(() => {
this.onEvent('game.status', this.getSnapshot());
});
}, 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;
let welcomed = false;
const done = (probe) => {
if (!resolved) {
resolved = true;
if (probe) {
try { probe.removeAllListeners(); probe.terminate(); } catch (_) {}
}
resolve();
}
};
try {
const probe = new WebSocket(url, ['ecast-v0'], {
headers: { Origin: 'https://jackbox.tv' },
handshakeTimeout: 8000,
});
const timeout = setTimeout(() => done(probe), 10000);
probe.on('message', (data) => {
if (welcomed) return;
try {
const msg = JSON.parse(data.toString());
if (msg.opcode === 'client/welcome') {
welcomed = true;
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);
done(probe);
});
probe.on('error', () => { clearTimeout(timeout); done(probe); });
probe.on('close', () => { clearTimeout(timeout); done(null); });
} catch (_) {
done(null);
}
});
}
stopStatusBroadcast() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
}
buildReconnectUrl() {
@@ -125,8 +224,8 @@ class EcastShardClient {
this.playerCount = playerCount;
this.playerNames = playerNames;
if (result.entities?.room) {
const roomEntity = result.entities.room;
const roomEntity = result.entities?.room || result.entities?.['bc:room'];
if (roomEntity) {
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
if (roomVal) {
const roomState = EcastShardClient.parseRoomEntity(roomVal);
@@ -152,6 +251,8 @@ class EcastShardClient {
lobbyState: this.lobbyState,
gameState: this.gameState,
});
this.startStatusBroadcast();
}
handleEntityUpdate(result) {
@@ -309,11 +410,14 @@ class EcastShardClient {
reject(err);
});
const thisWs = this.ws;
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();
if (this.ws === thisWs) {
this.ws = null;
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
void this.reconnectWithBackoff();
}
}
});
@@ -406,6 +510,7 @@ class EcastShardClient {
}
disconnect() {
this.stopStatusBroadcast();
if (this.ws) {
try {
this.ws.close(1000, 'Monitor stopped');
@@ -434,11 +539,17 @@ function broadcastAndPersist(sessionId, gameId) {
}
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
const status = eventType === 'game.ended' ? 'completed' : 'monitoring';
const checkStatus = eventType === 'game.ended' ? 'completed' : 'monitoring';
try {
db.prepare(
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
).run(eventData.playerCount ?? null, status, sessionId, gameId);
if (eventType === 'game.ended') {
db.prepare(
'UPDATE session_games SET player_count = ?, player_count_check_status = ?, status = ? WHERE session_id = ? AND id = ?'
).run(eventData.playerCount ?? null, checkStatus, 'played', sessionId, gameId);
} else {
db.prepare(
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
).run(eventData.playerCount ?? null, checkStatus, sessionId, gameId);
}
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
@@ -446,15 +557,22 @@ function broadcastAndPersist(sessionId, gameId) {
if (eventType === 'room.disconnected') {
const reason = eventData.reason;
const status =
const checkStatus =
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
try {
const game = db
.prepare('SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?')
.prepare('SELECT player_count_check_status, status FROM session_games WHERE session_id = ? AND id = ?')
.get(sessionId, gameId);
if (game && game.player_count_check_status !== 'completed') {
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
status,
checkStatus,
sessionId,
gameId
);
}
if (game && reason === 'room_closed' && game.status === 'playing') {
db.prepare('UPDATE session_games SET status = ? WHERE session_id = ? AND id = ?').run(
'played',
sessionId,
gameId
);
@@ -569,4 +687,9 @@ async function cleanupAllShards() {
console.log('[Shard Monitor] Cleaned up all active shards');
}
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
function getMonitorSnapshot(sessionId, gameId) {
const client = activeShards.get(`${sessionId}-${gameId}`);
return client ? client.getSnapshot() : null;
}
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot };

View File

@@ -41,6 +41,7 @@ All REST endpoints are prefixed with `/api/` except `/health`.
- `GET /api/sessions/{id}`
- `GET /api/sessions/{id}/games`
- `GET /api/sessions/{id}/votes`
- `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`
- `GET /api/votes`
- `GET /api/stats`
- `POST /api/pick`
@@ -139,6 +140,7 @@ Most list endpoints return full result sets. The exception is `GET /api/votes`,
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Yes | Update room code for session game |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Yes | Start room monitor for player count |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Yes | Stop room monitor |
| GET | `/api/sessions/{sessionId}/games/{sessionGameId}/status-live` | No | Get live game status from shard monitor |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Yes | Update player count for session game |
### Picker

View File

@@ -22,6 +22,7 @@ Sessions represent a gaming night. Only one session can be active at a time. Gam
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/status` | Bearer | Update session game status |
| DELETE | `/api/sessions/{sessionId}/games/{sessionGameId}` | Bearer | Remove game from session |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Bearer | Update room code for session game |
| GET | `/api/sessions/{sessionId}/games/{sessionGameId}/status-live` | No | Get live game status from shard monitor |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Bearer | Start room monitor |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Bearer | Stop room monitor |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Bearer | Update player count for session game |
@@ -832,6 +833,82 @@ curl -o session-5.txt "http://localhost:5000/api/sessions/5/export" \
---
## GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live
Get the live game status from an active shard monitor. If no monitor is running, falls back to data from the database. No authentication required.
The same data is broadcast every 20 seconds via the `game.status` WebSocket event to subscribed clients.
**Note:** `sessionGameId` is the `session_games.id` row ID, NOT `games.id`.
### Authentication
None required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| sessionId | integer | Session ID |
| sessionGameId | integer | Session game ID (`session_games.id`) |
### Response
**200 OK** — Live shard data (when monitor is active):
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"lobbyState": "CanStart",
"gameState": "Lobby",
"gameStarted": false,
"gameFinished": false,
"monitoring": true
}
```
**200 OK** — DB fallback (when no monitor is active):
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": null,
"maxPlayers": 8,
"playerCount": 4,
"players": [],
"lobbyState": null,
"gameState": null,
"gameStarted": false,
"gameFinished": true,
"monitoring": false,
"title": "Drawful 2",
"packName": "Jackbox Party Pack 8",
"status": "completed"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Session game not found" }` | Invalid sessionId or sessionGameId |
### Example
```bash
curl "http://localhost:5000/api/sessions/5/games/14/status-live"
```
---
## POST /api/sessions/{sessionId}/games/{sessionGameId}/start-player-check
Start the room monitor for a session game. The game must have a room code.

View File

@@ -136,6 +136,7 @@ Must be authenticated.
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
| `game.ended` | Game finished (broadcast to subscribers) |
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
| `vote.received` | Live vote recorded (broadcast to subscribers) |
@@ -318,6 +319,29 @@ All server-sent events use this envelope:
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
### game.status
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Periodic 20-second heartbeat from an active shard monitor. Also available on demand via `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`.
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"lobbyState": "CanStart",
"gameState": "Lobby",
"gameStarted": false,
"gameFinished": false,
"monitoring": true
}
```
### player-count.updated
- **Broadcast to:** Clients subscribed to the session
@@ -536,6 +560,10 @@ ws.onmessage = (event) => {
console.log('Room disconnected:', msg.data.reason);
break;
case 'game.status':
console.log('Status heartbeat:', msg.data.roomCode, '- players:', msg.data.playerCount, '- state:', msg.data.gameState);
break;
case 'player-count.updated':
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
break;

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'Jackbox Game Picker',
version: '0.5.1 - Thode Goes Wild Edition',
version: '0.6.0 - Fish Tank Edition',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
@@ -124,13 +124,13 @@ function Picker() {
loadData();
}, [isAuthenticated, authLoading, navigate, loadData]);
// Poll for active session status changes
// Fallback poll for session status — WebSocket events handle most updates
useEffect(() => {
if (!isAuthenticated || authLoading) return;
const interval = setInterval(() => {
checkActiveSession();
}, 3000);
}, 60000);
return () => clearInterval(interval);
}, [isAuthenticated, authLoading, checkActiveSession]);
@@ -939,7 +939,7 @@ function Picker() {
}
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
const { isAuthenticated } = useAuth();
const { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
@@ -952,27 +952,43 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
const [newPlayerCount, setNewPlayerCount] = useState('');
const playingGameRef = useRef(playingGame);
playingGameRef.current = playingGame;
const loadGames = useCallback(async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
setGames(response.data.reverse());
const freshGames = response.data;
setGames(freshGames.reverse());
const currentPlaying = freshGames.find(g => g.status === 'playing');
const prev = playingGameRef.current;
if (currentPlaying) {
if (!prev || prev.id !== currentPlaying.id || prev.player_count !== currentPlaying.player_count) {
setPlayingGame(currentPlaying);
}
} else if (prev) {
const still = freshGames.find(g => g.id === prev.id);
if (!still || still.status !== 'playing') {
setPlayingGame(null);
}
}
} catch (err) {
console.error('Failed to load session games');
} finally {
setLoading(false);
}
}, [sessionId]);
}, [sessionId, setPlayingGame]);
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate, loadGames]);
// Auto-refresh games list every 3 seconds
// Fallback polling — WebSocket events handle most updates; this is a safety net
useEffect(() => {
const interval = setInterval(() => {
loadGames();
}, 3000);
}, 60000);
return () => clearInterval(interval);
}, [loadGames]);
@@ -1011,6 +1027,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
'room.disconnected',
'player-count.updated',
'game.added',
'game.status',
];
if (reloadEvents.includes(message.type)) {

View File

@@ -135,6 +135,32 @@ describe('EcastShardClient', () => {
expect(client.lobbyState).toBe('CanStart');
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', () => {
@@ -376,8 +402,134 @@ describe('EcastShardClient', () => {
});
});
describe('getSnapshot', () => {
test('returns correct shape with current state', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'LSBN', maxPlayers: 8, onEvent: () => {}
});
client.appTag = 'drawful2international';
client.playerCount = 3;
client.playerNames = ['Alice', 'Bob', 'Charlie'];
client.lobbyState = 'CanStart';
client.gameState = 'Lobby';
client.gameStarted = false;
client.gameFinished = false;
const snapshot = client.getSnapshot();
expect(snapshot).toEqual({
sessionId: 1,
gameId: 5,
roomCode: 'LSBN',
appTag: 'drawful2international',
maxPlayers: 8,
playerCount: 3,
players: ['Alice', 'Bob', 'Charlie'],
lobbyState: 'CanStart',
gameState: 'Lobby',
gameStarted: false,
gameFinished: false,
monitoring: true,
});
});
test('returns a defensive copy of playerNames', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.playerNames = ['Alice'];
const snapshot = client.getSnapshot();
snapshot.players.push('Mutated');
expect(client.playerNames).toEqual(['Alice']);
});
});
describe('startStatusBroadcast / stopStatusBroadcast', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
function stubRefresh(client) {
client._refreshPlayerCount = () => Promise.resolve();
}
test('broadcasts game.status every 20 seconds', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.playerCount = 2;
client.playerNames = ['A', 'B'];
client.gameState = 'Lobby';
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(1);
expect(events[0].type).toBe('game.status');
expect(events[0].data.monitoring).toBe(true);
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(2);
client.stopStatusBroadcast();
jest.advanceTimersByTime(40000);
await Promise.resolve();
expect(events).toHaveLength(2);
});
test('disconnect stops the status broadcast', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(1);
client.disconnect();
jest.advanceTimersByTime(40000);
await Promise.resolve();
expect(events).toHaveLength(1);
});
test('handleWelcome starts status broadcast', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.handleWelcome({
id: 7,
secret: 'abc',
reconnect: false,
entities: {},
here: {},
});
jest.advanceTimersByTime(20000);
await Promise.resolve();
const statusEvents = events.filter(e => e.type === 'game.status');
expect(statusEvents).toHaveLength(1);
});
});
describe('module exports', () => {
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
const { startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot } = require('../../backend/utils/ecast-shard-client');
test('startMonitor is exported', () => {
expect(typeof startMonitor).toBe('function');
@@ -390,6 +542,15 @@ describe('EcastShardClient', () => {
test('cleanupAllShards is exported', () => {
expect(typeof cleanupAllShards).toBe('function');
});
test('getMonitorSnapshot is exported', () => {
expect(typeof getMonitorSnapshot).toBe('function');
});
test('getMonitorSnapshot returns null when no shard active', () => {
const snapshot = getMonitorSnapshot(999, 999);
expect(snapshot).toBeNull();
});
});
describe('handleError with code 2027', () => {