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:
cottongin
2026-03-20 23:34:22 -04:00
parent 4999060970
commit 171303a6f9
3 changed files with 75 additions and 22 deletions

View File

@@ -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

View File

@@ -126,7 +126,16 @@ class EcastShardClient {
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(); } };
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'], {
@@ -134,15 +143,14 @@ class EcastShardClient {
handshakeTimeout: 8000,
});
const timeout = setTimeout(() => {
try { probe.close(); } catch (_) {}
done();
}, 10000);
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;
@@ -165,14 +173,13 @@ class EcastShardClient {
}
} catch (_) {}
clearTimeout(timeout);
try { probe.close(); } catch (_) {}
done();
done(probe);
});
probe.on('error', () => { clearTimeout(timeout); done(); });
probe.on('close', () => { clearTimeout(timeout); done(); });
probe.on('error', () => { clearTimeout(timeout); done(probe); });
probe.on('close', () => { clearTimeout(timeout); done(null); });
} catch (_) {
done();
done(null);
}
});
}
@@ -532,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);
}
@@ -544,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
);

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';
@@ -952,17 +952,33 @@ 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();