Compare commits
8 Commits
0a59da8ee9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195448644a
|
||
|
|
c5ffe23404
|
||
|
|
2964cee291
|
||
|
|
91b7de3bb7
|
||
|
|
ea23b66cbf
|
||
|
|
ea6e8db90b
|
||
|
|
b2bb2989e9
|
||
|
|
52e9a7af42
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ frontend/public/manifest.json
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
logs/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
|
||||||
|
|
||||||
# Jackbox Party Pack Game Picker
|
# Jackbox Party Pack Game Picker
|
||||||
|
|
||||||
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.
|
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.
|
||||||
|
|||||||
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
config/admins.json
|
||||||
|
|
||||||
29
backend/bootstrap.js
vendored
29
backend/bootstrap.js
vendored
@@ -54,6 +54,33 @@ function bootstrapGames() {
|
|||||||
console.log(`Successfully imported ${records.length} games from CSV`);
|
console.log(`Successfully imported ${records.length} games from CSV`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bootstrapTickers() {
|
||||||
|
const tickersPath = path.join(__dirname, 'config', 'tickers.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(tickersPath)) {
|
||||||
|
console.log('tickers.json not found. Skipping ticker bootstrap.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickers = JSON.parse(fs.readFileSync(tickersPath, 'utf-8'));
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE games SET ticker = ? WHERE title = ? AND (ticker IS NULL OR ticker != ?)');
|
||||||
|
|
||||||
|
const updateMany = db.transaction((entries) => {
|
||||||
|
let updated = 0;
|
||||||
|
for (const [symbol, title] of entries) {
|
||||||
|
const result = update.run(symbol, title, symbol);
|
||||||
|
updated += result.changes;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateMany(Object.entries(tickers));
|
||||||
|
if (updated > 0) {
|
||||||
|
console.log(`Updated ticker symbols for ${updated} games`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseLengthMinutes(lengthStr) {
|
function parseLengthMinutes(lengthStr) {
|
||||||
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
||||||
return null;
|
return null;
|
||||||
@@ -69,5 +96,5 @@ function parseBoolean(value) {
|
|||||||
return value.toLowerCase() === 'yes' ? 1 : 0;
|
return value.toLowerCase() === 'yes' ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bootstrapGames };
|
module.exports = { bootstrapGames, bootstrapTickers };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
{ "name": "Alice", "key": "change-me-alice-key" },
|
{ "name": "Alice", "role": "admin", "key": "change-me-alice-key" },
|
||||||
{ "name": "Bob", "key": "change-me-bob-key" }
|
{ "name": "Bob", "role": "bot", "key": "change-me-bob-key" },
|
||||||
|
{ "name": "Charlie", "role": "utility", "key": "change-me-charlie-key" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ const path = require('path');
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
||||||
|
|
||||||
|
function canRead(filePath) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadAdmins() {
|
function loadAdmins() {
|
||||||
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (canRead(configPath)) {
|
||||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
const admins = JSON.parse(raw);
|
const admins = JSON.parse(raw);
|
||||||
|
|
||||||
@@ -35,6 +44,10 @@ function loadAdmins() {
|
|||||||
return admins;
|
return admins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(configPath) && !canRead(configPath)) {
|
||||||
|
console.warn(`[Auth] Config file exists at ${configPath} but is not readable, skipping`);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.ADMIN_KEY) {
|
if (process.env.ADMIN_KEY) {
|
||||||
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
||||||
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
||||||
@@ -49,7 +62,7 @@ const admins = loadAdmins();
|
|||||||
|
|
||||||
function findAdminByKey(key) {
|
function findAdminByKey(key) {
|
||||||
const match = admins.find(a => a.key === key);
|
const match = admins.find(a => a.key === key);
|
||||||
return match ? { name: match.name } : null;
|
return match ? { name: match.name, role: match.role || 'admin' } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { findAdminByKey, admins };
|
module.exports = { findAdminByKey, admins };
|
||||||
|
|||||||
60
backend/config/tickers.json
Normal file
60
backend/config/tickers.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"QPL3": "Quiplash 3",
|
||||||
|
"QPL2": "Quiplash 2",
|
||||||
|
"QLXL": "Quiplash XL",
|
||||||
|
"FBXL": "Fibbage XL",
|
||||||
|
"FBG2": "Fibbage 2",
|
||||||
|
"FBG3": "Fibbage 3",
|
||||||
|
"FBG4": "Fibbage 4",
|
||||||
|
"TMP1": "Trivia Murder Party",
|
||||||
|
"TMP2": "Trivia Murder Party 2",
|
||||||
|
"DRWF": "Drawful",
|
||||||
|
"DRWA": "Drawful Animate",
|
||||||
|
"DD": "Dirty Drawful",
|
||||||
|
"DOOM": "Doominate",
|
||||||
|
"JJ": "Job Job",
|
||||||
|
"TKO2": "Tee K.O. 2",
|
||||||
|
"TKOX": "Tee K.O. T-Shirt Knock Out",
|
||||||
|
"CU": "Champ'd Up",
|
||||||
|
"BR": "Blather 'Round",
|
||||||
|
"STR": "Split the Room",
|
||||||
|
"ROOM": "Roomerang",
|
||||||
|
"BRKT": "Bracketeering",
|
||||||
|
"NNSR": "Nonsensory",
|
||||||
|
"QXRT": "Quixort",
|
||||||
|
"JNKT": "Junktopia",
|
||||||
|
"TP": "Talking Points",
|
||||||
|
"PS": "Patently Stupid",
|
||||||
|
"PTB": "Push the Button",
|
||||||
|
"WD": "Weapons Drawn",
|
||||||
|
"HPNT": "Hypnotorious",
|
||||||
|
"DCTN": "Dictionarium",
|
||||||
|
"RM": "Role Models",
|
||||||
|
"JB": "Joke Boat",
|
||||||
|
"GSPN": "Guesspionage",
|
||||||
|
"MVC": "Mad Verse City",
|
||||||
|
"HRSY": "Hear Say",
|
||||||
|
"CH": "Cookie Haus",
|
||||||
|
"SPCT": "Suspectives",
|
||||||
|
"LOT": "Legends of Trivia",
|
||||||
|
"STI": "Survive the Internet",
|
||||||
|
"CVDL": "Civic Doodle",
|
||||||
|
"MSM": "Monster Seeking Monster",
|
||||||
|
"TPM": "The Poll Mine",
|
||||||
|
"TWEP": "The Wheel of Enormous Proportions",
|
||||||
|
"TJ": "Time Jinx",
|
||||||
|
"DRM": "Dodo Re Mi",
|
||||||
|
"FT": "Fixy Text",
|
||||||
|
"SS": "Survey Scramble",
|
||||||
|
"WS": "Word Spud",
|
||||||
|
"LS": "Lie Swatter",
|
||||||
|
"FI": "Fakin' It!",
|
||||||
|
"FANL": "Fakin' It All Night Long",
|
||||||
|
"LMF": "Let Me Finish",
|
||||||
|
"BDTS": "Bidiots",
|
||||||
|
"BC": "Bomb Corp.",
|
||||||
|
"YDK1": "You Don't Know Jack\u00ae 2015",
|
||||||
|
"YDKJ": "You Don't Know Jack\u00ae Full Stream",
|
||||||
|
"ZPDM": "Zeeple Dome",
|
||||||
|
"EW": "Earwax\u2122"
|
||||||
|
}
|
||||||
@@ -125,6 +125,19 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add ticker column for ticker-symbol voting
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE games ADD COLUMN ticker TEXT`);
|
||||||
|
} catch (err) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_games_ticker ON games(ticker)`);
|
||||||
|
} catch (err) {
|
||||||
|
// Index already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate existing popularity_score to upvotes/downvotes if needed
|
// Migrate existing popularity_score to upvotes/downvotes if needed
|
||||||
try {
|
try {
|
||||||
const gamesWithScore = db.prepare(`
|
const gamesWithScore = db.prepare(`
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router.post('/login', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ role: 'admin', name: admin.name, timestamp: Date.now() },
|
{ role: admin.role, name: admin.name, timestamp: Date.now() },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
@@ -26,6 +26,7 @@ router.post('/login', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
name: admin.name,
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
message: 'Authentication successful',
|
message: 'Authentication successful',
|
||||||
expiresIn: '24h'
|
expiresIn: '24h'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ router.get('/', (req, res) => {
|
|||||||
// Live vote endpoint - receives real-time votes from bot
|
// Live vote endpoint - receives real-time votes from bot
|
||||||
router.post('/live', authenticateToken, (req, res) => {
|
router.post('/live', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, vote, timestamp } = req.body;
|
const { username, vote, timestamp, ticker } = req.body;
|
||||||
|
|
||||||
// Validate payload
|
// Validate payload
|
||||||
if (!username || !vote || !timestamp) {
|
if (!username || !vote || !timestamp) {
|
||||||
@@ -123,57 +123,72 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all games played in this session with timestamps
|
|
||||||
const sessionGames = db.prepare(`
|
|
||||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
|
||||||
FROM session_games sg
|
|
||||||
JOIN games g ON sg.game_id = g.id
|
|
||||||
WHERE sg.session_id = ?
|
|
||||||
ORDER BY sg.played_at ASC
|
|
||||||
`).all(activeSession.id);
|
|
||||||
|
|
||||||
if (sessionGames.length === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'No games have been played in the active session yet'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match vote timestamp to the correct game using interval logic
|
|
||||||
const voteTime = voteTimestamp.getTime();
|
|
||||||
let matchedGame = null;
|
let matchedGame = null;
|
||||||
|
|
||||||
for (let i = 0; i < sessionGames.length; i++) {
|
if (ticker) {
|
||||||
const currentGame = sessionGames[i];
|
// Ticker voting: resolve game globally by ticker symbol
|
||||||
const nextGame = sessionGames[i + 1];
|
const game = db.prepare(`
|
||||||
|
SELECT id AS game_id, title, pack_name, upvotes, downvotes, popularity_score
|
||||||
|
FROM games WHERE ticker = ?
|
||||||
|
`).get(ticker);
|
||||||
|
|
||||||
const currentGameTime = new Date(currentGame.played_at).getTime();
|
if (!game) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Unknown ticker '${ticker}'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (nextGame) {
|
matchedGame = game;
|
||||||
const nextGameTime = new Date(nextGame.played_at).getTime();
|
} else {
|
||||||
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
// thisgame++/thisgame-- voting: resolve game by timestamp interval
|
||||||
matchedGame = currentGame;
|
const sessionGames = db.prepare(`
|
||||||
break;
|
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||||
}
|
FROM session_games sg
|
||||||
} else {
|
JOIN games g ON sg.game_id = g.id
|
||||||
// Last game in session - vote belongs here if timestamp is after this game started
|
WHERE sg.session_id = ?
|
||||||
if (voteTime >= currentGameTime) {
|
ORDER BY sg.played_at ASC
|
||||||
matchedGame = currentGame;
|
`).all(activeSession.id);
|
||||||
break;
|
|
||||||
|
if (sessionGames.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'No games have been played in the active session yet'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const voteTime = voteTimestamp.getTime();
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionGames.length; i++) {
|
||||||
|
const currentGame = sessionGames[i];
|
||||||
|
const nextGame = sessionGames[i + 1];
|
||||||
|
|
||||||
|
const currentGameTime = new Date(currentGame.played_at).getTime();
|
||||||
|
|
||||||
|
if (nextGame) {
|
||||||
|
const nextGameTime = new Date(nextGame.played_at).getTime();
|
||||||
|
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
||||||
|
matchedGame = currentGame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (voteTime >= currentGameTime) {
|
||||||
|
matchedGame = currentGame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedGame) {
|
if (!matchedGame) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'Vote timestamp does not match any game in the active session',
|
error: 'Vote timestamp does not match any game in the active session',
|
||||||
debug: {
|
debug: {
|
||||||
voteTimestamp: timestamp,
|
voteTimestamp: timestamp,
|
||||||
sessionGames: sessionGames.map(g => ({
|
sessionGames: sessionGames.map(g => ({
|
||||||
title: g.title,
|
title: g.title,
|
||||||
played_at: g.played_at
|
played_at: g.played_at
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate vote (within 1 second window)
|
// Check for duplicate vote (within 1 second window)
|
||||||
@@ -258,6 +273,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
id: updatedGame.id,
|
id: updatedGame.id,
|
||||||
title: updatedGame.title,
|
title: updatedGame.title,
|
||||||
pack_name: matchedGame.pack_name,
|
pack_name: matchedGame.pack_name,
|
||||||
|
ticker: ticker || undefined,
|
||||||
},
|
},
|
||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
@@ -303,7 +319,8 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
type: vote,
|
type: vote,
|
||||||
timestamp: timestamp
|
timestamp: timestamp,
|
||||||
|
ticker: ticker || undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { bootstrapGames } = require('./bootstrap');
|
const { bootstrapGames, bootstrapTickers } = require('./bootstrap');
|
||||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ setWebSocketManager(wsManager);
|
|||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
bootstrapGames();
|
bootstrapGames();
|
||||||
|
bootstrapTickers();
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class EcastShardClient {
|
|||||||
lobbyState: roomVal.lobbyState ?? null,
|
lobbyState: roomVal.lobbyState ?? null,
|
||||||
gameCanStart: !!roomVal.gameCanStart,
|
gameCanStart: !!roomVal.gameCanStart,
|
||||||
gameIsStarting: !!roomVal.gameIsStarting,
|
gameIsStarting: !!roomVal.gameIsStarting,
|
||||||
gameStarted: roomVal.state === 'Gameplay',
|
gameStarted: roomVal.state != null && roomVal.state !== 'Lobby',
|
||||||
gameFinished: !!roomVal.gameFinished,
|
gameFinished: !!roomVal.gameFinished,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,12 @@ class EcastShardClient {
|
|||||||
break;
|
break;
|
||||||
case 'client/disconnected':
|
case 'client/disconnected':
|
||||||
break;
|
break;
|
||||||
|
case 'room/lock':
|
||||||
|
this.handleRoomLock();
|
||||||
|
break;
|
||||||
|
case 'room/exit':
|
||||||
|
this.handleRoomExit(message.result);
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
this.handleError(message.result);
|
this.handleError(message.result);
|
||||||
break;
|
break;
|
||||||
@@ -363,6 +369,44 @@ class EcastShardClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRoomLock() {
|
||||||
|
if (!this.gameStarted) {
|
||||||
|
console.log(`[Shard Monitor] Room ${this.roomCode} locked (game starting)`);
|
||||||
|
this.gameStarted = true;
|
||||||
|
this.gameState = this.gameState || 'Gameplay';
|
||||||
|
this.onEvent('game.started', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRoomExit() {
|
||||||
|
if (this.gameFinished) return;
|
||||||
|
console.log(`[Shard Monitor] Room ${this.roomCode} exited`);
|
||||||
|
this.gameFinished = true;
|
||||||
|
this.onEvent('game.ended', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
});
|
||||||
|
this.onEvent('room.disconnected', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
reason: 'room_closed',
|
||||||
|
finalPlayerCount: this.playerCount,
|
||||||
|
});
|
||||||
|
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
handleError(result) {
|
handleError(result) {
|
||||||
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
||||||
if (result?.code === 2027) {
|
if (result?.code === 2027) {
|
||||||
|
|||||||
@@ -26,13 +26,17 @@ class WebSocketManager {
|
|||||||
* Handle new WebSocket connection
|
* Handle new WebSocket connection
|
||||||
*/
|
*/
|
||||||
handleConnection(ws, req) {
|
handleConnection(ws, req) {
|
||||||
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req.headers['x-real-ip']
|
||||||
|
|| req.socket.remoteAddress;
|
||||||
|
console.log('[WebSocket] New connection from', clientIp);
|
||||||
|
|
||||||
// Initialize client info
|
// Initialize client info
|
||||||
const clientInfo = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
adminName: null,
|
adminName: null,
|
||||||
|
role: null,
|
||||||
currentPage: null,
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
@@ -130,6 +134,7 @@ class WebSocketManager {
|
|||||||
clientInfo.authenticated = true;
|
clientInfo.authenticated = true;
|
||||||
clientInfo.userId = decoded.role;
|
clientInfo.userId = decoded.role;
|
||||||
clientInfo.adminName = decoded.name || null;
|
clientInfo.adminName = decoded.name || null;
|
||||||
|
clientInfo.role = decoded.role || 'admin';
|
||||||
|
|
||||||
if (!decoded.name) {
|
if (!decoded.name) {
|
||||||
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
||||||
@@ -141,7 +146,8 @@ class WebSocketManager {
|
|||||||
message: 'Authenticated successfully'
|
message: 'Authenticated successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
|
console.log('[WebSocket] Client authenticated:', clientInfo.adminName);
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WebSocket] Authentication failed:', err.message);
|
console.error('[WebSocket] Authentication failed:', err.message);
|
||||||
@@ -299,7 +305,7 @@ class WebSocketManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
console.log('[WebSocket] Client disconnected:', clientInfo.adminName || 'unauthenticated');
|
||||||
this.broadcastPresence();
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,8 +313,12 @@ class WebSocketManager {
|
|||||||
broadcastPresence() {
|
broadcastPresence() {
|
||||||
const admins = [];
|
const admins = [];
|
||||||
this.clients.forEach((info) => {
|
this.clients.forEach((info) => {
|
||||||
if (info.authenticated && info.adminName && info.currentPage) {
|
if (!info.authenticated || !info.adminName) return;
|
||||||
admins.push({ name: info.adminName, page: info.currentPage });
|
const role = info.role || 'admin';
|
||||||
|
if (role === 'bot' || role === 'utility') {
|
||||||
|
admins.push({ name: info.adminName, role, page: null });
|
||||||
|
} else if (info.currentPage) {
|
||||||
|
admins.push({ name: info.adminName, role, page: info.currentPage });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ services:
|
|||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
volumes:
|
volumes:
|
||||||
- jackbox-data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro,z
|
||||||
# - ./backend/config/admins.json:/app/config/admins.json:ro
|
- ./backend/config/admins.json:/app/config/admins.json:ro,z
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Votes Endpoints
|
# Votes Endpoints
|
||||||
|
|
||||||
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Votes are matched to the currently-playing game using timestamp intervals.
|
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Two voting mechanisms are supported:
|
||||||
|
|
||||||
|
- **`thisgame++`/`thisgame--`** — votes for the game currently being played, matched via timestamp intervals.
|
||||||
|
- **Ticker voting** — votes for a specific game by its ticker symbol (e.g. `QPL3` for Quiplash 3), regardless of what is currently being played.
|
||||||
|
|
||||||
## Endpoint Summary
|
## Endpoint Summary
|
||||||
|
|
||||||
@@ -71,7 +74,10 @@ Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"
|
|||||||
|
|
||||||
## POST /api/votes/live
|
## POST /api/votes/live
|
||||||
|
|
||||||
Submit a real-time up/down vote for the game currently being played. Automatically finds the active session and matches the vote to the correct game using the provided timestamp and session game intervals.
|
Submit a real-time up/down vote. Supports two independent voting mechanisms:
|
||||||
|
|
||||||
|
- **Ticker voting** — include a `ticker` field to vote for a specific game by symbol. The game is resolved globally and does not need to be in the active session.
|
||||||
|
- **`thisgame++`/`thisgame--` voting** — omit `ticker` to vote for the game currently being played, matched via timestamp intervals against `session_games`.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -84,6 +90,20 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
||||||
| vote | string | Yes | `"up"` or `"down"` |
|
| vote | string | Yes | `"up"` or `"down"` |
|
||||||
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
||||||
|
| ticker | string | No | Ticker symbol identifying the game (e.g. `QPL3`, `TMP2`). When provided, the game is resolved by ticker and timestamp matching is skipped. |
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++`/`thisgame--` vote (no ticker):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -96,7 +116,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
### Behavior
|
### Behavior
|
||||||
|
|
||||||
- Finds the active session (single session with `is_active = 1`).
|
- Finds the active session (single session with `is_active = 1`).
|
||||||
- Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
- **With `ticker`:** Looks up the game globally by ticker symbol. The game does not need to be part of the active session.
|
||||||
|
- **Without `ticker`:** Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
||||||
- Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction.
|
- Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction.
|
||||||
- **Deduplication:** Rejects votes from the same username within a 1-second window (409 Conflict).
|
- **Deduplication:** Rejects votes from the same username within a 1-second window (409 Conflict).
|
||||||
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
||||||
@@ -105,6 +126,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
|
|
||||||
**200 OK**
|
**200 OK**
|
||||||
|
|
||||||
|
The `ticker` field is included in the response when the vote was submitted with a ticker.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -120,7 +143,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
"vote": {
|
"vote": {
|
||||||
"username": "viewer123",
|
"username": "viewer123",
|
||||||
"type": "up",
|
"type": "up",
|
||||||
"timestamp": "2026-03-15T20:30:00Z"
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -133,12 +157,29 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
||||||
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
||||||
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
||||||
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games |
|
| 404 | `{ "error": "Unknown ticker 'XYZ'" }` | Ticker does not match any game |
|
||||||
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { "voteTimestamp": "2026-03-15T20:30:00Z", "sessionGames": [{ "title": "Quiplash 3", "played_at": "..." }] } }` | Timestamp outside any game interval |
|
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games (timestamp voting only) |
|
||||||
|
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { ... } }` | Timestamp outside any game interval (timestamp voting only) |
|
||||||
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
||||||
| 500 | `{ "error": "..." }` | Server error |
|
| 500 | `{ "error": "..." }` | Server error |
|
||||||
|
|
||||||
### Example
|
### Examples
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++` vote (no ticker):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Presence */}
|
|
||||||
<PresenceBar />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8 flex-grow">
|
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -172,8 +169,11 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Admin Presence */}
|
||||||
|
<PresenceBar />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 flex-shrink-0">
|
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="text-center md:text-left">
|
<div className="text-center md:text-left">
|
||||||
|
|||||||
@@ -2,36 +2,86 @@ import React from 'react';
|
|||||||
import { usePresence } from '../hooks/usePresence';
|
import { usePresence } from '../hooks/usePresence';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubbleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceBadge({ name, role }) {
|
||||||
|
const isBot = role === 'bot';
|
||||||
|
const colorClass = isBot
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300'
|
||||||
|
: 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{isBot ? <ChatBubbleIcon /> : <GearIcon />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PresenceBar() {
|
function PresenceBar() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const { viewers } = usePresence();
|
const { viewers, services } = usePresence();
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
const otherViewers = viewers.filter(v => v !== 'me');
|
const otherViewers = viewers.filter(v => v.name !== 'me');
|
||||||
if (otherViewers.length === 0) return null;
|
const hasViewers = otherViewers.length > 0;
|
||||||
|
const hasServices = services.length > 0;
|
||||||
|
|
||||||
|
if (!hasViewers && !hasServices) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-2 sm:px-4 pt-3">
|
<div className="sticky bottom-0 z-40 flex justify-end px-4 pb-4 pointer-events-none">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
|
<div className="pointer-events-auto bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2 w-fit">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
{hasViewers && (
|
||||||
who's here?
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
who's here?
|
||||||
{viewers.map((name, i) => (
|
|
||||||
<span
|
|
||||||
key={`${name}-${i}`}
|
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
name === 'me'
|
|
||||||
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</div>
|
{viewers.map((v, i) => (
|
||||||
|
<span
|
||||||
|
key={`${v.name}-${i}`}
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
v.name === 'me'
|
||||||
|
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasServices && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||||
|
connected
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<ServiceBadge key={`${s.name}-${i}`} name={s.name} role={s.role} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: 'Jackbox Game Picker',
|
shortName: 'Jackbox Game Picker',
|
||||||
version: '0.6.4 - Fish Tank Edition',
|
version: '0.7.0 - Fixed For Real Edition',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const useAuth = () => {
|
|||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||||
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
||||||
|
const [adminRole, setAdminRole] = useState(localStorage.getItem('adminRole'));
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -27,9 +28,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
const name = response.data.user?.name;
|
const name = response.data.user?.name;
|
||||||
|
const role = response.data.user?.role || 'admin';
|
||||||
if (name) {
|
if (name) {
|
||||||
setAdminName(name);
|
setAdminName(name);
|
||||||
|
setAdminRole(role);
|
||||||
localStorage.setItem('adminName', name);
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role);
|
||||||
} else {
|
} else {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
@@ -47,11 +51,13 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const login = async (key) => {
|
const login = async (key) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/auth/login', { key });
|
const response = await axios.post('/api/auth/login', { key });
|
||||||
const { token: newToken, name } = response.data;
|
const { token: newToken, name, role } = response.data;
|
||||||
localStorage.setItem('adminToken', newToken);
|
localStorage.setItem('adminToken', newToken);
|
||||||
localStorage.setItem('adminName', name);
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role || 'admin');
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
setAdminName(name);
|
setAdminName(name);
|
||||||
|
setAdminRole(role || 'admin');
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
migratePreferences(name);
|
migratePreferences(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -66,14 +72,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
localStorage.removeItem('adminName');
|
localStorage.removeItem('adminName');
|
||||||
|
localStorage.removeItem('adminRole');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setAdminName(null);
|
setAdminName(null);
|
||||||
|
setAdminRole(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
adminName,
|
adminName,
|
||||||
|
adminRole,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ export function usePresence() {
|
|||||||
const { token, adminName, isAuthenticated } = useAuth();
|
const { token, adminName, isAuthenticated } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [viewers, setViewers] = useState([]);
|
const [viewers, setViewers] = useState([]);
|
||||||
|
const [services, setServices] = useState([]);
|
||||||
const wsRef = useRef(null);
|
const wsRef = useRef(null);
|
||||||
const pingRef = useRef(null);
|
const pingRef = useRef(null);
|
||||||
const reconnectRef = useRef(null);
|
const reconnectRef = useRef(null);
|
||||||
|
const locationRef = useRef(location.pathname);
|
||||||
|
const adminNameRef = useRef(adminName);
|
||||||
|
|
||||||
|
locationRef.current = location.pathname;
|
||||||
|
adminNameRef.current = adminName;
|
||||||
|
|
||||||
const getWsUrl = useCallback(() => {
|
const getWsUrl = useCallback(() => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -32,7 +38,7 @@ export function usePresence() {
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
if (msg.type === 'auth_success') {
|
if (msg.type === 'auth_success') {
|
||||||
ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
ws.send(JSON.stringify({ type: 'page_focus', page: locationRef.current }));
|
||||||
|
|
||||||
pingRef.current = setInterval(() => {
|
pingRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -42,11 +48,16 @@ export function usePresence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'presence_update') {
|
if (msg.type === 'presence_update') {
|
||||||
const currentPage = location.pathname;
|
const currentPage = locationRef.current;
|
||||||
const onSamePage = msg.admins
|
const currentName = adminNameRef.current;
|
||||||
.filter(a => a.page === currentPage)
|
const pageViewers = msg.admins
|
||||||
.map(a => a.name === adminName ? 'me' : a.name);
|
.filter(a => a.role !== 'bot' && a.role !== 'utility' && a.page === currentPage)
|
||||||
setViewers(onSamePage);
|
.map(a => ({ name: a.name === currentName ? 'me' : a.name, role: a.role || 'admin' }));
|
||||||
|
const connectedServices = msg.admins
|
||||||
|
.filter(a => a.role === 'bot' || a.role === 'utility')
|
||||||
|
.map(a => ({ name: a.name, role: a.role }));
|
||||||
|
setViewers(pageViewers);
|
||||||
|
setServices(connectedServices);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +69,7 @@ export function usePresence() {
|
|||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, token, adminName, location.pathname, getWsUrl]);
|
}, [isAuthenticated, token, getWsUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
@@ -79,5 +90,5 @@ export function usePresence() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
return { viewers };
|
return { viewers, services };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ describe('EcastShardClient', () => {
|
|||||||
expect(result.gameStarted).toBe(true);
|
expect(result.gameStarted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detects game started from non-Lobby state (Pack 7 Logo)', () => {
|
||||||
|
const roomVal = { state: 'Logo', locale: 'en', platformId: 'PS4' };
|
||||||
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
|
expect(result.gameStarted).toBe(true);
|
||||||
|
expect(result.gameState).toBe('Logo');
|
||||||
|
expect(result.lobbyState).toBeNull();
|
||||||
|
expect(result.gameFinished).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('detects game finished', () => {
|
test('detects game finished', () => {
|
||||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
@@ -272,6 +281,26 @@ describe('EcastShardClient', () => {
|
|||||||
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.started on state transition to Logo (Pack 7)', () => {
|
||||||
|
client.lobbyState = 'Countdown';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Logo', locale: 'en', platformId: 'PS4' },
|
||||||
|
version: 14, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
expect(client.gameState).toBe('Logo');
|
||||||
|
expect(client.gameStarted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not broadcast game.started if already started', () => {
|
test('does not broadcast game.started if already started', () => {
|
||||||
client.gameStarted = true;
|
client.gameStarted = true;
|
||||||
client.gameState = 'Gameplay';
|
client.gameState = 'Gameplay';
|
||||||
@@ -573,4 +602,80 @@ describe('EcastShardClient', () => {
|
|||||||
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handleRoomLock', () => {
|
||||||
|
test('emits game.started when game has not yet started', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleRoomLock();
|
||||||
|
|
||||||
|
expect(client.gameStarted).toBe(true);
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not emit game.started if game already started', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameStarted = true;
|
||||||
|
|
||||||
|
client.handleRoomLock();
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRoomExit', () => {
|
||||||
|
test('emits game.ended and room.disconnected on room exit', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.playerCount = 3;
|
||||||
|
client.playerNames = ['X', 'Y', 'Z'];
|
||||||
|
|
||||||
|
client.handleRoomExit();
|
||||||
|
|
||||||
|
expect(client.gameFinished).toBe(true);
|
||||||
|
expect(events.some(e => e.type === 'game.ended')).toBe(true);
|
||||||
|
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not emit events if game already finished', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameFinished = true;
|
||||||
|
|
||||||
|
client.handleRoomExit();
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,23 +26,33 @@ describe('load-admins', () => {
|
|||||||
|
|
||||||
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
||||||
const configPath = writeConfig([
|
const configPath = writeConfig([
|
||||||
{ name: 'Alice', key: 'key-a' },
|
{ name: 'Alice', role: 'admin', key: 'key-a' },
|
||||||
{ name: 'Bob', key: 'key-b' }
|
{ name: 'Bob', role: 'bot', key: 'key-b' }
|
||||||
]);
|
]);
|
||||||
process.env.ADMIN_CONFIG_PATH = configPath;
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' });
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' });
|
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob', role: 'bot' });
|
||||||
expect(findAdminByKey('wrong')).toBeNull();
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('defaults role to admin when not specified', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
test('falls back to ADMIN_KEY when no config file', () => {
|
test('falls back to ADMIN_KEY when no config file', () => {
|
||||||
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||||
process.env.ADMIN_KEY = 'legacy-key';
|
process.env.ADMIN_KEY = 'legacy-key';
|
||||||
|
|
||||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' });
|
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin', role: 'admin' });
|
||||||
expect(findAdminByKey('wrong')).toBeNull();
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +101,7 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
({ app } = require('../../backend/server'));
|
({ app } = require('../../backend/server'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login returns admin name in response', async () => {
|
test('login returns admin name and role in response', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/auth/login')
|
.post('/api/auth/login')
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@@ -99,10 +109,11 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.name).toBeDefined();
|
expect(res.body.name).toBeDefined();
|
||||||
|
expect(res.body.role).toBe('admin');
|
||||||
expect(res.body.token).toBeDefined();
|
expect(res.body.token).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verify returns admin name in user object', async () => {
|
test('verify returns admin name and role in user object', async () => {
|
||||||
const loginRes = await request(app)
|
const loginRes = await request(app)
|
||||||
.post('/api/auth/login')
|
.post('/api/auth/login')
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@@ -114,6 +125,7 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.user.name).toBeDefined();
|
expect(res.body.user.name).toBeDefined();
|
||||||
|
expect(res.body.user.role).toBe('admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid key still returns 401', async () => {
|
test('invalid key still returns 401', async () => {
|
||||||
@@ -155,15 +167,15 @@ describe('WebSocket presence', () => {
|
|||||||
server.close(done);
|
server.close(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeToken(name) {
|
function makeToken(name, role = 'admin') {
|
||||||
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
return jwt.sign({ role, name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectAndAuth(name) {
|
function connectAndAuth(name, role = 'admin') {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name, role) }));
|
||||||
});
|
});
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
@@ -187,7 +199,7 @@ describe('WebSocket presence', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('page_focus triggers presence_update with admin name and page', async () => {
|
test('page_focus triggers presence_update with admin name, role, and page', async () => {
|
||||||
const ws1 = await connectAndAuth('Alice');
|
const ws1 = await connectAndAuth('Alice');
|
||||||
const ws2 = await connectAndAuth('Bob');
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
@@ -198,7 +210,7 @@ describe('WebSocket presence', () => {
|
|||||||
const msg = await presencePromise;
|
const msg = await presencePromise;
|
||||||
expect(msg.admins).toEqual(
|
expect(msg.admins).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ name: 'Alice', page: '/history' })
|
expect.objectContaining({ name: 'Alice', role: 'admin', page: '/history' })
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,4 +236,61 @@ describe('WebSocket presence', () => {
|
|||||||
|
|
||||||
ws2.close();
|
ws2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bot role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsBot = await connectAndAuth('ChatBot', 'bot');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const botEntry = msg.admins.find(a => a.name === 'ChatBot');
|
||||||
|
expect(botEntry).toBeDefined();
|
||||||
|
expect(botEntry.role).toBe('bot');
|
||||||
|
expect(botEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsBot.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('utility role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsUtil = await connectAndAuth('OBS', 'utility');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const utilEntry = msg.admins.find(a => a.name === 'OBS');
|
||||||
|
expect(utilEntry).toBeDefined();
|
||||||
|
expect(utilEntry.role).toBe('utility');
|
||||||
|
expect(utilEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsUtil.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin without page_focus is NOT in presence', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws1, 'presence_update');
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const bobEntry = msg.admins.find(a => a.name === 'Bob');
|
||||||
|
expect(bobEntry).toBeUndefined();
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
|||||||
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/votes/live — ticker voting', () => {
|
||||||
|
let session, tickerGame, sessionGame;
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
tickerGame = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', ticker: 'QPL3' });
|
||||||
|
sessionGame = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, sessionGame.id, { status: 'playing', played_at: baseTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote with valid ticker resolves to the correct game', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(tickerGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Quiplash 3');
|
||||||
|
expect(res.body.vote.ticker).toBe('QPL3');
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT * FROM live_votes WHERE game_id = ? AND username = ?'
|
||||||
|
).get(tickerGame.id, 'viewer1');
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.vote_type).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote works for a game not in the active session', async () => {
|
||||||
|
const outsideGame = seedGame({ title: 'Fibbage XL', pack_name: 'Party Pack 1', ticker: 'FBXL' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'FBXL',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(outsideGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Fibbage XL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown ticker returns 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'NOPE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/Unknown ticker 'NOPE'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote updates game scores', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer2',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:06:05.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = db.prepare('SELECT upvotes, downvotes, popularity_score FROM games WHERE id = ?').get(tickerGame.id);
|
||||||
|
expect(game.upvotes).toBe(1);
|
||||||
|
expect(game.downvotes).toBe(1);
|
||||||
|
expect(game.popularity_score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ function seedGame(overrides = {}) {
|
|||||||
upvotes: 0,
|
upvotes: 0,
|
||||||
downvotes: 0,
|
downvotes: 0,
|
||||||
popularity_score: 0,
|
popularity_score: 0,
|
||||||
|
ticker: null,
|
||||||
};
|
};
|
||||||
const g = { ...defaults, ...overrides };
|
const g = { ...defaults, ...overrides };
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score)
|
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score, ticker)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score);
|
`).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score, g.ticker);
|
||||||
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
492
tools/jackbox-logger.js
Normal file
492
tools/jackbox-logger.js
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let WebSocket;
|
||||||
|
try {
|
||||||
|
WebSocket = require('ws');
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
WebSocket = require('../backend/node_modules/ws');
|
||||||
|
} catch (_2) {
|
||||||
|
console.error('Error: WebSocket library (ws) not found.');
|
||||||
|
console.error('Run: cd backend && npm install');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ANSI helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const C = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
white: '\x1b[37m',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI argument parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||||
|
console.log(`
|
||||||
|
${C.bold}Jackbox API Logger${C.reset}
|
||||||
|
Connects to a Jackbox game room and logs all WebSocket events.
|
||||||
|
|
||||||
|
${C.bold}Usage:${C.reset}
|
||||||
|
node tools/jackbox-logger.js <ROOM_CODE> [options]
|
||||||
|
|
||||||
|
${C.bold}Options:${C.reset}
|
||||||
|
--role <shard|audience|player> Connection role (default: shard)
|
||||||
|
--name <name> Display name (default: JBLogger)
|
||||||
|
--no-file Skip writing to log file
|
||||||
|
--verbose Print full JSON to console
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
${C.bold}Examples:${C.reset}
|
||||||
|
node tools/jackbox-logger.js ABCD
|
||||||
|
node tools/jackbox-logger.js ABCD --role audience
|
||||||
|
node tools/jackbox-logger.js ABCD --verbose --no-file
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
roomCode: null,
|
||||||
|
role: 'shard',
|
||||||
|
name: 'JBLogger',
|
||||||
|
noFile: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--role') {
|
||||||
|
opts.role = args[++i];
|
||||||
|
} else if (arg === '--name') {
|
||||||
|
opts.name = args[++i];
|
||||||
|
} else if (arg === '--no-file') {
|
||||||
|
opts.noFile = true;
|
||||||
|
} else if (arg === '--verbose') {
|
||||||
|
opts.verbose = true;
|
||||||
|
} else if (!arg.startsWith('-') && !opts.roomCode) {
|
||||||
|
opts.roomCode = arg.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.roomCode) {
|
||||||
|
console.error(`${C.red}Error: room code is required${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['shard', 'audience', 'player'].includes(opts.role)) {
|
||||||
|
console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// REST: fetch room info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getRoomInfo(roomCode) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.ok) resolve(json.body);
|
||||||
|
else reject(new Error(json.error || 'Room not found'));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timestamp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ts() {
|
||||||
|
return new Date().toISOString().slice(11, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function summarize(msg) {
|
||||||
|
const r = msg.result || {};
|
||||||
|
|
||||||
|
switch (msg.opcode) {
|
||||||
|
case 'client/welcome': {
|
||||||
|
const hereEntries = r.here ? Object.entries(r.here) : [];
|
||||||
|
const players = hereEntries
|
||||||
|
.filter(([, v]) => v.roles?.player)
|
||||||
|
.map(([id, v]) => `${v.roles.player.name}(${id})`);
|
||||||
|
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||||||
|
return (
|
||||||
|
`id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` +
|
||||||
|
` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` +
|
||||||
|
` entities: [${entityKeys.join(', ')}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'object': {
|
||||||
|
if (r.key === 'room' || r.key === 'bc:room') {
|
||||||
|
const v = r.val || {};
|
||||||
|
return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`;
|
||||||
|
}
|
||||||
|
if (r.key === 'textDescriptions') {
|
||||||
|
const latest = r.val?.latestDescriptions;
|
||||||
|
if (Array.isArray(latest) && latest.length > 0) {
|
||||||
|
const last = latest[latest.length - 1];
|
||||||
|
return `TEXT "${last.text}" (${last.category}) v${r.version}`;
|
||||||
|
}
|
||||||
|
return `textDescriptions v${r.version}`;
|
||||||
|
}
|
||||||
|
if (r.key?.startsWith('player:')) {
|
||||||
|
const v = r.val || {};
|
||||||
|
return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`;
|
||||||
|
}
|
||||||
|
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||||||
|
return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/connected': {
|
||||||
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||||
|
const playerName = r.roles?.player?.name || r.name || '';
|
||||||
|
return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/disconnected': {
|
||||||
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||||
|
return `id=${r.id} role=${roleName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/kicked':
|
||||||
|
return JSON.stringify(r).slice(0, 200);
|
||||||
|
|
||||||
|
case 'room/lock':
|
||||||
|
return 'room locked (game starting)';
|
||||||
|
|
||||||
|
case 'room/exit':
|
||||||
|
return 'room closed';
|
||||||
|
|
||||||
|
case 'room/get-audience':
|
||||||
|
return `audience connections=${r.connections}`;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return `code=${r.code}: ${r.msg}`;
|
||||||
|
|
||||||
|
case 'ok':
|
||||||
|
return `seq response`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return JSON.stringify(r).slice(0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function opcodeColor(opcode) {
|
||||||
|
if (opcode === 'client/welcome') return C.green;
|
||||||
|
if (opcode === 'error') return C.red;
|
||||||
|
if (opcode === 'client/connected') return C.cyan;
|
||||||
|
if (opcode === 'client/disconnected') return C.yellow;
|
||||||
|
if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta;
|
||||||
|
if (opcode === 'object') return C.white;
|
||||||
|
return C.dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class FileLogger {
|
||||||
|
constructor(roomCode) {
|
||||||
|
const logsDir = path.join(__dirname, '..', 'logs');
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`);
|
||||||
|
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
|
||||||
|
}
|
||||||
|
|
||||||
|
write(entry) {
|
||||||
|
this.stream.write(JSON.stringify(entry) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.stream.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs();
|
||||||
|
const { roomCode, role, name, noFile, verbose } = opts;
|
||||||
|
|
||||||
|
console.log(`${C.bold}Jackbox API Logger${C.reset}`);
|
||||||
|
console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Fetch room info
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`);
|
||||||
|
let roomInfo;
|
||||||
|
try {
|
||||||
|
roomInfo = await getRoomInfo(roomCode);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`);
|
||||||
|
console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`);
|
||||||
|
console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`);
|
||||||
|
console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`);
|
||||||
|
console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`);
|
||||||
|
console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// File logger
|
||||||
|
let fileLogger = null;
|
||||||
|
if (!noFile) {
|
||||||
|
fileLogger = new FileLogger(roomCode);
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_start',
|
||||||
|
roomCode,
|
||||||
|
role,
|
||||||
|
name,
|
||||||
|
host: roomInfo.host,
|
||||||
|
appTag: roomInfo.appTag,
|
||||||
|
maxPlayers: roomInfo.maxPlayers,
|
||||||
|
locked: roomInfo.locked,
|
||||||
|
full: roomInfo.full,
|
||||||
|
audienceEnabled: roomInfo.audienceEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// State for reconnection
|
||||||
|
let shardId = null;
|
||||||
|
let secret = null;
|
||||||
|
let msgCount = 0;
|
||||||
|
let reconnecting = false;
|
||||||
|
let manuallyStopped = false;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
function buildWsUrl(reconnect) {
|
||||||
|
if (role === 'audience') {
|
||||||
|
return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`;
|
||||||
|
}
|
||||||
|
const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`;
|
||||||
|
if (reconnect && secret && shardId) {
|
||||||
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`;
|
||||||
|
}
|
||||||
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(isReconnect) {
|
||||||
|
const url = buildWsUrl(isReconnect);
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`);
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, ['ecast-v0'], {
|
||||||
|
headers: {
|
||||||
|
Origin: 'https://jackbox.tv',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
handshakeTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`);
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
msgCount++;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
|
||||||
|
// Capture credentials from welcome for reconnection
|
||||||
|
if (msg.opcode === 'client/welcome' && msg.result) {
|
||||||
|
shardId = msg.result.id;
|
||||||
|
secret = msg.result.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console output
|
||||||
|
const color = opcodeColor(msg.opcode);
|
||||||
|
const summary = summarize(msg);
|
||||||
|
console.log(
|
||||||
|
`${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}`
|
||||||
|
);
|
||||||
|
if (verbose) {
|
||||||
|
console.log(JSON.stringify(msg, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// File output
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'recv',
|
||||||
|
msgNum: msgCount,
|
||||||
|
pc: msg.pc,
|
||||||
|
opcode: msg.opcode,
|
||||||
|
raw: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'recv',
|
||||||
|
msgNum: msgCount,
|
||||||
|
parseError: true,
|
||||||
|
raw: raw.toString().slice(0, 2000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'disconnected',
|
||||||
|
code,
|
||||||
|
reason: reason?.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!manuallyStopped && secret != null) {
|
||||||
|
reconnectWithBackoff(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'error',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SIGINT handler
|
||||||
|
const onSigint = () => {
|
||||||
|
manuallyStopped = true;
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log();
|
||||||
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||||
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||||
|
if (fileLogger) {
|
||||||
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_end',
|
||||||
|
msgCount,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
fileLogger.close();
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
try {
|
||||||
|
ws.close(1000, 'Logger stopped');
|
||||||
|
} catch (_) {}
|
||||||
|
setTimeout(() => process.exit(0), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.removeAllListeners('SIGINT');
|
||||||
|
process.on('SIGINT', onSigint);
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectWithBackoff() {
|
||||||
|
if (reconnecting || manuallyStopped) return;
|
||||||
|
reconnecting = true;
|
||||||
|
const delays = [2000, 4000, 8000];
|
||||||
|
|
||||||
|
for (let i = 0; i < delays.length; i++) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`);
|
||||||
|
await new Promise((r) => setTimeout(r, delays[i]));
|
||||||
|
|
||||||
|
if (manuallyStopped) {
|
||||||
|
reconnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const freshRoom = await getRoomInfo(roomCode);
|
||||||
|
if (!freshRoom) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`);
|
||||||
|
reconnecting = false;
|
||||||
|
shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomInfo.host = freshRoom.host;
|
||||||
|
connect(true);
|
||||||
|
reconnecting = false;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`);
|
||||||
|
reconnecting = false;
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log();
|
||||||
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||||
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||||
|
if (fileLogger) {
|
||||||
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_end',
|
||||||
|
msgCount,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
fileLogger.close();
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(`${C.red}Fatal: ${e.message}${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user