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
This commit is contained in:
@@ -314,6 +314,14 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Game not found' });
|
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)
|
// Set all current 'playing' games to 'played' (except skipped ones)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE session_games
|
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' });
|
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') {
|
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(`
|
db.prepare(`
|
||||||
UPDATE session_games
|
UPDATE session_games
|
||||||
SET status = CASE
|
SET status = CASE
|
||||||
|
|||||||
@@ -126,7 +126,16 @@ class EcastShardClient {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`;
|
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const done = () => { if (!resolved) { resolved = true; resolve(); } };
|
let welcomed = false;
|
||||||
|
const done = (probe) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
if (probe) {
|
||||||
|
try { probe.removeAllListeners(); probe.terminate(); } catch (_) {}
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const probe = new WebSocket(url, ['ecast-v0'], {
|
const probe = new WebSocket(url, ['ecast-v0'], {
|
||||||
@@ -134,15 +143,14 @@ class EcastShardClient {
|
|||||||
handshakeTimeout: 8000,
|
handshakeTimeout: 8000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => done(probe), 10000);
|
||||||
try { probe.close(); } catch (_) {}
|
|
||||||
done();
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
probe.on('message', (data) => {
|
probe.on('message', (data) => {
|
||||||
|
if (welcomed) return;
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.opcode === 'client/welcome') {
|
if (msg.opcode === 'client/welcome') {
|
||||||
|
welcomed = true;
|
||||||
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here);
|
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here);
|
||||||
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
|
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
|
||||||
this.playerCount = playerCount;
|
this.playerCount = playerCount;
|
||||||
@@ -165,14 +173,13 @@ class EcastShardClient {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
try { probe.close(); } catch (_) {}
|
done(probe);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
probe.on('error', () => { clearTimeout(timeout); done(); });
|
probe.on('error', () => { clearTimeout(timeout); done(probe); });
|
||||||
probe.on('close', () => { clearTimeout(timeout); done(); });
|
probe.on('close', () => { clearTimeout(timeout); done(null); });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
done();
|
done(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -532,11 +539,17 @@ function broadcastAndPersist(sessionId, gameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
|
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 {
|
try {
|
||||||
db.prepare(
|
if (eventType === 'game.ended') {
|
||||||
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
db.prepare(
|
||||||
).run(eventData.playerCount ?? null, status, sessionId, gameId);
|
'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) {
|
} catch (e) {
|
||||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||||
}
|
}
|
||||||
@@ -544,15 +557,22 @@ function broadcastAndPersist(sessionId, gameId) {
|
|||||||
|
|
||||||
if (eventType === 'room.disconnected') {
|
if (eventType === 'room.disconnected') {
|
||||||
const reason = eventData.reason;
|
const reason = eventData.reason;
|
||||||
const status =
|
const checkStatus =
|
||||||
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
|
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
|
||||||
try {
|
try {
|
||||||
const game = db
|
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);
|
.get(sessionId, gameId);
|
||||||
if (game && game.player_count_check_status !== 'completed') {
|
if (game && game.player_count_check_status !== 'completed') {
|
||||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
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,
|
sessionId,
|
||||||
gameId
|
gameId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
@@ -952,17 +952,33 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
|
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
|
||||||
const [newPlayerCount, setNewPlayerCount] = useState('');
|
const [newPlayerCount, setNewPlayerCount] = useState('');
|
||||||
|
|
||||||
|
const playingGameRef = useRef(playingGame);
|
||||||
|
playingGameRef.current = playingGame;
|
||||||
|
|
||||||
const loadGames = useCallback(async () => {
|
const loadGames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||||
// Reverse chronological order (most recent first)
|
const freshGames = response.data;
|
||||||
setGames(response.data.reverse());
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load session games');
|
console.error('Failed to load session games');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [sessionId, setPlayingGame]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGames();
|
loadGames();
|
||||||
|
|||||||
Reference in New Issue
Block a user