Compare commits
3 Commits
a7bd0650eb
...
171303a6f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171303a6f9
|
||
|
|
4999060970
|
||
|
|
34637d6d2c
|
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user