From 34637d6d2c3a74be62ae0b9df75108b883d5c899 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 21:05:19 -0400 Subject: [PATCH] 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 --- backend/routes/sessions.js | 51 ++++++++++- backend/utils/ecast-shard-client.js | 42 ++++++++- docs/api/README.md | 2 + docs/api/endpoints/sessions.md | 77 +++++++++++++++++ docs/api/websocket.md | 28 ++++++ frontend/src/config/branding.js | 2 +- frontend/src/pages/Picker.jsx | 11 +-- tests/api/ecast-shard-client.test.js | 124 ++++++++++++++++++++++++++- 8 files changed, 328 insertions(+), 9 deletions(-) diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index f4cca19..9952cd3 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -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(); @@ -838,6 +838,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 { diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js index 908e518..afe87a5 100644 --- a/backend/utils/ecast-shard-client.js +++ b/backend/utils/ecast-shard-client.js @@ -90,6 +90,38 @@ 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.onEvent('game.status', this.getSnapshot()); + }, 20000); + } + + stopStatusBroadcast() { + if (this.statusInterval) { + clearInterval(this.statusInterval); + this.statusInterval = null; + } } buildReconnectUrl() { @@ -152,6 +184,8 @@ class EcastShardClient { lobbyState: this.lobbyState, gameState: this.gameState, }); + + this.startStatusBroadcast(); } handleEntityUpdate(result) { @@ -406,6 +440,7 @@ class EcastShardClient { } disconnect() { + this.stopStatusBroadcast(); if (this.ws) { try { this.ws.close(1000, 'Monitor stopped'); @@ -569,4 +604,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 }; diff --git a/docs/api/README.md b/docs/api/README.md index ff6ecd3..d8c2615 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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 diff --git a/docs/api/endpoints/sessions.md b/docs/api/endpoints/sessions.md index 8f34a4d..6487d99 100644 --- a/docs/api/endpoints/sessions.md +++ b/docs/api/endpoints/sessions.md @@ -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. diff --git a/docs/api/websocket.md b/docs/api/websocket.md index 6ee68e3..c99c57c 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -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; diff --git a/frontend/src/config/branding.js b/frontend/src/config/branding.js index 74f717c..ce41257 100644 --- a/frontend/src/config/branding.js +++ b/frontend/src/config/branding.js @@ -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: { diff --git a/frontend/src/pages/Picker.jsx b/frontend/src/pages/Picker.jsx index 2c8f639..8f6414a 100644 --- a/frontend/src/pages/Picker.jsx +++ b/frontend/src/pages/Picker.jsx @@ -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); @@ -968,11 +968,11 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) 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 +1011,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) 'room.disconnected', 'player-count.updated', 'game.added', + 'game.status', ]; if (reloadEvents.includes(message.type)) { diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js index 51c9313..c3d30a2 100644 --- a/tests/api/ecast-shard-client.test.js +++ b/tests/api/ecast-shard-client.test.js @@ -376,8 +376,121 @@ 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()); + + test('broadcasts game.status every 20 seconds', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.playerCount = 2; + client.playerNames = ['A', 'B']; + client.gameState = 'Lobby'; + + client.startStatusBroadcast(); + + jest.advanceTimersByTime(20000); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('game.status'); + expect(events[0].data.monitoring).toBe(true); + + jest.advanceTimersByTime(20000); + expect(events).toHaveLength(2); + + client.stopStatusBroadcast(); + + jest.advanceTimersByTime(40000); + expect(events).toHaveLength(2); + }); + + test('disconnect stops the status broadcast', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + + client.startStatusBroadcast(); + + jest.advanceTimersByTime(20000); + expect(events).toHaveLength(1); + + client.disconnect(); + + jest.advanceTimersByTime(40000); + expect(events).toHaveLength(1); + }); + + test('handleWelcome starts status broadcast', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + + client.handleWelcome({ + id: 7, + secret: 'abc', + reconnect: false, + entities: {}, + here: {}, + }); + + jest.advanceTimersByTime(20000); + 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 +503,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', () => {