Compare commits

..

37 Commits

Author SHA1 Message Date
cottongin
c756d45e24 fix: guard shard event emissions on both manuallyStopped and gameFinished
Prevent stale events from shards that ended naturally (not via
stopMonitor). handleMessage now gates on gameFinished in addition to
manuallyStopped. handleEntityUpdate properly cleans up on gameFinished
by emitting room.disconnected, removing from activeShards, and calling
disconnect. handleError also removes from activeShards. Probe message
handler and status broadcast bail out when the shard is stopped or the
game has finished.

Made-with: Cursor
2026-03-21 00:07:10 -04:00
cottongin
171303a6f9 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
2026-03-20 23:34:22 -04:00
cottongin
4999060970 fix: periodic player count refresh via probe shard connection
Some Jackbox games (e.g. Trivia Murder Party 2) do not send
client/connected events to shard connections and lack textDescriptions,
leaving the player count stuck at 0 if the shard connects before
players join. Fix by opening a lightweight probe shard every 20s to
read the fresh here map. Also fix bc:room entity lookup in
handleWelcome and a WebSocket close handler race condition.

Made-with: Cursor
2026-03-20 21:29:58 -04:00
cottongin
34637d6d2c feat: add periodic game.status broadcast and live status REST endpoint
Add 20-second game.status WebSocket heartbeat from active shard monitors
containing full game state, and GET /status-live REST endpoint for on-demand
polling. Fix missing token destructuring in SessionInfo causing crash.
Relax frontend polling from 3s to 60s since WebSocket events now cover
real-time updates. Bump version to 0.6.0.

Made-with: Cursor
2026-03-20 21:05:19 -04:00
cottongin
a7bd0650eb docs: update ecast API reference with connection roles and shard details
Add Connection Roles table documenting host, player, shard, audience,
observer, and moderator roles with their capabilities and slot impact.
Add shard client/welcome capture and passive room monitoring section.

Made-with: Cursor
2026-03-20 11:47:47 -04:00
cottongin
65036a4e1b fix: Picker WebSocket auth, event handling, and status display
- Authenticate with JWT before subscribing to session events
- Use message.type instead of message.event (matches server format)
- Handle all new shard monitor events (room.connected, lobby.*,
  game.started, game.ended, room.disconnected)
- Replace never-set 'waiting' status with 'monitoring'
- Show monitoring indicator with live player count

Made-with: Cursor
2026-03-20 11:47:19 -04:00
cottongin
336ba0e608 docs: update websocket event reference with new shard monitor events
Add room.connected, lobby.player-joined, lobby.updated, game.started,
game.ended, room.disconnected events. Clarify player-count.updated as
manual-override only. Update complete example with new event handlers.

Made-with: Cursor
2026-03-20 11:35:29 -04:00
cottongin
03f79422af fix: clean up activeShards on reconnection failure
Remove stale entries from the activeShards map when
reconnectWithBackoff exhausts all attempts or detects room closure.

Made-with: Cursor
2026-03-20 11:34:15 -04:00
cottongin
2503c3fc09 chore: remove Puppeteer and old room-monitor/player-count-checker modules
Made-with: Cursor
2026-03-20 11:29:01 -04:00
cottongin
9c9927218a feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT
Made-with: Cursor
2026-03-20 11:27:58 -04:00
cottongin
3c1d5b2224 refactor: rewire sessions routes to use ecast shard client
Made-with: Cursor
2026-03-20 11:27:54 -04:00
cottongin
1c4c8bc19c feat: add startMonitor, stopMonitor, cleanupAllShards module exports 2026-03-20 11:25:01 -04:00
cottongin
de395d3a28 feat: add reconnection logic with exponential backoff to shard client 2026-03-20 11:24:48 -04:00
cottongin
3f21299720 feat: add event broadcasting and entity update handlers to shard client
Made-with: Cursor
2026-03-20 11:19:57 -04:00
cottongin
516db57248 feat: add EcastShardClient with connection, welcome parsing, and player counting
Made-with: Cursor
2026-03-20 11:09:05 -04:00
cottongin
0fc2ddbf23 feat: add getRoomInfo to jackbox-api for full room data including host
Made-with: Cursor
2026-03-20 11:04:54 -04:00
cottongin
7712ebeb04 Add implementation plan for ecast shard monitor
10-task TDD plan covering: jackbox-api extension, EcastShardClient
class, event broadcasting, reconnection, route rewiring, graceful
shutdown, old module removal, doc updates, and manual smoke test.

Made-with: Cursor
2026-03-20 10:53:19 -04:00
cottongin
002e1d70a6 Add design doc for ecast shard monitor replacing Puppeteer audience approach
Replaces room-monitor.js (REST polling) and player-count-checker.js
(Puppeteer/CDP audience join) with a single EcastShardClient that
connects as a shard via direct WebSocket. Defines new event contract,
integration points, error handling, and reconnection strategy.

Made-with: Cursor
2026-03-20 10:42:33 -04:00
cottongin
e6198181f8 docs: reframe player counting as slot-based (not a limitation)
Occupied slots = effective player count since held reconnection slots
are unavailable to new players. connections - 1 = player count,
maxPlayers - (connections - 1) = available slots. Clean and simple.

Made-with: Cursor
2026-03-20 09:56:58 -04:00
cottongin
7b0dc5c015 docs: major corrections to ecast API docs from second round of testing
Key corrections based on testing with fresh room SCWX:
- connections count includes ALL ever-joined players, not just active ones
  (slots persist for reconnection, count never decreases)
- here field also includes disconnected players (slot reservation model)
- client/connected and client/disconnected confirmed as NOT delivered to
  player connections after extensive testing
- Jackbox has no concept of "leaving" — player disconnect is invisible
  to the API
- Added reconnection URL format (secret + id query params)
- Added error code 2027 (REST/WebSocket state divergence)
- Added ws-lifecycle-test.js for systematic protocol testing

Made-with: Cursor
2026-03-20 09:51:24 -04:00
cottongin
af5e8cbd94 docs: comprehensive Jackbox ecast API reverse engineering
Adds complete documentation of the ecast platform covering:
- REST API (8 endpoints including newly discovered /connections, /info, /status)
- WebSocket protocol (connection, message format, 40+ opcodes)
- Entity model (room, player, audience, textDescriptions)
- Game lifecycle (lobby → start → gameplay → end)
- Player/room management answers (counting, join/leave detection, etc.)

Also adds scripts/ws-probe.js utility for direct WebSocket probing.

Made-with: Cursor
2026-03-20 09:39:17 -04:00
cottongin
e5ba43bcbb Add design doc for Jackbox ecast API reverse engineering
Defines scope, methodology (REST probing + WebSocket interception),
and documentation structure for comprehensive API documentation effort.

Made-with: Cursor
2026-03-20 09:18:55 -04:00
cottongin
35617268e9 fix: upgrade Docker to Node 22, add vote persistence diagnostics and e2e tests
- Bump Dockerfile base image from node:18-alpine to node:22-alpine to
  fix build failure (better-sqlite3@12.8.0 requires Node 20+)
- Add post-transaction verification logging in POST /api/votes/live to
  detect live_votes insertion failures in production
- Add direct DB assertion to regression test for live_votes population
- Add end-to-end integration tests covering the full vote flow: POST
  vote -> GET /api/sessions/:id/votes -> GET /api/votes -> direct DB

Made-with: Cursor
2026-03-16 20:53:32 -04:00
cottongin
0d0d20161b docs: update remaining docs with vote tracking API changes
Update README.md, docs/api/README.md, session-lifecycle guide,
webhooks-and-events guide, and archive docs (BOT_INTEGRATION,
API_QUICK_REFERENCE) with new endpoints and vote.received event.

Made-with: Cursor
2026-03-15 19:21:35 -04:00
cottongin
3ed3af06ba docs: add vote tracking endpoints to API documentation
Update REST endpoint docs (votes.md, sessions.md), WebSocket protocol
(websocket.md), OpenAPI spec, and voting guide with the new
GET /api/votes, GET /api/sessions/:id/votes, and vote.received event.

Made-with: Cursor
2026-03-15 19:16:23 -04:00
cottongin
e9add95efa feat: add vote.received WebSocket event on live votes
Made-with: Cursor
2026-03-15 19:08:00 -04:00
cottongin
83b274de79 feat: add GET /api/votes endpoint with filtering and pagination
Made-with: Cursor
2026-03-15 19:00:00 -04:00
cottongin
264953453c test: regression tests for WebSocket events
Made-with: Cursor
2026-03-15 18:55:55 -04:00
cottongin
56adbe7aa2 test: regression tests for GET /api/sessions endpoints
Made-with: Cursor
2026-03-15 18:53:32 -04:00
cottongin
8ddbd1440f test: regression tests for POST /api/votes/live
Made-with: Cursor
2026-03-15 18:53:25 -04:00
cottongin
19c4b7dc37 test: regression tests for GET /api/games vote fields
Made-with: Cursor
2026-03-15 18:53:07 -04:00
cottongin
8e8e6bdf05 fix: upgrade better-sqlite3 for Node 24 compat, add --forceExit to jest
Made-with: Cursor
2026-03-15 18:51:50 -04:00
cottongin
84b0c83409 test: add jest/supertest infrastructure and make server.js testable
Made-with: Cursor
2026-03-15 18:40:44 -04:00
cottongin
81fcae545e docs: vote tracking API implementation plan
TDD plan with 9 tasks: test infrastructure, regression tests
(4 files), and 3 feature implementations with full test code.

Made-with: Cursor
2026-03-15 18:24:50 -04:00
cottongin
4bf41b64cf docs: vote tracking API design (REST + WebSocket)
Covers WebSocket vote.received event, GET /api/sessions/:id/votes
breakdown, GET /api/votes paginated history, and two-phase TDD
testing strategy with regression tests before implementation.

Made-with: Cursor
2026-03-15 18:18:26 -04:00
cottongin
8ba32e128c docs: comprehensive API documentation from source code
Replace existing docs with fresh documentation built entirely from source
code analysis. OpenAPI 3.1 spec as source of truth, plus human-readable
Markdown with curl examples, response samples, and workflow guides.

- OpenAPI 3.1 spec covering all 42 endpoints (validated against source)
- 7 endpoint reference docs (auth, games, sessions, picker, stats, votes, webhooks)
- WebSocket protocol documentation (auth, subscriptions, 4 event types)
- 4 guide documents (getting started, session lifecycle, voting, webhooks)
- API README with overview, auth docs, and quick reference table
- Old docs archived to docs/archive/

Made-with: Cursor
2026-03-15 16:44:53 -04:00
cottongin
505c335d20 Decouple room monitoring from player count, fix Jackbox API fetch
Extracts checkRoomStatus into shared jackbox-api.js with proper
User-Agent header (bare fetch was silently rejected by Jackbox API)
and always-on error logging (previously gated behind DEBUG flag).

Splits room-start detection (room-monitor.js) from audience-based
player counting (player-count-checker.js) to eliminate circular
dependency and allow immediate game.started detection. Room monitor
now polls immediately instead of waiting 10 seconds for first check.

Made-with: Cursor
2026-03-08 18:25:52 -04:00
58 changed files with 19837 additions and 648 deletions

View File

@@ -36,6 +36,8 @@ A full-stack web application that helps groups pick games to play from various J
- **Live Voting API**: Real-time vote processing from external bots
- Accept live votes via REST API
- Real-time `vote.received` WebSocket event for stream overlays
- Per-session vote breakdown and paginated global vote history
- Automatic deduplication (1-second window)
- Timestamp-based game matching
- JWT authentication for security
@@ -249,6 +251,7 @@ The manifest is automatically generated during the build process, so you don't n
- `POST /api/sessions` - Create new session (admin)
- `POST /api/sessions/:id/close` - Close session (admin)
- `GET /api/sessions/:id/games` - Get games in session
- `GET /api/sessions/:id/votes` - Get per-game vote breakdown for a session
- `POST /api/sessions/:id/games` - Add game to session (admin)
- `POST /api/sessions/:id/chat-import` - Import chat log (admin)
@@ -258,7 +261,8 @@ The manifest is automatically generated during the build process, so you don't n
### Statistics
- `GET /api/stats` - Get overall statistics
### Live Votes
### Votes
- `GET /api/votes` - Paginated vote history with filtering
- `POST /api/votes/live` - Submit real-time vote (admin)
### Webhooks

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:22-alpine
WORKDIR /app

5629
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,24 +5,26 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "jest --config ../jest.config.js --runInBand --verbose --forceExit",
"test:watch": "jest --config ../jest.config.js --runInBand --watch --forceExit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"jsonwebtoken": "^9.0.2",
"dotenv": "^16.3.1",
"csv-parse": "^5.5.3",
"csv-stringify": "^6.4.5",
"ws": "^8.14.0",
"puppeteer": "^24.0.0"
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"ws": "^8.14.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^6.3.4"
}
}

View File

@@ -4,7 +4,7 @@ const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager');
const { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
const router = express.Router();
@@ -253,6 +253,40 @@ router.get('/:id/games', (req, res) => {
}
});
// Get vote breakdown for a session
router.get('/:id/votes', (req, res) => {
try {
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const votes = db.prepare(`
SELECT
lv.game_id,
g.title,
g.pack_name,
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
SUM(lv.vote_type) AS net_score,
COUNT(*) AS total_votes
FROM live_votes lv
JOIN games g ON lv.game_id = g.id
WHERE lv.session_id = ?
GROUP BY lv.game_id
ORDER BY net_score DESC
`).all(req.params.id);
res.json({
session_id: parseInt(req.params.id),
votes,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => {
try {
@@ -280,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
@@ -356,13 +398,12 @@ router.post('/:id/games', authenticateToken, (req, res) => {
console.error('Error triggering notifications:', error);
}
// Automatically start player count check if room code was provided
// Automatically start room monitoring if room code was provided
if (room_code) {
try {
startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players);
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
} catch (error) {
console.error('Error starting player count check:', error);
// Don't fail the request if player count check fails
console.error('Error starting room monitor:', error);
}
}
@@ -557,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
@@ -580,12 +630,12 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
return res.status(404).json({ error: 'Session game not found' });
}
// Stop player count check if game is no longer playing
// Stop room monitor and player count check if game is no longer playing
if (status !== 'playing') {
try {
stopPlayerCountCheck(sessionId, gameId);
stopMonitor(sessionId, gameId);
} catch (error) {
console.error('Error stopping player count check:', error);
console.error('Error stopping room monitor/player count check:', error);
}
}
@@ -600,11 +650,11 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
try {
const { sessionId, gameId } = req.params;
// Stop player count check before deleting
// Stop room monitor and player count check before deleting
try {
stopPlayerCountCheck(sessionId, gameId);
stopMonitor(sessionId, gameId);
} catch (error) {
console.error('Error stopping player count check:', error);
console.error('Error stopping room monitor/player count check:', error);
}
const result = db.prepare(`
@@ -805,6 +855,55 @@ router.get('/:id/export', authenticateToken, (req, res) => {
}
});
// Get live game status from shard monitor or DB fallback
router.get('/:sessionId/games/:gameId/status-live', (req, res) => {
try {
const { sessionId, gameId } = req.params;
const snapshot = getMonitorSnapshot(sessionId, gameId);
if (snapshot) {
return res.json(snapshot);
}
const game = db.prepare(`
SELECT
sg.room_code,
sg.player_count,
sg.player_count_check_status,
g.title,
g.pack_name,
g.max_players
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ? AND sg.id = ?
`).get(sessionId, gameId);
if (!game) {
return res.status(404).json({ error: 'Session game not found' });
}
res.json({
sessionId: parseInt(sessionId, 10),
gameId: parseInt(gameId, 10),
roomCode: game.room_code,
appTag: null,
maxPlayers: game.max_players,
playerCount: game.player_count,
players: [],
lobbyState: null,
gameState: null,
gameStarted: false,
gameFinished: game.player_count_check_status === 'completed',
monitoring: false,
title: game.title,
packName: game.pack_name,
status: game.player_count_check_status,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Start player count check for a session game (admin only)
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
try {
@@ -826,12 +925,12 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (
return res.status(400).json({ error: 'Game does not have a room code' });
}
// Start the check
startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players);
// Start room monitoring (will hand off to player count check when game starts)
startMonitor(sessionId, gameId, game.room_code, game.max_players);
res.json({
message: 'Player count check started',
status: 'waiting'
message: 'Room monitor started',
status: 'monitoring'
});
} catch (error) {
res.status(500).json({ error: error.message });
@@ -843,11 +942,11 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r
try {
const { sessionId, gameId } = req.params;
// Stop the check
stopPlayerCountCheck(sessionId, gameId);
// Stop both room monitor and player count check
stopMonitor(sessionId, gameId);
res.json({
message: 'Player count check stopped',
message: 'Room monitor and player count check stopped',
status: 'stopped'
});
} catch (error) {

View File

@@ -1,9 +1,91 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { getWebSocketManager } = require('../utils/websocket-manager');
const router = express.Router();
// Get vote history with filtering and pagination
router.get('/', (req, res) => {
try {
let { session_id, game_id, username, vote_type, page, limit } = req.query;
page = parseInt(page) || 1;
limit = Math.min(parseInt(limit) || 50, 100);
if (page < 1) page = 1;
if (limit < 1) limit = 50;
const offset = (page - 1) * limit;
const where = [];
const params = [];
if (session_id !== undefined) {
const sid = parseInt(session_id);
if (isNaN(sid)) {
return res.status(400).json({ error: 'session_id must be an integer' });
}
where.push('lv.session_id = ?');
params.push(sid);
}
if (game_id !== undefined) {
const gid = parseInt(game_id);
if (isNaN(gid)) {
return res.status(400).json({ error: 'game_id must be an integer' });
}
where.push('lv.game_id = ?');
params.push(gid);
}
if (username) {
where.push('lv.username = ?');
params.push(username);
}
if (vote_type !== undefined) {
if (vote_type !== 'up' && vote_type !== 'down') {
return res.status(400).json({ error: 'vote_type must be "up" or "down"' });
}
where.push('lv.vote_type = ?');
params.push(vote_type === 'up' ? 1 : -1);
}
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
const countResult = db.prepare(
`SELECT COUNT(*) as total FROM live_votes lv ${whereClause}`
).get(...params);
const total = countResult.total;
const total_pages = Math.ceil(total / limit) || 0;
const votes = db.prepare(`
SELECT
lv.id,
lv.session_id,
lv.game_id,
g.title AS game_title,
g.pack_name,
lv.username,
CASE WHEN lv.vote_type = 1 THEN 'up' ELSE 'down' END AS vote_type,
lv.timestamp,
lv.created_at
FROM live_votes lv
JOIN games g ON lv.game_id = g.id
${whereClause}
ORDER BY lv.timestamp DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({
votes,
pagination: { page, limit, total, total_pages },
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Live vote endpoint - receives real-time votes from bot
router.post('/live', authenticateToken, (req, res) => {
try {
@@ -43,7 +125,7 @@ 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.upvotes, g.downvotes, g.popularity_score
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 = ?
@@ -149,6 +231,16 @@ router.post('/live', authenticateToken, (req, res) => {
processVote();
// Verify the live_votes row was persisted (diagnostic for production debugging)
const voteCheck = db.prepare(
'SELECT id FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ? AND timestamp = ?'
).get(activeSession.id, matchedGame.game_id, username, timestamp);
if (!voteCheck) {
console.error('[votes] CRITICAL: live_votes INSERT committed but row not found', {
session_id: activeSession.id, game_id: matchedGame.game_id, username, timestamp,
});
}
// Get updated game stats
const updatedGame = db.prepare(`
SELECT id, title, upvotes, downvotes, popularity_score
@@ -156,6 +248,33 @@ router.post('/live', authenticateToken, (req, res) => {
WHERE id = ?
`).get(matchedGame.game_id);
// Broadcast vote.received via WebSocket
try {
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('vote.received', {
sessionId: activeSession.id,
game: {
id: updatedGame.id,
title: updatedGame.title,
pack_name: matchedGame.pack_name,
},
vote: {
username: username,
type: vote,
timestamp: timestamp,
},
totals: {
upvotes: updatedGame.upvotes,
downvotes: updatedGame.downvotes,
popularity_score: updatedGame.popularity_score,
},
}, activeSession.id);
}
} catch (wsError) {
console.error('Error broadcasting vote.received event:', wsError);
}
// Get session stats
const sessionStats = db.prepare(`
SELECT

View File

@@ -4,6 +4,7 @@ const http = require('http');
const cors = require('cors');
const { bootstrapGames } = require('./bootstrap');
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
const { cleanupAllShards } = require('./utils/ecast-shard-client');
const app = express();
const PORT = process.env.PORT || 5000;
@@ -12,9 +13,6 @@ const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
// Bootstrap database with games
bootstrapGames();
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
@@ -50,8 +48,21 @@ const server = http.createServer(app);
const wsManager = new WebSocketManager(server);
setWebSocketManager(wsManager);
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
});
if (require.main === module) {
bootstrapGames();
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
});
const shutdown = async () => {
console.log('Shutting down gracefully...');
await cleanupAllShards();
server.close(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
module.exports = { app, server };

View File

@@ -0,0 +1,710 @@
const WebSocket = require('ws');
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
const { getRoomInfo } = require('./jackbox-api');
class EcastShardClient {
static parsePlayersFromHere(here) {
if (here == null || typeof here !== 'object') {
return { playerCount: 0, playerNames: [] };
}
const names = [];
const keys = Object.keys(here).sort((a, b) => Number(a) - Number(b));
for (const key of keys) {
const conn = here[key];
if (conn?.roles?.player) {
names.push(conn.roles.player.name ?? '');
}
}
return { playerCount: names.length, playerNames: names };
}
static parseRoomEntity(roomVal) {
if (roomVal == null || typeof roomVal !== 'object') {
return {
gameState: null,
lobbyState: null,
gameCanStart: false,
gameIsStarting: false,
gameStarted: false,
gameFinished: false,
};
}
return {
gameState: roomVal.state ?? null,
lobbyState: roomVal.lobbyState ?? null,
gameCanStart: !!roomVal.gameCanStart,
gameIsStarting: !!roomVal.gameIsStarting,
gameStarted: roomVal.state === 'Gameplay',
gameFinished: !!roomVal.gameFinished,
};
}
static parsePlayerJoinFromTextDescriptions(val) {
if (val == null || typeof val !== 'object') {
return [];
}
const latest = val.latestDescriptions;
if (!Array.isArray(latest)) {
return [];
}
const out = [];
for (const desc of latest) {
if (!desc || typeof desc !== 'object') continue;
const { category, text } = desc;
if (category !== 'TEXT_DESCRIPTION_PLAYER_JOINED' && category !== 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP') {
continue;
}
if (typeof text !== 'string') continue;
const joinedIdx = text.indexOf(' joined');
if (joinedIdx === -1) continue;
const before = text.slice(0, joinedIdx).trim();
const name = before.split(/\s+/)[0] || before;
out.push({
name,
isVIP: category === 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP',
});
}
return out;
}
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent }) {
this.sessionId = sessionId;
this.gameId = gameId;
this.roomCode = roomCode;
this.maxPlayers = maxPlayers;
this.onEvent = onEvent || (() => {});
this.ws = null;
this.shardId = null;
this.secret = null;
this.host = null;
this.playerCount = 0;
this.playerNames = [];
this.lobbyState = null;
this.gameState = null;
this.gameStarted = false;
this.gameFinished = false;
this.manuallyStopped = false;
this.seq = 0;
this.appTag = null;
this.reconnecting = false;
this.statusInterval = null;
}
getSnapshot() {
return {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
appTag: this.appTag,
maxPlayers: this.maxPlayers,
playerCount: this.playerCount,
players: [...this.playerNames],
lobbyState: this.lobbyState,
gameState: this.gameState,
gameStarted: this.gameStarted,
gameFinished: this.gameFinished,
monitoring: true,
};
}
startStatusBroadcast() {
this.stopStatusBroadcast();
this.statusInterval = setInterval(() => {
this._refreshPlayerCount().finally(() => {
if (!this.manuallyStopped && !this.gameFinished) {
this.onEvent('game.status', this.getSnapshot());
}
});
}, 20000);
}
_refreshPlayerCount() {
if (!this.host || this.gameFinished || this.manuallyStopped) {
return Promise.resolve();
}
return new Promise((resolve) => {
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`;
let resolved = false;
let welcomed = false;
const done = (probe) => {
if (!resolved) {
resolved = true;
if (probe) {
try { probe.removeAllListeners(); probe.terminate(); } catch (_) {}
}
resolve();
}
};
try {
const probe = new WebSocket(url, ['ecast-v0'], {
headers: { Origin: 'https://jackbox.tv' },
handshakeTimeout: 8000,
});
const timeout = setTimeout(() => done(probe), 10000);
probe.on('message', (data) => {
if (welcomed || this.manuallyStopped) { clearTimeout(timeout); done(probe); return; }
try {
const msg = JSON.parse(data.toString());
if (msg.opcode === 'client/welcome') {
welcomed = true;
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here);
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
this.playerCount = playerCount;
this.playerNames = playerNames;
if (!this.manuallyStopped) {
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: playerNames[playerNames.length - 1] || '',
playerCount,
players: [...playerNames],
maxPlayers: this.maxPlayers,
});
}
} else if (playerCount !== this.playerCount) {
this.playerCount = playerCount;
this.playerNames = playerNames;
}
} else if (msg.opcode === 'error' && msg.result?.code === 2027) {
this.gameFinished = true;
}
} catch (_) {}
clearTimeout(timeout);
done(probe);
});
probe.on('error', () => { clearTimeout(timeout); done(probe); });
probe.on('close', () => { clearTimeout(timeout); done(null); });
} catch (_) {
done(null);
}
});
}
stopStatusBroadcast() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
}
buildReconnectUrl() {
return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`;
}
handleMessage(message) {
if (this.manuallyStopped || this.gameFinished) return;
switch (message.opcode) {
case 'client/welcome':
this.handleWelcome(message.result);
break;
case 'object':
this.handleEntityUpdate(message.result);
break;
case 'client/connected':
this.handleClientConnected(message.result);
break;
case 'client/disconnected':
break;
case 'error':
this.handleError(message.result);
break;
default:
break;
}
}
handleWelcome(result) {
this.shardId = result.id;
this.secret = result.secret;
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(result.here);
this.playerCount = playerCount;
this.playerNames = playerNames;
const roomEntity = result.entities?.room || result.entities?.['bc:room'];
if (roomEntity) {
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
if (roomVal) {
const roomState = EcastShardClient.parseRoomEntity(roomVal);
this.lobbyState = roomState.lobbyState;
this.gameState = roomState.gameState;
this.gameStarted = roomState.gameStarted;
this.gameFinished = roomState.gameFinished;
}
}
console.log(
`[Shard Monitor] Welcome: id=${this.shardId}, players=${this.playerCount} [${this.playerNames.join(', ')}], state=${this.gameState}, lobby=${this.lobbyState}`
);
this.onEvent('room.connected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
appTag: this.appTag,
maxPlayers: this.maxPlayers,
playerCount: this.playerCount,
players: [...this.playerNames],
lobbyState: this.lobbyState,
gameState: this.gameState,
});
this.startStatusBroadcast();
}
handleEntityUpdate(result) {
if (!result?.key) return;
if (result.key === 'room' || result.key === 'bc:room') {
if (result.val) {
const prevLobbyState = this.lobbyState;
const prevGameStarted = this.gameStarted;
const prevGameFinished = this.gameFinished;
const roomState = EcastShardClient.parseRoomEntity(result.val);
this.lobbyState = roomState.lobbyState;
this.gameState = roomState.gameState;
this.gameStarted = roomState.gameStarted;
this.gameFinished = roomState.gameFinished;
if (this.lobbyState !== prevLobbyState && !this.gameStarted) {
this.onEvent('lobby.updated', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
lobbyState: this.lobbyState,
gameCanStart: roomState.gameCanStart,
gameIsStarting: roomState.gameIsStarting,
playerCount: this.playerCount,
});
}
if (this.gameStarted && !prevGameStarted) {
this.onEvent('game.started', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
if (this.gameFinished && !prevGameFinished) {
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();
}
}
}
if (result.key === 'textDescriptions') {
if (result.val) {
const joins = EcastShardClient.parsePlayerJoinFromTextDescriptions(result.val);
for (const join of joins) {
if (!this.playerNames.includes(join.name)) {
this.playerNames.push(join.name);
this.playerCount = this.playerNames.length;
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: join.name,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
}
}
}
}
handleClientConnected(result) {
if (!result) return;
if (result.roles?.player) {
const name = result.roles.player.name ?? '';
if (!this.playerNames.includes(name)) {
this.playerNames.push(name);
this.playerCount = this.playerNames.length;
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: name,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
}
}
handleError(result) {
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
if (result?.code === 2027) {
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();
}
}
_openWebSocket(url) {
return new Promise((resolve, reject) => {
let welcomeTimeoutId = null;
const cleanupWelcomeTimeout = () => {
if (welcomeTimeoutId != null) {
clearTimeout(welcomeTimeoutId);
welcomeTimeoutId = null;
}
};
this.ws = new WebSocket(url, ['ecast-v0'], {
headers: { Origin: 'https://jackbox.tv' },
handshakeTimeout: 10000,
});
this.ws.on('open', () => {
console.log(`[Shard Monitor] Connected to room ${this.roomCode}`);
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
if (message.opcode === 'client/welcome') {
cleanupWelcomeTimeout();
resolve();
}
} catch (e) {
console.error('[Shard Monitor] Failed to parse message:', e.message);
}
});
this.ws.on('error', (err) => {
cleanupWelcomeTimeout();
console.error(`[Shard Monitor] WebSocket error for room ${this.roomCode}:`, err.message);
reject(err);
});
const thisWs = this.ws;
this.ws.on('close', (code, reason) => {
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
if (this.ws === thisWs) {
this.ws = null;
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
void this.reconnectWithBackoff();
}
}
});
welcomeTimeoutId = setTimeout(() => {
welcomeTimeoutId = null;
if (!this.shardId) {
reject(new Error('Timeout waiting for client/welcome'));
this.disconnect();
}
}, 15000);
});
}
async connect(roomInfo, reconnectUrl) {
this.disconnect();
this.shardId = null;
this.secret = null;
this.host = roomInfo.host;
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
this.appTag = roomInfo.appTag;
const url =
reconnectUrl ||
`wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
return this._openWebSocket(url);
}
async reconnect() {
const url = this.buildReconnectUrl();
this.disconnect();
this.shardId = null;
return this._openWebSocket(url);
}
async reconnectWithBackoff() {
if (this.reconnecting || this.manuallyStopped || this.gameFinished) {
return false;
}
this.reconnecting = true;
const delays = [2000, 4000, 8000];
try {
for (let i = 0; i < delays.length; i++) {
await new Promise((r) => setTimeout(r, delays[i]));
const roomInfo = await getRoomInfo(this.roomCode);
if (!roomInfo.exists) {
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}`);
return false;
}
try {
await this.reconnect();
console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`);
return true;
} catch (e) {
console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message);
}
}
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'connection_failed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
return false;
} finally {
this.reconnecting = false;
}
}
disconnect() {
this.stopStatusBroadcast();
if (this.ws) {
try {
this.ws.close(1000, 'Monitor stopped');
} catch (e) {
// Ignore close errors
}
this.ws = null;
}
}
sendMessage(opcode, params = {}) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.seq++;
this.ws.send(JSON.stringify({ seq: this.seq, opcode, params }));
}
}
}
const activeShards = new Map();
function broadcastAndPersist(sessionId, gameId) {
return (eventType, eventData) => {
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent(eventType, eventData, parseInt(sessionId, 10));
}
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
const checkStatus = eventType === 'game.ended' ? 'completed' : 'monitoring';
try {
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);
}
}
if (eventType === 'room.disconnected') {
const reason = eventData.reason;
const checkStatus =
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
try {
const game = db
.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(
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
);
}
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
}
};
}
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
const monitorKey = `${sessionId}-${gameId}`;
if (activeShards.has(monitorKey)) {
console.log(`[Shard Monitor] Already monitoring ${monitorKey}`);
return;
}
console.log(`[Shard Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
const roomInfo = await getRoomInfo(roomCode);
if (!roomInfo.exists) {
console.log(`[Shard Monitor] Room ${roomCode} not found`);
const onEvent = broadcastAndPersist(sessionId, gameId);
onEvent('room.disconnected', {
sessionId,
gameId,
roomCode,
reason: 'room_not_found',
finalPlayerCount: null,
});
return;
}
const onEvent = broadcastAndPersist(sessionId, gameId);
try {
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
'monitoring',
sessionId,
gameId
);
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
const client = new EcastShardClient({
sessionId,
gameId,
roomCode,
maxPlayers: roomInfo.maxPlayers || maxPlayers,
onEvent,
});
activeShards.set(monitorKey, client);
try {
await client.connect(roomInfo);
} catch (e) {
console.error(`[Shard Monitor] Failed to connect to room ${roomCode}:`, e.message);
activeShards.delete(monitorKey);
onEvent('room.disconnected', {
sessionId,
gameId,
roomCode,
reason: 'connection_failed',
finalPlayerCount: null,
});
}
}
async function stopMonitor(sessionId, gameId) {
const monitorKey = `${sessionId}-${gameId}`;
const client = activeShards.get(monitorKey);
if (client) {
client.manuallyStopped = true;
client.disconnect();
activeShards.delete(monitorKey);
const game = db
.prepare('SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?')
.get(sessionId, gameId);
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
'stopped',
sessionId,
gameId
);
}
client.onEvent('room.disconnected', {
sessionId,
gameId,
roomCode: client.roomCode,
reason: 'manually_stopped',
finalPlayerCount: client.playerCount,
});
console.log(`[Shard Monitor] Stopped monitor for ${monitorKey}`);
}
}
async function cleanupAllShards() {
for (const [, client] of activeShards) {
client.manuallyStopped = true;
client.disconnect();
}
activeShards.clear();
console.log('[Shard Monitor] Cleaned up all active shards');
}
function getMonitorSnapshot(sessionId, gameId) {
const client = activeShards.get(`${sessionId}-${gameId}`);
return client ? client.getSnapshot() : null;
}
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot };

View File

@@ -0,0 +1,75 @@
const JACKBOX_API_BASE = 'https://ecast.jackboxgames.com/api/v2';
const DEFAULT_HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; GamePicker/1.0)'
};
/**
* Check room status via the Jackbox ecast REST API.
* Shared by room-monitor (polling for lock) and player-count-checker (room existence).
*/
async function checkRoomStatus(roomCode) {
try {
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
headers: DEFAULT_HEADERS
});
if (!response.ok) {
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
return { exists: false };
}
const data = await response.json();
const roomData = data.body || data;
if (process.env.DEBUG === 'true') {
console.log('[Jackbox API] Room data:', JSON.stringify(roomData, null, 2));
}
return {
exists: true,
locked: roomData.locked || false,
full: roomData.full || false,
maxPlayers: roomData.maxPlayers || 8,
minPlayers: roomData.minPlayers || 0
};
} catch (e) {
console.error(`[Jackbox API] Error checking room ${roomCode}:`, e.message);
return { exists: false };
}
}
async function getRoomInfo(roomCode) {
try {
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
headers: DEFAULT_HEADERS
});
if (!response.ok) {
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
return { exists: false };
}
const data = await response.json();
const body = data.body || data;
return {
exists: true,
host: body.host,
audienceHost: body.audienceHost,
appTag: body.appTag,
appId: body.appId,
code: body.code,
locked: body.locked || false,
full: body.full || false,
maxPlayers: body.maxPlayers || 8,
minPlayers: body.minPlayers || 0,
audienceEnabled: body.audienceEnabled || false,
};
} catch (e) {
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
return { exists: false };
}
}
module.exports = { checkRoomStatus, getRoomInfo };

View File

@@ -1,577 +0,0 @@
const puppeteer = require('puppeteer');
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
// Store active check jobs
const activeChecks = new Map();
/**
* Check room status via Jackbox API
*/
async function checkRoomStatus(roomCode) {
try {
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
if (response.ok) {
const data = await response.json();
const roomData = data.body || data;
if (process.env.DEBUG) {
console.log('[API] Room data:', JSON.stringify(roomData, null, 2));
}
return {
exists: true,
locked: roomData.locked || false,
full: roomData.full || false,
maxPlayers: roomData.maxPlayers || 8,
minPlayers: roomData.minPlayers || 0
};
}
return { exists: false };
} catch (e) {
if (process.env.DEBUG) {
console.error('[API] Error checking room:', e.message);
}
return { exists: false };
}
}
/**
* Watch a game from start to finish as audience member
* Collects analytics throughout the entire game lifecycle
*/
async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
let browser;
const checkKey = `${sessionId}-${gameId}`;
try {
console.log(`[Player Count] Opening audience connection for ${checkKey} (max: ${maxPlayers})`);
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu'
]
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
// Track all player counts we've seen
const seenPlayerCounts = new Set();
let bestPlayerCount = null;
let startPlayerCount = null; // Authoritative count from 'start' action
let gameEnded = false;
let audienceJoined = false; // Track whether we've confirmed audience join
let frameCount = 0;
// Enable CDP and listen for WebSocket frames BEFORE navigating
const client = await page.target().createCDPSession();
await client.send('Network.enable');
client.on('Network.webSocketFrameReceived', ({ response }) => {
if (response.payloadData && !gameEnded) {
frameCount++;
try {
const data = JSON.parse(response.payloadData);
if (process.env.DEBUG && frameCount % 10 === 0) {
console.log(`[Frame ${frameCount}] opcode: ${data.opcode}`);
}
// Check for bc:room with player count data
let roomVal = null;
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
roomVal = data.result.entities['bc:room'][1]?.val;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] Found bc:room in client/welcome`);
}
// First client/welcome means Jackbox accepted our audience join
if (!audienceJoined) {
audienceJoined = true;
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
// Broadcast audience.joined event via WebSocket
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('audience.joined', {
sessionId,
gameId,
roomCode
}, parseInt(sessionId));
}
}
}
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
roomVal = data.result.val;
}
if (roomVal) {
// Check if game has ended
if (roomVal.gameResults?.players) {
const finalCount = roomVal.gameResults.players.length;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
// Verify it matches start count if we had one
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
} else if (startPlayerCount !== null) {
console.log(`[Frame ${frameCount}] ✓ Verified: Start count matches final count (${finalCount})`);
}
}
bestPlayerCount = finalCount;
gameEnded = true;
// Update immediately with final count
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
return;
}
// Extract player counts from analytics (game in progress)
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
for (const analytic of roomVal.analytics) {
// Check for 'start' action - this is authoritative
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
if (startPlayerCount === null) {
startPlayerCount = analytic.value;
bestPlayerCount = analytic.value;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
}
// Update UI with authoritative start count
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
}
continue; // Skip to next analytic
}
// If we already have start count, we don't need to keep counting
if (startPlayerCount !== null) {
continue;
}
// Otherwise, look for any numeric value that could be a player count
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
seenPlayerCounts.add(analytic.value);
// Clamp to maxPlayers to avoid cumulative stats inflating count
const clampedValue = Math.min(analytic.value, maxPlayers);
// Update best guess (highest count seen so far, clamped to maxPlayers)
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
bestPlayerCount = clampedValue;
if (process.env.DEBUG) {
if (analytic.value > maxPlayers) {
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (clamped to ${clampedValue})`);
} else {
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
}
}
// Update UI with current best guess
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
}
}
}
}
// Check if room is no longer locked (game ended another way)
if (roomVal.locked === false && bestPlayerCount !== null) {
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] Room unlocked, game likely ended. Final count: ${bestPlayerCount}`);
}
gameEnded = true;
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
return;
}
}
} catch (e) {
if (process.env.DEBUG && frameCount % 50 === 0) {
console.log(`[Frame ${frameCount}] Parse error:`, e.message);
}
}
}
});
// Navigate and join audience
if (process.env.DEBUG) console.log('[Audience] Navigating to jackbox.tv...');
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
if (process.env.DEBUG) console.log('[Audience] Waiting for form...');
await page.waitForSelector('input#roomcode', { timeout: 10000 });
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
if (process.env.DEBUG) console.log('[Audience] Typing room code:', roomCode);
const roomInput = await page.$('input#roomcode');
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
await new Promise(resolve => setTimeout(resolve, 2000));
if (process.env.DEBUG) console.log('[Audience] Typing name...');
const nameInput = await page.$('input#username');
await nameInput.type('CountBot', { delay: 30 });
if (process.env.DEBUG) console.log('[Audience] Waiting for JOIN AUDIENCE button...');
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
}, { timeout: 10000 });
if (process.env.DEBUG) console.log('[Audience] Clicking JOIN AUDIENCE...');
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
if (btn) btn.click();
});
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
// Keep watching until game ends or we're told to stop
// Check every 5 seconds if we should still be watching
const checkInterval = setInterval(async () => {
// Check if we should stop
const game = db.prepare(`
SELECT status, player_count_check_status
FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped') {
if (process.env.DEBUG) {
console.log(`[Audience] Stopping watch - game status changed`);
}
clearInterval(checkInterval);
gameEnded = true;
if (browser) await browser.close();
return;
}
// Check if game ended
if (gameEnded) {
clearInterval(checkInterval);
if (browser) await browser.close();
return;
}
// Check if room still exists
const roomStatus = await checkRoomStatus(roomCode);
if (!roomStatus.exists) {
if (process.env.DEBUG) {
console.log(`[Audience] Room no longer exists - game ended`);
}
gameEnded = true;
clearInterval(checkInterval);
if (bestPlayerCount !== null) {
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
} else {
updatePlayerCount(sessionId, gameId, null, 'failed');
}
if (browser) await browser.close();
return;
}
}, 5000);
// Store the interval so we can clean it up
const check = activeChecks.get(checkKey);
if (check) {
check.watchInterval = checkInterval;
check.browser = browser;
}
} catch (error) {
console.error('[Audience] Error watching game:', error.message);
if (browser) {
await browser.close();
}
// If we had a best guess, use it; otherwise fail
if (bestPlayerCount !== null) {
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
} else {
updatePlayerCount(sessionId, gameId, null, 'failed');
}
}
}
/**
* Broadcast game.started event when room becomes locked
*/
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
try {
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('game.started', {
sessionId,
gameId,
roomCode,
maxPlayers
}, parseInt(sessionId));
}
console.log(`[Player Count] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
} catch (error) {
console.error('[Player Count] Failed to broadcast game.started:', error.message);
}
}
/**
* Update player count in database
*/
function updatePlayerCount(sessionId, gameId, playerCount, status) {
try {
db.prepare(`
UPDATE session_games
SET player_count = ?, player_count_check_status = ?
WHERE session_id = ? AND id = ?
`).run(playerCount, status, sessionId, gameId);
// Broadcast via WebSocket
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('player-count.updated', {
sessionId,
gameId,
playerCount,
status
}, parseInt(sessionId));
}
console.log(`[Player Count] Updated game ${gameId}: ${playerCount} players (${status})`);
} catch (error) {
console.error('[Player Count] Failed to update database:', error.message);
}
}
/**
* Start checking player count for a game
* Strategy:
* 1. Wait 10 seconds for initial room setup
* 2. Poll every 10 seconds until game is locked (started)
* 3. Broadcast game.started event when locked detected
* 4. Join audience and watch entire game
* 5. Update UI as we learn more
* 6. Finalize when game ends
*/
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
const checkKey = `${sessionId}-${gameId}`;
// If already checking, don't start again
if (activeChecks.has(checkKey)) {
console.log(`[Player Count] Already checking ${checkKey}`);
return;
}
// Check if already completed (but allow retrying failed checks)
const game = db.prepare(`
SELECT player_count_check_status
FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (game && game.player_count_check_status === 'completed') {
console.log(`[Player Count] Check already completed for ${checkKey}, skipping`);
return;
}
// If retrying a failed check, reset the status
if (game && game.player_count_check_status === 'failed') {
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
}
console.log(`[Player Count] Starting check for game ${gameId} with room code ${roomCode}`);
// Update status to waiting
db.prepare(`
UPDATE session_games
SET player_count_check_status = 'waiting'
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
// Function to check if game is ready (locked)
const waitForGameStart = async () => {
const roomStatus = await checkRoomStatus(roomCode);
if (!roomStatus.exists) {
console.log(`[Player Count] Room ${roomCode} does not exist`);
updatePlayerCount(sessionId, gameId, null, 'failed');
stopPlayerCountCheck(sessionId, gameId);
return false;
}
// If locked, game has started - ready to watch
if (roomStatus.locked) {
console.log(`[Player Count] Room is LOCKED - game in progress, starting watch`);
return { ready: true, maxPlayers: roomStatus.maxPlayers };
}
// Log if full but not yet started
if (roomStatus.full) {
console.log(`[Player Count] Room is FULL but not locked yet - waiting for game start`);
} else {
console.log(`[Player Count] Room not ready yet (lobby still open)`);
}
return null; // Not ready, keep polling
};
// Wait 10 seconds before first check
const initialTimeout = setTimeout(async () => {
try {
// Update status to checking
db.prepare(`
UPDATE session_games
SET player_count_check_status = 'checking'
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
console.log(`[Player Count] Initial check after 10s for ${checkKey}`);
const result = await waitForGameStart();
if (result && result.ready === true) {
// Game is locked, broadcast game.started and start watching
const realMaxPlayers = result.maxPlayers;
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
} else if (result === null) {
// Not ready yet, poll every 10 seconds
const checkInterval = setInterval(async () => {
// Check if we should stop
const game = db.prepare(`
SELECT status, player_count_check_status
FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped' || game.player_count_check_status === 'completed') {
console.log(`[Player Count] Stopping check for ${checkKey} - game status changed`);
stopPlayerCountCheck(sessionId, gameId);
return;
}
const result = await waitForGameStart();
if (result && result.ready === true) {
// Game is now locked, stop interval, broadcast game.started, and start watching
clearInterval(checkInterval);
const check = activeChecks.get(checkKey);
if (check) check.interval = null;
const realMaxPlayers = result.maxPlayers;
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
} else if (result === false) {
// Check failed or completed, stop
clearInterval(checkInterval);
stopPlayerCountCheck(sessionId, gameId);
}
}, 10000); // Poll every 10 seconds
// Store the interval
const check = activeChecks.get(checkKey);
if (check) check.interval = checkInterval;
}
// If ready === false, check already stopped/completed
} catch (error) {
console.error(`[Player Count] Error starting check for ${checkKey}:`, error.message);
updatePlayerCount(sessionId, gameId, null, 'failed');
stopPlayerCountCheck(sessionId, gameId);
}
}, 10000); // Wait 10 seconds before first check
// Store the check references
activeChecks.set(checkKey, {
sessionId,
gameId,
roomCode,
initialTimeout,
interval: null,
watchInterval: null,
browser: null
});
}
/**
* Stop checking player count for a game
*/
async function stopPlayerCountCheck(sessionId, gameId) {
const checkKey = `${sessionId}-${gameId}`;
const check = activeChecks.get(checkKey);
if (check) {
if (check.initialTimeout) {
clearTimeout(check.initialTimeout);
}
if (check.interval) {
clearInterval(check.interval);
}
if (check.watchInterval) {
clearInterval(check.watchInterval);
}
if (check.browser) {
try {
await check.browser.close();
} catch (e) {
// Ignore errors closing browser
}
}
activeChecks.delete(checkKey);
// Update status to stopped if not already completed or failed
const game = db.prepare(`
SELECT player_count_check_status
FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
db.prepare(`
UPDATE session_games
SET player_count_check_status = 'stopped'
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
}
console.log(`[Player Count] Stopped check for ${checkKey}`);
}
}
/**
* Clean up all active checks (for graceful shutdown)
*/
async function cleanupAllChecks() {
for (const [checkKey, check] of activeChecks.entries()) {
if (check.initialTimeout) {
clearTimeout(check.initialTimeout);
}
if (check.interval) {
clearInterval(check.interval);
}
if (check.watchInterval) {
clearInterval(check.watchInterval);
}
if (check.browser) {
try {
await check.browser.close();
} catch (e) {
// Ignore errors
}
}
}
activeChecks.clear();
console.log('[Player Count] Cleaned up all active checks');
}
module.exports = {
startPlayerCountCheck,
stopPlayerCountCheck,
cleanupAllChecks
};

182
docs/api/README.md Normal file
View File

@@ -0,0 +1,182 @@
# Jackbox Game Picker API
## Overview
The API manages Jackbox Party Pack games, runs gaming sessions, tracks popularity via voting, picks games with weighted random selection, and notifies external systems via webhooks and WebSocket.
## Base URL
| Environment | Base URL | Notes |
|-------------|----------|-------|
| Local development | `http://localhost:5000` | Backend direct |
| Docker Compose | `http://localhost:3000/api` | Via Vite/Nginx proxy |
All REST endpoints are prefixed with `/api/` except `/health`.
## Authentication
1. **Login**: POST to `/api/auth/login` with JSON body:
```json
{ "key": "<admin-key>" }
```
Returns a JWT token.
2. **Authorization**: Include the token in requests:
```
Authorization: Bearer <token>
```
3. **Expiry**: Tokens expire in 24 hours.
### Public Endpoints (no auth required)
- `GET /api/games`
- `GET /api/games/packs`
- `GET /api/games/meta/packs`
- `GET /api/games/{id}`
- `GET /api/sessions`
- `GET /api/sessions/active`
- `GET /api/sessions/{id}`
- `GET /api/sessions/{id}/games`
- `GET /api/sessions/{id}/votes`
- `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`
- `GET /api/votes`
- `GET /api/stats`
- `POST /api/pick`
- `GET /health`
All write and admin operations require authentication.
## Request/Response Format
- Request and response bodies use JSON. Set `Content-Type: application/json`.
- **Exceptions**:
- `GET /api/games/export/csv` returns `text/csv`
- `GET /api/sessions/{id}/export` returns `text/plain` or `application/json` depending on `format` query param
## Error Handling
All errors return:
```json
{ "error": "description" }
```
| Status | Meaning |
|--------|---------|
| 400 | Bad request / validation failure |
| 401 | No token provided |
| 403 | Invalid or expired token |
| 404 | Not found |
| 409 | Conflict (e.g. duplicate vote) |
| 500 | Server error |
Global error handler may include additional detail:
```json
{ "error": "Something went wrong!", "message": "<details>" }
```
## Boolean Fields
SQLite stores booleans as integers (0/1). In request bodies, pass JavaScript booleans (`true`/`false`); the API converts them. In responses, expect `0`/`1` for game and session fields. **Exception**: `Webhook.enabled` returns a JavaScript boolean.
## Pagination
Most list endpoints return full result sets. The exception is `GET /api/votes`, which supports pagination via `page` and `limit` query parameters (default: page 1, limit 50, max 100).
## Quick Reference
### Health
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | No | Health check |
### Auth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/login` | No | Authenticate with admin key |
| POST | `/api/auth/verify` | Yes | Verify JWT token |
### Games
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/games` | No | List games with optional filters |
| POST | `/api/games` | Yes | Create a new game |
| GET | `/api/games/packs` | No | List all packs |
| GET | `/api/games/meta/packs` | No | List pack metadata |
| GET | `/api/games/export/csv` | Yes | Export games as CSV |
| POST | `/api/games/import/csv` | Yes | Import games from CSV |
| PATCH | `/api/games/packs/{name}/favor` | Yes | Update pack favor bias |
| PATCH | `/api/games/packs/{name}/toggle` | Yes | Enable or disable a pack |
| GET | `/api/games/{id}` | No | Get a game by ID |
| PUT | `/api/games/{id}` | Yes | Update a game |
| DELETE | `/api/games/{id}` | Yes | Delete a game |
| PATCH | `/api/games/{id}/toggle` | Yes | Toggle game enabled status |
| PATCH | `/api/games/{id}/favor` | Yes | Update game favor bias |
### Sessions
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/sessions` | No | List all sessions |
| POST | `/api/sessions` | Yes | Create a new session |
| GET | `/api/sessions/active` | No | Get the active session |
| GET | `/api/sessions/{id}` | No | Get a session by ID |
| DELETE | `/api/sessions/{id}` | Yes | Delete a session |
| POST | `/api/sessions/{id}/close` | Yes | Close a session |
| GET | `/api/sessions/{id}/games` | No | List games in a session |
| GET | `/api/sessions/{id}/votes` | No | Get per-game vote breakdown for a session |
| POST | `/api/sessions/{id}/games` | Yes | Add a game to a session |
| POST | `/api/sessions/{id}/chat-import` | Yes | Import chat log for vote processing |
| GET | `/api/sessions/{id}/export` | Yes | Export session |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/status` | Yes | Update session game status |
| DELETE | `/api/sessions/{sessionId}/games/{sessionGameId}` | Yes | Remove game from session |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Yes | Update room code for session game |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Yes | Start room monitor for player count |
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Yes | Stop room monitor |
| GET | `/api/sessions/{sessionId}/games/{sessionGameId}/status-live` | No | Get live game status from shard monitor |
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Yes | Update player count for session game |
### Picker
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/pick` | No | Pick a random game with optional filters |
### Stats
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/stats` | No | Get aggregate statistics |
### Votes
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/votes` | No | Paginated vote history with filtering |
| POST | `/api/votes/live` | Yes | Record a live vote (up/down) |
### Webhooks
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/webhooks` | Yes | List all webhooks |
| POST | `/api/webhooks` | Yes | Create a webhook |
| GET | `/api/webhooks/{id}` | Yes | Get a webhook by ID |
| PATCH | `/api/webhooks/{id}` | Yes | Update a webhook |
| DELETE | `/api/webhooks/{id}` | Yes | Delete a webhook |
| POST | `/api/webhooks/test/{id}` | Yes | Send test webhook |
| GET | `/api/webhooks/{id}/logs` | Yes | List webhook delivery logs |
## Documentation Links
- [OpenAPI Spec](openapi.yaml)
- **Endpoint docs**: [Auth](endpoints/auth.md), [Games](endpoints/games.md), [Sessions](endpoints/sessions.md), [Picker](endpoints/picker.md), [Stats](endpoints/stats.md), [Votes](endpoints/votes.md), [Webhooks](endpoints/webhooks.md)
- [WebSocket Protocol](websocket.md)
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)

135
docs/api/endpoints/auth.md Normal file
View File

@@ -0,0 +1,135 @@
# Auth Endpoints
Simple admin-key authentication. Single role (admin). No user management. Obtain a JWT from `POST /api/auth/login` and use it as a Bearer token for protected endpoints.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/login` | No | Exchange admin key for JWT |
| POST | `/api/auth/verify` | Bearer | Validate token and return user info |
---
## POST /api/auth/login
Exchange an admin key for a JWT. Use the returned token in the `Authorization: Bearer <token>` header for protected routes. Tokens expire after 24 hours.
### Authentication
None. This endpoint is public.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| key | string | Yes | Admin key (configured via `ADMIN_KEY` env) |
```json
{
"key": "your-admin-key"
}
```
### Response
**200 OK**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"message": "Authentication successful",
"expiresIn": "24h"
}
```
| Field | Description |
|-------|-------------|
| token | JWT to use in `Authorization: Bearer <token>` |
| message | Success message |
| expiresIn | Token lifetime |
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "Admin key is required" }` | `key` field missing |
| 401 | `{ "error": "Invalid admin key" }` | Wrong key |
### Example
```bash
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"key": "your-admin-key"}'
```
**Sample response:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJ0aW1lc3RhbXAiOjE3MTAwMDAwMDAwLCJpYXQiOjE3MTAwMDAwMDB9.abc123",
"message": "Authentication successful",
"expiresIn": "24h"
}
```
---
## POST /api/auth/verify
Verify that the provided Bearer token is valid and return the decoded user payload.
### Authentication
Bearer token required. Include in header: `Authorization: Bearer <token>`.
### Parameters
None.
### Response
**200 OK**
```json
{
"valid": true,
"user": {
"role": "admin",
"timestamp": 1710000000000
}
}
```
| Field | Description |
|-------|-------------|
| valid | Always `true` when token is valid |
| user.role | User role (always `"admin"`) |
| user.timestamp | Unix ms when token was issued |
### Error Responses
| Status | Body | When |
|--------|------|------|
| 401 | `{ "error": "Access token required" }` | No `Authorization` header or Bearer token |
| 403 | `{ "error": "Invalid or expired token" }` | Bad or expired token |
### Example
```bash
curl -X POST http://localhost:5000/api/auth/verify \
-H "Authorization: Bearer $TOKEN"
```
**Sample response:**
```json
{
"valid": true,
"user": {
"role": "admin",
"timestamp": 1710000000000
}
}
```

627
docs/api/endpoints/games.md Normal file
View File

@@ -0,0 +1,627 @@
# Games Endpoints
Manage the Jackbox game catalog. Games belong to packs (e.g., "Jackbox Party Pack 7"). Each game has player limits, type, audience support, family-friendliness, and favor bias for weighted selection. Packs can also have favor/disfavor bias to influence the picker.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/games` | No | List games with optional filters |
| GET | `/api/games/packs` | No | List all packs |
| GET | `/api/games/meta/packs` | No | Pack metadata (counts, plays) |
| GET | `/api/games/export/csv` | Bearer | Export games as CSV |
| PATCH | `/api/games/packs/{name}/favor` | Bearer | Set pack favor bias |
| GET | `/api/games/{id}` | No | Get single game |
| POST | `/api/games` | Bearer | Create game |
| PUT | `/api/games/{id}` | Bearer | Update game |
| DELETE | `/api/games/{id}` | Bearer | Delete game |
| PATCH | `/api/games/{id}/toggle` | Bearer | Toggle game enabled status |
| PATCH | `/api/games/packs/{name}/toggle` | Bearer | Enable/disable all games in pack |
| POST | `/api/games/import/csv` | Bearer | Import games from CSV |
| PATCH | `/api/games/{id}/favor` | Bearer | Set game favor bias |
---
## GET /api/games
List all games with optional query filters. Results are ordered by `pack_name`, then `title`.
### Authentication
None.
### Parameters
| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| enabled | query | string | No | `"true"` or `"false"` to filter by enabled status |
| playerCount | query | integer | No | Filter games where `min_players ≤ count ≤ max_players` |
| drawing | query | string | No | `"only"` = `game_type='Drawing'`, `"exclude"` = exclude Drawing |
| length | query | string | No | `"short"` (≤15 min or NULL), `"medium"` (1625 min), `"long"` (>25 min) |
| familyFriendly | query | string | No | `"true"` or `"false"` |
| pack | query | string | No | Exact `pack_name` match |
### Response
**200 OK**
```json
[
{
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 500 | `{ "error": "..." }` | Server error |
### Example
```bash
curl "http://localhost:5000/api/games?playerCount=6&pack=Jackbox%20Party%20Pack%207"
```
---
## GET /api/games/packs
List all packs with their favor bias.
### Authentication
None.
### Response
**200 OK**
```json
[
{
"id": 1,
"name": "Jackbox Party Pack 7",
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
---
## GET /api/games/meta/packs
Return pack metadata: total game count, enabled count, and total plays per pack.
### Authentication
None.
### Response
**200 OK**
```json
[
{
"name": "Jackbox Party Pack 7",
"total_count": 5,
"enabled_count": 5,
"total_plays": 42
}
]
```
---
## GET /api/games/export/csv
Export the full game catalog as a CSV file download.
### Authentication
Bearer token required.
### Response
**200 OK**
- Content-Type: `text/csv`
- Content-Disposition: `attachment; filename="jackbox-games.csv"`
- Columns: Pack Name, Title, Min Players, Max Players, Length (minutes), Audience, Family Friendly, Game Type, Secondary Type
### Example
```bash
curl -o jackbox-games.csv "http://localhost:5000/api/games/export/csv" \
-H "Authorization: Bearer $TOKEN"
```
---
## PATCH /api/games/packs/{name}/favor
Set favor bias for a pack. Affects weighted random selection in the picker.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| name | string | Pack name (exact match, URL-encode if spaces) |
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| favor_bias | integer | Yes | `1` = favor, `-1` = disfavor, `0` = neutral |
```json
{
"favor_bias": 1
}
```
### Response
**200 OK**
```json
{
"message": "Pack favor bias updated successfully",
"favor_bias": 1
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)" }` | Invalid value |
### Example
```bash
curl -X PATCH "http://localhost:5000/api/games/packs/Jackbox%20Party%20Pack%207/favor" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"favor_bias": 1}'
```
---
## GET /api/games/{id}
Get a single game by ID.
### Authentication
None.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Game ID |
### Response
**200 OK**
```json
{
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Game not found" }` | Invalid ID |
### Example
```bash
curl "http://localhost:5000/api/games/1"
```
---
## POST /api/games
Create a new game. Pack is created automatically if it does not exist.
### Authentication
Bearer token required.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| pack_name | string | Yes | Pack name (e.g., "Jackbox Party Pack 7") |
| title | string | Yes | Game title |
| min_players | integer | Yes | Minimum players |
| max_players | integer | Yes | Maximum players |
| length_minutes | integer | No | Approx. play length |
| has_audience | boolean | No | Audience mode supported |
| family_friendly | boolean | No | Family-friendly rating |
| game_type | string | No | Primary type (e.g., "Writing", "Drawing") |
| secondary_type | string | No | Secondary type |
```json
{
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": true,
"family_friendly": false,
"game_type": "Writing",
"secondary_type": null
}
```
### Response
**201 Created**
Returns the created game object.
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "Missing required fields" }` | Missing pack_name, title, min_players, or max_players |
### Example
```bash
curl -X POST "http://localhost:5000/api/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": true,
"family_friendly": false,
"game_type": "Writing",
"secondary_type": null
}'
```
---
## PUT /api/games/{id}
Update a game. All fields are optional; uses COALESCE (only provided fields are updated).
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Game ID |
### Request Body
All fields optional. Include only the fields to update.
```json
{
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 20,
"has_audience": true,
"family_friendly": false,
"game_type": "Writing",
"secondary_type": null,
"enabled": true
}
```
### Response
**200 OK**
Returns the updated game object.
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Game not found" }` | Invalid ID |
### Example
```bash
curl -X PUT "http://localhost:5000/api/games/1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"length_minutes": 20, "enabled": true}'
```
---
## DELETE /api/games/{id}
Delete a game permanently.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Game ID |
### Response
**200 OK**
```json
{
"message": "Game deleted successfully"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Game not found" }` | Invalid ID |
### Example
```bash
curl -X DELETE "http://localhost:5000/api/games/1" \
-H "Authorization: Bearer $TOKEN"
```
---
## PATCH /api/games/{id}/toggle
Toggle the game's `enabled` field (0↔1). Use to quickly enable/disable a game without a full PUT.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Game ID |
### Response
**200 OK**
Returns the updated game object with the flipped `enabled` value.
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Game not found" }` | Invalid ID |
### Example
```bash
curl -X PATCH "http://localhost:5000/api/games/1/toggle" \
-H "Authorization: Bearer $TOKEN"
```
---
## PATCH /api/games/packs/{name}/toggle
Enable or disable all games in a pack at once.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| name | string | Pack name (URL-encode if spaces) |
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| enabled | boolean | Yes | `true` to enable, `false` to disable |
```json
{
"enabled": true
}
```
### Response
**200 OK**
```json
{
"message": "Pack enabled successfully",
"gamesAffected": 12
}
```
Message varies: "Pack enabled successfully" or "Pack disabled successfully" based on the `enabled` value.
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "enabled status required" }` | Missing `enabled` field |
### Example
```bash
curl -X PATCH "http://localhost:5000/api/games/packs/Jackbox%20Party%20Pack%207/toggle" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
```
---
## POST /api/games/import/csv
Import games from CSV data. Default mode is `append`. Use `"replace"` to delete all existing games before importing.
### Authentication
Bearer token required.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| csvData | string | Yes | Raw CSV content (header + rows) |
| mode | string | No | `"append"` (default) or `"replace"` |
**CSV columns:** Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type
```json
{
"csvData": "Game Pack,Game Title,Min. Players,Max. Players,Length,Audience,Family Friendly?,Game Type,Secondary Type\nJackbox Party Pack 7,Quiplash 3,3,8,15,Yes,No,Writing,\nJackbox Party Pack 7,The Devils and the Details,3,7,25,Yes,No,Strategy,",
"mode": "append"
}
```
### Response
**200 OK**
```json
{
"message": "Successfully imported 5 games",
"count": 5,
"mode": "append"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "CSV data required" }` | Missing `csvData` |
### Example
```bash
curl -X POST "http://localhost:5000/api/games/import/csv" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"csvData": "Game Pack,Game Title,Min. Players,Max. Players,Length,Audience,Family Friendly?,Game Type,Secondary Type\nJackbox Party Pack 7,Quiplash 3,3,8,15,Yes,No,Writing,\nJackbox Party Pack 7,The Devils and the Details,3,7,25,Yes,No,Strategy,",
"mode": "append"
}'
```
---
## PATCH /api/games/{id}/favor
Set favor bias for a single game. Affects weighted random selection in the picker.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Game ID |
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| favor_bias | integer | Yes | `1` = favor, `-1` = disfavor, `0` = neutral |
```json
{
"favor_bias": -1
}
```
### Response
**200 OK**
```json
{
"message": "Favor bias updated successfully",
"favor_bias": -1
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)" }` | Invalid value |
| 404 | `{ "error": "Game not found" }` | Invalid ID |
### Example
```bash
curl -X PATCH "http://localhost:5000/api/games/1/favor" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"favor_bias": -1}'
```

View File

@@ -0,0 +1,120 @@
# Picker Endpoints
Weighted random game selection. Picks from enabled games matching your filters, with favor bias affecting probability. Avoids recently played games within a session.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/pick` | No | Pick a random game with filters and repeat avoidance |
---
## POST /api/pick
Select a game using weighted random selection. Filters to only enabled games, applies favor/disfavor bias to influence probability, and optionally excludes recently played games when a session is provided.
### Authentication
None.
### Request Body
All fields optional. Provide only the filters you want to apply.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| playerCount | integer | No | Filter games where `min_players ≤ count ≤ max_players` |
| drawing | string | No | `"only"` = Drawing type only, `"exclude"` = exclude Drawing type |
| length | string | No | `"short"` (≤15 min or NULL), `"medium"` (1625 min), `"long"` (>25 min) |
| familyFriendly | boolean | No | Filter by family-friendly rating |
| sessionId | integer | No | Session ID for repeat avoidance |
| excludePlayed | boolean | No | When `true`, exclude ALL games played in session. Default: exclude last 2 only |
```json
{
"playerCount": 6,
"drawing": "exclude",
"length": "short",
"familyFriendly": true,
"sessionId": 3,
"excludePlayed": false
}
```
### Filters
- **Enabled games only:** Only games with `enabled = 1` are considered.
- **playerCount:** Filters games where `min_players ≤ playerCount ≤ max_players`.
- **drawing:** `"only"` = games with `game_type = 'Drawing'`; `"exclude"` = games that are not Drawing type.
- **length:** `"short"` = ≤15 min (includes NULL); `"medium"` = 1625 min; `"long"` = >25 min.
- **familyFriendly:** `true` or `false` filters by `family_friendly`.
### Weighted Selection
- **Game favor_bias:** `1` = 3× weight, `0` = 1× weight, `-1` = 0.2× weight.
- **Pack favor_bias:** `1` = 2× weight, `0` = 1× weight, `-1` = 0.3× weight.
- Game and pack biases multiply together.
### Repeat Avoidance (with sessionId)
- **Default (`excludePlayed: false`):** Excludes the last 2 played games in the session.
- **With `excludePlayed: true`:** Excludes ALL games played in the session.
### Response
**200 OK**
```json
{
"game": {
"id": 42,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
},
"poolSize": 15,
"totalEnabled": 17
}
```
| Field | Description |
|-------|-------------|
| game | Full game object for the selected game |
| poolSize | Number of games in the eligible pool after filters |
| totalEnabled | Approximate total enabled games (includes excluded when sessionId provided) |
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "No games match the current filters", "suggestion": "Try adjusting your filters or enabling more games" }` | No games match the filters |
| 404 | `{ "error": "All eligible games have been played in this session", "suggestion": "Enable more games or adjust your filters", "recentlyPlayed": [1, 5, 12] }` | All eligible games already played in session (when `excludePlayed: true`) |
| 404 | `{ "error": "All eligible games have been played recently", "suggestion": "Enable more games or adjust your filters", "recentlyPlayed": [1, 5] }` | Last 2 games are the only matches (when `excludePlayed: false`) |
| 500 | `{ "error": "..." }` | Server error |
### Example
```bash
curl -X POST "http://localhost:5000/api/pick" \
-H "Content-Type: application/json" \
-d '{
"playerCount": 6,
"drawing": "exclude",
"length": "short",
"familyFriendly": true,
"sessionId": 3,
"excludePlayed": false
}'
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
# Stats Endpoints
Aggregate statistics about the game library, sessions, and popularity.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/stats` | No | Get aggregate statistics |
---
## GET /api/stats
Return aggregate statistics: game counts, pack count, session counts, total games played, most-played games, and top-rated games.
### Authentication
None.
### Response
**200 OK**
```json
{
"games": { "count": 89 },
"gamesEnabled": { "count": 75 },
"packs": { "count": 9 },
"sessions": { "count": 12 },
"activeSessions": { "count": 1 },
"totalGamesPlayed": { "count": 156 },
"mostPlayedGames": [
{
"id": 42,
"title": "Quiplash 3",
"pack_name": "Jackbox Party Pack 7",
"play_count": 15,
"popularity_score": 8,
"upvotes": 10,
"downvotes": 2
}
],
"topRatedGames": [
{
"id": 42,
"title": "Quiplash 3",
"pack_name": "Jackbox Party Pack 7",
"play_count": 15,
"popularity_score": 8,
"upvotes": 10,
"downvotes": 2
}
]
}
```
| Field | Description |
|-------|-------------|
| games.count | Total number of games in the library |
| gamesEnabled.count | Number of enabled games |
| packs.count | Number of distinct packs |
| sessions.count | Total sessions (all time) |
| activeSessions.count | Sessions with `is_active = 1` |
| totalGamesPlayed.count | Total game plays across all sessions |
| mostPlayedGames | Top 10 games by `play_count` DESC (only games with `play_count` > 0) |
| topRatedGames | Top 10 games by `popularity_score` DESC (only games with `popularity_score` > 0) |
### Error Responses
| Status | Body | When |
|--------|------|------|
| 500 | `{ "error": "..." }` | Server error |
### Example
```bash
curl "http://localhost:5000/api/stats"
```

152
docs/api/endpoints/votes.md Normal file
View File

@@ -0,0 +1,152 @@
# 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.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/votes` | None | Paginated vote history with filtering |
| POST | `/api/votes/live` | Bearer | Submit a live vote (up/down) |
---
## GET /api/votes
Paginated vote history with filtering. Use query parameters to filter by session, game, username, or vote type.
### Authentication
None.
### Query Parameters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| session_id | int | No | — | Filter by session ID |
| game_id | int | No | — | Filter by game ID |
| username | string | No | — | Filter by voter username |
| vote_type | string | No | — | `"up"` or `"down"` |
| page | int | No | 1 | Page number |
| limit | int | No | 50 | Items per page (max 100) |
### Response
**200 OK**
Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"up"` or `"down"` (not raw integers).
```json
{
"votes": [
{
"id": 891,
"session_id": 5,
"game_id": 42,
"game_title": "Quiplash 3",
"pack_name": "Party Pack 7",
"username": "viewer123",
"vote_type": "up",
"timestamp": "2026-03-15T20:29:55.000Z",
"created_at": "2026-03-15T20:29:56.000Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 237,
"total_pages": 5
}
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "..." }` | Invalid `session_id`, `game_id`, or `vote_type` |
| 200 | `{ "votes": [], "pagination": { "page": 1, "limit": 50, "total": 0, "total_pages": 0 } }` | No results match the filters |
---
## 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.
### Authentication
Bearer token required. Include in header: `Authorization: Bearer <token>`.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| username | string | Yes | Identifier for the voter (used for deduplication) |
| vote | string | Yes | `"up"` or `"down"` |
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
```json
{
"username": "viewer123",
"vote": "up",
"timestamp": "2026-03-15T20:30:00Z"
}
```
### Behavior
- 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).
- 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).
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
### Response
**200 OK**
```json
{
"success": true,
"message": "Vote recorded successfully",
"session": { "id": 3, "games_played": 5 },
"game": {
"id": 42,
"title": "Quiplash 3",
"upvotes": 11,
"downvotes": 2,
"popularity_score": 9
},
"vote": {
"username": "viewer123",
"type": "up",
"timestamp": "2026-03-15T20:30:00Z"
}
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "Missing required fields: username, vote, timestamp" }` | Missing required fields |
| 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 |
| 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": "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 |
| 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 |
### Example
```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"
}'
```

View File

@@ -0,0 +1,382 @@
# Webhooks Endpoints
HTTP callback endpoints for external integrations. Register webhook URLs to receive notifications about events like game additions. All endpoints require authentication.
## Endpoint Summary
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/webhooks` | Bearer | List all webhooks |
| GET | `/api/webhooks/{id}` | Bearer | Get single webhook |
| POST | `/api/webhooks` | Bearer | Create webhook |
| PATCH | `/api/webhooks/{id}` | Bearer | Update webhook |
| DELETE | `/api/webhooks/{id}` | Bearer | Delete webhook |
| POST | `/api/webhooks/test/{id}` | Bearer | Send test event |
| GET | `/api/webhooks/{id}/logs` | Bearer | Get webhook delivery logs |
---
## GET /api/webhooks
List all registered webhooks. `secret` is not included in responses. `events` is returned as a parsed array. `enabled` is returned as a boolean.
### Authentication
Bearer token required.
### Response
**200 OK**
```json
[
{
"id": 1,
"name": "Discord Bot",
"url": "https://example.com/webhook",
"events": ["game.added"],
"enabled": true,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
Note: `secret` is never returned.
### Error Responses
| Status | Body | When |
|--------|------|------|
| 401 | `{ "error": "Access token required" }` | No Bearer token |
| 403 | `{ "error": "Invalid or expired token" }` | Bad or expired token |
| 500 | `{ "error": "..." }` | Server error |
### Example
```bash
curl "http://localhost:5000/api/webhooks" \
-H "Authorization: Bearer $TOKEN"
```
---
## GET /api/webhooks/{id}
Get a single webhook by ID.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Webhook ID |
### Response
**200 OK**
```json
{
"id": 1,
"name": "Discord Bot",
"url": "https://example.com/webhook",
"events": ["game.added"],
"enabled": true,
"created_at": "2024-01-15T12:00:00.000Z"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
### Example
```bash
curl "http://localhost:5000/api/webhooks/1" \
-H "Authorization: Bearer $TOKEN"
```
---
## POST /api/webhooks
Create a new webhook.
### Authentication
Bearer token required.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Display name for the webhook |
| url | string | Yes | Callback URL (must be valid) |
| secret | string | Yes | Secret for signing payloads |
| events | array | Yes | Event types to subscribe to (e.g., `["game.added"]`) |
```json
{
"name": "Discord Bot",
"url": "https://example.com/webhook",
"secret": "mysecret123",
"events": ["game.added"]
}
```
### Response
**201 Created**
```json
{
"id": 5,
"name": "Discord Bot",
"url": "https://example.com/webhook",
"events": ["game.added"],
"enabled": true,
"created_at": "2024-01-15T12:00:00.000Z",
"message": "Webhook created successfully"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "Missing required fields: name, url, secret, events" }` | Missing fields |
| 400 | `{ "error": "events must be an array" }` | `events` is not an array |
| 400 | `{ "error": "Invalid URL format" }` | URL validation failed |
### Example
```bash
curl -X POST "http://localhost:5000/api/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Discord Bot",
"url": "https://example.com/webhook",
"secret": "mysecret123",
"events": ["game.added"]
}'
```
---
## PATCH /api/webhooks/{id}
Update an existing webhook. At least one field must be provided.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Webhook ID |
### Request Body
All fields optional. Include only the fields to update.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | No | Display name |
| url | string | No | Callback URL (must be valid) |
| secret | string | No | New secret |
| events | array | No | Event types (must be array) |
| enabled | boolean | No | Enable or disable the webhook |
```json
{
"name": "Discord Bot Updated",
"url": "https://example.com/webhook-v2",
"enabled": true
}
```
### Response
**200 OK**
```json
{
"id": 5,
"name": "Discord Bot Updated",
"url": "https://example.com/webhook-v2",
"events": ["game.added"],
"enabled": true,
"created_at": "2024-01-15T12:00:00.000Z",
"message": "Webhook updated successfully"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 400 | `{ "error": "No fields to update" }` | No fields in body |
| 400 | `{ "error": "Invalid URL format" }` | Invalid URL |
| 400 | `{ "error": "events must be an array" }` | `events` not an array |
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
### Example
```bash
curl -X PATCH "http://localhost:5000/api/webhooks/5" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Discord Bot Updated", "enabled": true}'
```
---
## DELETE /api/webhooks/{id}
Delete a webhook. Cascades to `webhook_logs` (logs are deleted).
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Webhook ID |
### Response
**200 OK**
```json
{
"message": "Webhook deleted successfully",
"webhookId": 5
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
### Example
```bash
curl -X DELETE "http://localhost:5000/api/webhooks/5" \
-H "Authorization: Bearer $TOKEN"
```
---
## POST /api/webhooks/test/{id}
Send a test `game.added` event with dummy data to the webhook URL. Delivery runs asynchronously; check `webhook_logs` for status.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Webhook ID |
### Response
**200 OK**
```json
{
"message": "Test webhook sent",
"note": "Check webhook_logs table for delivery status"
}
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
### Example
```bash
curl -X POST "http://localhost:5000/api/webhooks/test/5" \
-H "Authorization: Bearer $TOKEN"
```
---
## GET /api/webhooks/{id}/logs
Get delivery logs for a webhook. Payload is parsed from JSON string to object.
### Authentication
Bearer token required.
### Path Parameters
| Name | Type | Description |
|------|------|-------------|
| id | integer | Webhook ID |
### Query Parameters
| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| limit | query | integer | No | Max number of logs (default: 50) |
### Response
**200 OK**
```json
[
{
"id": 1,
"webhook_id": 5,
"event_type": "game.added",
"payload": {
"session": { "id": 3, "is_active": true, "games_played": 2 },
"game": {
"id": 42,
"title": "Quiplash 3",
"pack_name": "Jackbox Party Pack 7",
"min_players": 3,
"max_players": 8,
"manually_added": false
}
},
"response_status": 200,
"error_message": null,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
### Error Responses
| Status | Body | When |
|--------|------|------|
| 500 | `{ "error": "..." }` | Server error |
### Example
```bash
curl "http://localhost:5000/api/webhooks/5/logs?limit=20" \
-H "Authorization: Bearer $TOKEN"
```

View File

@@ -0,0 +1,316 @@
# Getting Started
A narrative walkthrough of the minimum viable integration path. Use this guide to go from zero to a completed game night session using the Jackbox Game Picker API.
**Prerequisites:** API running locally (`http://localhost:5000`), admin key set via `ADMIN_KEY` environment variable.
---
## 1. Health Check
Verify the API is running before anything else. The health endpoint requires no authentication.
**Why:** Quick sanity check. If this fails, nothing else will work.
```bash
curl http://localhost:5000/health
```
**Sample response (200 OK):**
```json
{
"status": "ok",
"message": "Jackbox Game Picker API is running"
}
```
---
## 2. Authenticate
Exchange your admin key for a JWT. You'll use this token for all write operations (creating sessions, adding games, closing sessions).
**Why:** Creating sessions, adding games to them, and closing sessions require authentication. The picker and game listings are public, but session management is not.
See [Auth endpoints](../endpoints/auth.md) for full details.
```bash
TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"key": "your-admin-key"}' | jq -r '.token')
```
Or capture the full response:
```bash
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"key": "your-admin-key"}'
```
**Sample response (200 OK):**
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJ0aW1lc3RhbXAiOjE3MTAwMDAwMDAwLCJpYXQiOjE3MTAwMDAwMDB9.abc123",
"message": "Authentication successful",
"expiresIn": "24h"
}
```
Store `token` in `$TOKEN` for the remaining steps. Tokens expire after 24 hours.
---
## 3. Browse Games
List available games. Use query parameters to narrow the catalog—for example, `playerCount` filters to games that support that many players.
**Why:** Know what's in the catalog before you pick. Filtering by player count ensures you only see games you can actually play.
See [Games endpoints](../endpoints/games.md) for all filters.
```bash
curl "http://localhost:5000/api/games?playerCount=6"
```
**Sample response (200 OK):**
```json
[
{
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
},
{
"id": 2,
"pack_name": "Jackbox Party Pack 7",
"title": "The Devils and the Details",
"min_players": 3,
"max_players": 7,
"length_minutes": 25,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Strategy",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
}
]
```
---
## 4. Pick a Game
Get a weighted random game based on your filters. The picker considers favor/disfavor bias and can avoid recently played games when a session is provided.
**Why:** Instead of manually choosing, let the API pick a game that fits your player count, length, and other preferences. Use the same filters you used to browse.
See [Picker endpoint](../endpoints/picker.md) for all options.
```bash
curl -X POST http://localhost:5000/api/pick \
-H "Content-Type: application/json" \
-d '{"playerCount": 6}'
```
**Sample response (200 OK):**
```json
{
"game": {
"id": 1,
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"min_players": 3,
"max_players": 8,
"length_minutes": 15,
"has_audience": 1,
"family_friendly": 0,
"game_type": "Writing",
"secondary_type": null,
"play_count": 0,
"popularity_score": 0,
"enabled": 1,
"favor_bias": 0,
"created_at": "2024-01-15T12:00:00.000Z"
},
"poolSize": 12,
"totalEnabled": 17
}
```
Save the `game.id` (e.g. `1`) — you'll use it when adding the game to the session.
---
## 5. Start a Session
Create a new gaming session. Only one session can be active at a time. Use notes to label the night (e.g., "Friday game night").
**Why:** Sessions track which games you played, when, and support voting and room monitoring. Starting a session marks the beginning of your game night.
See [Sessions endpoints](../endpoints/sessions.md) for full details.
```bash
curl -X POST http://localhost:5000/api/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Friday game night"}'
```
**Sample response (201 Created):**
```json
{
"id": 5,
"notes": "Friday game night",
"is_active": 1,
"created_at": "2026-03-15T19:00:00.000Z",
"closed_at": null
}
```
Save the `id` (e.g. `5`) — you'll use it to add games and close the session.
---
## 6. Add the Picked Game
Add the game you picked (step 4) to the session you created (step 5). You can optionally pass a room code once the game is running.
**Why:** Adding a game to the session records that you played it, increments play counts, and enables voting and room monitoring. Use `game_id` from the pick response.
```bash
curl -X POST "http://localhost:5000/api/sessions/5/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"game_id": 1, "room_code": "ABCD"}'
```
Replace `5` with your session ID and `1` with the `game.id` from the pick response.
**Sample response (201 Created):**
```json
{
"id": 14,
"session_id": 5,
"game_id": 1,
"manually_added": 0,
"status": "playing",
"room_code": "ABCD",
"played_at": "2026-03-15T20:30:00.000Z",
"pack_name": "Jackbox Party Pack 7",
"title": "Quiplash 3",
"game_type": "Writing",
"min_players": 3,
"max_players": 8
}
```
---
## 7. Close the Session
When the game night is over, close the session. Any games still marked `playing` are automatically marked `played`.
**Why:** Closing the session finalizes it, frees the "active session" slot for the next night, and triggers any end-of-session webhooks or WebSocket events.
```bash
curl -X POST "http://localhost:5000/api/sessions/5/close" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Great session!"}'
```
Replace `5` with your session ID.
**Sample response (200 OK):**
```json
{
"id": 5,
"notes": "Great session!",
"is_active": 0,
"created_at": "2026-03-15T19:00:00.000Z",
"closed_at": "2026-03-15T23:30:00.000Z",
"games_played": 1
}
```
---
## Quick Reference
| Step | Endpoint | Auth |
|------|----------|------|
| 1 | `GET /health` | No |
| 2 | `POST /api/auth/login` | No |
| 3 | `GET /api/games?playerCount=6` | No |
| 4 | `POST /api/pick` | No |
| 5 | `POST /api/sessions` | Bearer |
| 6 | `POST /api/sessions/{id}/games` | Bearer |
| 7 | `POST /api/sessions/{id}/close` | Bearer |
---
## Full Copy-Paste Flow
```bash
# 1. Health check
curl http://localhost:5000/health
# 2. Get token (replace with your actual admin key)
TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"key": "your-admin-key"}' | jq -r '.token')
# 3. Browse games for 6 players
curl "http://localhost:5000/api/games?playerCount=6"
# 4. Pick a game for 6 players
PICK=$(curl -s -X POST http://localhost:5000/api/pick \
-H "Content-Type: application/json" \
-d '{"playerCount": 6}')
GAME_ID=$(echo $PICK | jq -r '.game.id')
# 5. Start session
SESSION=$(curl -s -X POST http://localhost:5000/api/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Friday game night"}')
SESSION_ID=$(echo $SESSION | jq -r '.id')
# 6. Add picked game to session
curl -X POST "http://localhost:5000/api/sessions/$SESSION_ID/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"game_id\": $GAME_ID, \"room_code\": \"ABCD\"}"
# 7. Close session when done
curl -X POST "http://localhost:5000/api/sessions/$SESSION_ID/close" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Great session!"}'
```
This assumes `jq` is installed for JSON parsing. Without it, extract IDs manually from the JSON responses.

View File

@@ -0,0 +1,287 @@
# Session Lifecycle Guide
This guide walks through the full lifecycle of a Jackbox gaming session—from creation through closing and deletion—with narrative explanations, behavior notes, and curl examples.
**Base URL:** `http://localhost:5000`
**Authentication:** All write operations require a Bearer token. Set `TOKEN` in your shell and use `-H "Authorization: Bearer $TOKEN"` in curl examples.
---
## 1. Creating a Session
Only **one active session** can exist at a time. If an active session already exists, you must close it before creating a new one.
Notes are optional; they help you remember what a session was for (e.g., "Friday game night", "Birthday party").
Creating a session triggers a **`session.started`** WebSocket event broadcast to all authenticated clients. See [Real-time updates via WebSocket](#9-real-time-updates-via-websocket) for details.
**Endpoint:** [POST /api/sessions](../endpoints/sessions.md#post-apisessions)
```bash
# Create a session with notes
curl -X POST "http://localhost:5000/api/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Friday game night"}'
# Create a session without notes (body can be empty)
curl -X POST "http://localhost:5000/api/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
**Response (201 Created):**
```json
{
"id": 5,
"notes": "Friday game night",
"is_active": 1,
"created_at": "2026-03-15T19:00:00.000Z",
"closed_at": null
}
```
If an active session already exists, you receive `400` with a message like `"An active session already exists. Please close it before creating a new one."` and an `activeSessionId` in the response.
---
## 2. Adding Games
You can add games in two ways: via the **picker** (weighted random selection) or **manually** by specifying a game ID.
### Via the Picker
First, use [POST /api/pick](../endpoints/picker.md#post-apipick) to select a game with filters and repeat avoidance. Then add that game to the session.
```bash
# 1. Pick a game (optionally filter by player count, session for repeat avoidance)
GAME=$(curl -s -X POST "http://localhost:5000/api/pick" \
-H "Content-Type: application/json" \
-d '{"playerCount": 6, "sessionId": 5}' | jq -r '.game.id')
# 2. Add the picked game to the session
curl -X POST "http://localhost:5000/api/sessions/5/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"game_id\": $GAME, \"manually_added\": false}"
```
### Manual Addition
Add a game directly by its `game_id` (from the games catalog):
```bash
curl -X POST "http://localhost:5000/api/sessions/5/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"game_id": 42, "manually_added": true, "room_code": "ABCD"}'
```
**Endpoint:** [POST /api/sessions/{id}/games](../endpoints/sessions.md#post-apisessionsidgames)
### Side Effects of Adding a Game
When you add a game to an active session, several things happen automatically:
1. **Previous `playing` games** are auto-transitioned to **`played`**. At most one game is `playing` at a time.
2. The game's **`play_count`** is incremented in the catalog.
3. The **`game.added`** webhook is fired (if you have webhooks configured) and a **`game.added`** WebSocket event is broadcast to session subscribers.
4. If you provide a **`room_code`**, the room monitor is **auto-started** for player count tracking.
Newly added games start with status **`playing`**.
---
## 3. Tracking Game Status
Each game in a session has a status: **`playing`**, **`played`**, or **`skipped`**.
| Status | Meaning |
|----------|-------------------------------------------|
| `playing`| Currently being played (at most one at a time) |
| `played` | Finished playing |
| `skipped`| Skipped (e.g., technical issues); stays skipped |
**Behavior:** When you change a game's status to **`playing`**, any other games with status `playing` are automatically set to **`played`**. Skipped games are never auto-transitioned; they remain `skipped`.
**Endpoint:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/status](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidstatus)
**Important:** In session game sub-routes, `sessionGameId` refers to **`session_games.id`** (the row in the `session_games` table), **not** `games.id`. When listing session games with `GET /api/sessions/{id}/games`, the `id` field in each object is the `session_games.id`.
```bash
# Mark a game as played (sessionGameId 14, not game_id)
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "played"}'
# Mark a game as playing (others playing → played)
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "playing"}'
# Mark a game as skipped
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "skipped"}'
```
---
## 4. Room Codes
Room codes are 4-character strings used by Jackbox games for lobby entry. Valid format: exactly 4 characters, uppercase letters (AZ) and digits (09) only. Example: `ABCD`, `XY9Z`.
A room code enables **room monitoring** for player count. You can set or update it when adding a game or via a dedicated PATCH endpoint.
**Endpoint:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/room-code](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidroom-code)
```bash
# Set room code when adding a game
curl -X POST "http://localhost:5000/api/sessions/5/games" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"game_id": 42, "room_code": "ABCD"}'
# Update room code later
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/room-code" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"room_code": "XY9Z"}'
```
---
## 5. Player Count Monitoring
For games with a room code, you can track how many players join. The room monitor polls the Jackbox lobby to detect player count changes.
- **Start monitoring:** [POST /api/sessions/{sessionId}/games/{sessionGameId}/start-player-check](../endpoints/sessions.md#post-apisessionssessionidgamessessiongameidstart-player-check)
- **Stop monitoring:** [POST /api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check](../endpoints/sessions.md#post-apisessionssessionidgamessessiongameidstop-player-check)
- **Manual update:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidplayer-count)
When the player count changes (via room monitor or manual update), a **`player-count.updated`** WebSocket event is broadcast to session subscribers.
```bash
# Start room monitor (game must have a room code)
curl -X POST "http://localhost:5000/api/sessions/5/games/14/start-player-check" \
-H "Authorization: Bearer $TOKEN"
# Manually set player count
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/player-count" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"player_count": 6}'
# Stop monitoring
curl -X POST "http://localhost:5000/api/sessions/5/games/14/stop-player-check" \
-H "Authorization: Bearer $TOKEN"
```
---
## 6. Closing Sessions
Closing a session marks it as inactive. The API:
1. Auto-finalizes all games with status **`playing`** to **`played`**
2. Sets `closed_at` and `is_active = 0`
3. Triggers a **`session.ended`** WebSocket broadcast to session subscribers
You can add or update session notes in the close request body.
**Endpoint:** [POST /api/sessions/{id}/close](../endpoints/sessions.md#post-apisessionsidclose)
```bash
curl -X POST "http://localhost:5000/api/sessions/5/close" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "Great session!"}'
```
**Response (200 OK):**
```json
{
"id": 5,
"notes": "Great session!",
"is_active": 0,
"created_at": "2026-03-15T19:00:00.000Z",
"closed_at": "2026-03-15T23:30:00.000Z",
"games_played": 4
}
```
You cannot add games to a closed session.
---
## 7. Exporting Session Data
Export a session in two formats: **JSON** (structured) or **TXT** (human-readable).
**Endpoint:** [GET /api/sessions/{id}/export](../endpoints/sessions.md#get-apisessionsidexport)
- **JSON** (`?format=json`): Includes `session`, `games`, and `chat_logs` as structured data. Useful for archival or integrations.
- **TXT** (default): Human-readable plaintext with headers and sections.
```bash
# Export as JSON
curl -o session-5.json "http://localhost:5000/api/sessions/5/export?format=json" \
-H "Authorization: Bearer $TOKEN"
# Export as TXT (default)
curl -o session-5.txt "http://localhost:5000/api/sessions/5/export" \
-H "Authorization: Bearer $TOKEN"
```
---
## 8. Deleting Sessions
Sessions must be **closed** before deletion. Active sessions cannot be deleted.
Deletion **cascades** to related data:
- `session_games` rows are deleted
- `chat_logs` rows are deleted
**Endpoint:** [DELETE /api/sessions/{id}](../endpoints/sessions.md#delete-apisessionsid)
```bash
curl -X DELETE "http://localhost:5000/api/sessions/5" \
-H "Authorization: Bearer $TOKEN"
```
**Response (200 OK):**
```json
{
"message": "Session deleted successfully",
"sessionId": 5
}
```
---
## 9. Real-time Updates via WebSocket
The API provides real-time updates over WebSocket for session events: `session.started`, `game.added`, `session.ended`, `player-count.updated`, and `vote.received`. Connect to `/api/sessions/live`, authenticate with your JWT, and subscribe to session IDs to receive these events without polling.
For connection setup, message types, and event payloads, see [WebSocket Protocol](../websocket.md).
---
## Quick Reference: sessionGameId vs game_id
| Context | ID meaning | Example |
|---------|------------|---------|
| `POST /api/sessions/{id}/games` body | `game_id` = catalog `games.id` | `{"game_id": 42}` |
| `GET /api/sessions/{id}/games` response `id` | `session_games.id` | Use `14` in sub-routes |
| `PATCH .../games/{sessionGameId}/status` | `sessionGameId` = `session_games.id` | `/sessions/5/games/14/status` |
When in doubt: session game sub-routes use **`session_games.id`**, not `games.id`.

View File

@@ -0,0 +1,218 @@
# Voting and Popularity
A narrative guide to how the Jackbox Game Picker handles community voting and game popularity. This system lets viewers and stream chat influence which games rise to the top—without directly controlling the random picker.
---
## 1. How Popularity Works
Every game has a **popularity score** stored in the database:
```
popularity_score = upvotes - downvotes
```
The score is computed from `upvotes` and `downvotes` and persisted per game. As votes accumulate across sessions, the score reflects community sentiment over time.
**Important:** Popularity is used for **rankings** (e.g., "top rated games" in stats) but **does not directly affect picker weights**. The random picker uses favor bias, not popularity, when selecting games.
---
## 2. Favor Bias vs Popularity
Two separate systems govern how games are treated:
| Aspect | **Favor Bias** | **Popularity** |
|--------|----------------|----------------|
| Who controls it | Admin (via API) | Community (via votes) |
| Values | `-1` (disfavor), `0` (neutral), `1` (favor) | `upvotes - downvotes` (unbounded) |
| Affects picker? | Yes — directly changes weights | No |
| Purpose | Manual curation; push/penalize specific games | Community sentiment; rankings |
**Favor bias** affects picker probability directly. Setting `favor_bias` to `1` on a game boosts its weight; `-1` reduces it. See [Games favor endpoint](../endpoints/games.md#patch-apigamesidfavor) and [Picker weighted selection](../endpoints/picker.md#weighted-selection).
**Popularity** is driven entirely by viewer votes. It surfaces in stats (e.g., `topRatedGames`) and session game lists, but the picker does not read it. These systems are independent.
---
## 3. Two Voting Mechanisms
The API supports two ways to record votes: batch chat import (after the fact) and live votes (real-time from bots).
### Chat Import (Batch, After-the-Fact)
Collect Twitch or YouTube chat logs containing `thisgame++` (upvote) and `thisgame--` (downvote), then submit them in bulk.
**Flow:**
1. Export chat logs with `username`, `message`, and `timestamp` for each message.
2. Filter or pass messages; the API parses `thisgame++` and `thisgame--` from the `message` field.
3. POST to `POST /api/sessions/{id}/chat-import` with a `chatData` array of `{ username, message, timestamp }`.
4. The API matches each votes timestamp to the game that was playing at that time (using `played_at` intervals).
5. Votes are deduplicated by SHA-256 hash of `username:message:timestamp`.
6. Response includes `votesByGame` breakdown and `debug` info (e.g., session timeline, vote matches).
See [Sessions chat-import endpoint](../endpoints/sessions.md#post-apisessionsidchat-import).
### Live Votes (Real-Time, from Bots)
A bot sends individual votes during the stream. Each vote is processed immediately.
**Flow:**
1. Bot detects `thisgame++` or `thisgame--` (or equivalent) in chat.
2. Bot sends `POST /api/votes/live` with `{ username, vote, timestamp }`.
3. `vote` must be `"up"` or `"down"`.
4. `timestamp` must be ISO 8601 (e.g., `2026-03-15T20:30:00Z`).
5. The API finds the active session and matches the vote timestamp to the game playing at that time.
6. **Deduplication:** Votes from the same username within 1 second are rejected with `409 Conflict`.
See [Votes live endpoint](../endpoints/votes.md#post-apivoteslive).
**Real-time tracking:** Live votes also broadcast a `vote.received` WebSocket event to all clients subscribed to the active session. This enables stream overlays and bots to react to votes in real-time without polling. See [WebSocket vote.received](../websocket.md#votereceived).
---
## 3b. Querying Vote Data
Two endpoints expose vote data for reading:
- **`GET /api/sessions/{id}/votes`** — Per-game vote breakdown for a session. Returns aggregated `upvotes`, `downvotes`, `net_score`, and `total_votes` per game. See [Sessions votes endpoint](../endpoints/sessions.md#get-apisessionsidvotes).
- **`GET /api/votes`** — Paginated global vote history with filtering by `session_id`, `game_id`, `username`, and `vote_type`. Returns individual vote records. See [Votes list endpoint](../endpoints/votes.md#get-apivotes).
---
## 4. Timestamp Matching Explained
Games in a session have a `played_at` timestamp. A votes timestamp determines which game it belongs to.
**Rule:** A vote belongs to the game whose `played_at` is the **most recent one before** the vote timestamp.
Example session timeline:
- Game A: `played_at` 20:00
- Game B: `played_at` 20:15
- Game C: `played_at` 20:30
- Vote at 20:10 → Game A (last `played_at` before 20:10)
- Vote at 20:20 → Game B
- Vote at 20:45 → Game C (last game in session; captures all votes after it started)
The **last game** in the session captures all votes that occur after its `played_at`.
---
## 5. How Stats Reflect Popularity
`GET /api/stats` returns aggregate statistics, including:
- **mostPlayedGames** — top 10 by `play_count` (games with `play_count` > 0).
- **topRatedGames** — top 10 by `popularity_score` (games with `popularity_score` > 0).
Both are limited to the top 10 and exclude games with score/count ≤ 0. See [Stats endpoint](../endpoints/stats.md).
---
## 6. Example Requests
### Chat Import
Import a batch of chat messages for session `5`:
```bash
curl -X POST "http://localhost:5000/api/sessions/5/chat-import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"chatData": [
{
"username": "viewer1",
"message": "thisgame++",
"timestamp": "2026-03-15T20:30:00Z"
},
{
"username": "viewer2",
"message": "thisgame--",
"timestamp": "2026-03-15T20:31:00Z"
},
{
"username": "viewer3",
"message": "thisgame++",
"timestamp": "2026-03-15T20:32:00Z"
}
]
}'
```
**Sample response (200 OK):**
```json
{
"message": "Chat log imported and processed successfully",
"messagesImported": 3,
"duplicatesSkipped": 0,
"votesProcessed": 3,
"votesByGame": {
"42": {
"title": "Quiplash 3",
"upvotes": 2,
"downvotes": 1
}
},
"debug": {
"sessionGamesTimeline": [
{
"title": "Quiplash 3",
"played_at": "2026-03-15T20:00:00.000Z",
"played_at_ms": 1742068800000
}
],
"voteMatches": []
}
}
```
### Live Vote
Submit a single live vote (requires active session):
```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"
}'
```
**Sample response (200 OK):**
```json
{
"success": true,
"message": "Vote recorded successfully",
"session": { "id": 3, "games_played": 5 },
"game": {
"id": 42,
"title": "Quiplash 3",
"upvotes": 11,
"downvotes": 2,
"popularity_score": 9
},
"vote": {
"username": "viewer123",
"type": "up",
"timestamp": "2026-03-15T20:30:00Z"
}
}
```
---
## Related Documentation
- [Sessions endpoints](../endpoints/sessions.md) — chat import, session games, `played_at`
- [Votes endpoints](../endpoints/votes.md) — live votes, deduplication, errors
- [Stats endpoints](../endpoints/stats.md) — `mostPlayedGames`, `topRatedGames`
- [Picker endpoints](../endpoints/picker.md) — weighted selection, favor bias (no popularity)
- [Games endpoints](../endpoints/games.md) — favor bias per game and pack

View File

@@ -0,0 +1,217 @@
# Webhooks and Events
A narrative guide to the Jackbox Game Picker event notification system: webhooks (HTTP callbacks) and WebSocket (persistent real-time connections). Both deliver event data about session and game activity.
---
## 1. Two Notification Systems
The API offers two complementary ways to receive event notifications:
| System | Model | Best for |
|--------|-------|----------|
| **Webhooks** | HTTP POST callbacks to your URL | Server-to-server, external integrations |
| **WebSocket** | Persistent bidirectional connection | Real-time UIs, dashboards, live tools |
Both systems emit the same kinds of events (e.g. `game.added`) but differ in how they deliver them.
---
## 2. When to Use Which
### Use Webhooks when:
- **Server-to-server** — Discord bots, Slack, logging pipelines, external APIs
- **Stateless** — Your endpoint receives a POST, processes it, and returns. No long-lived connection
- **Behind firewalls** — Your server can receive HTTP but may not hold open WebSocket connections
- **Async delivery** — Youre fine with HTTP round-trip latency and want delivery logged and auditable
### Use WebSocket when:
- **Real-time UI** — Dashboards, admin panels, live session viewers
- **Instant updates** — You need push-style notifications with minimal latency
- **Persistent connection** — Your app keeps a live connection and subscribes to specific sessions
- **Best-effort is fine** — WebSocket is push-only; theres no built-in delivery log for events
---
## 3. Webhook Setup
Webhooks are registered via the REST API. See [Webhooks endpoints](../endpoints/webhooks.md) for full CRUD details.
### Create a Webhook
`POST /api/webhooks` with:
- `name` — Display name (e.g. `"Discord Bot"`)
- `url` — Callback URL (must be a valid HTTP/HTTPS URL)
- `secret` — Shared secret for signing payloads (HMAC-SHA256)
- `events` — Array of event types that trigger this webhook (e.g. `["game.added"]`)
**Example:**
```bash
curl -X POST "http://localhost:5000/api/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Discord Bot",
"url": "https://my-server.com/webhooks/jackbox",
"secret": "mysecret123",
"events": ["game.added"]
}'
```
The `events` array defines which events fire this webhook. Currently, the codebase triggers webhooks for **`game.added`** when a game is added to a session. The `triggerWebhook` function in `backend/utils/webhooks.js` is invoked from `sessions.js` on that event.
### Update, Enable/Disable, Delete
- **Update:** `PATCH /api/webhooks/{id}` — Change `name`, `url`, `secret`, `events`, or `enabled`
- **Disable:** `PATCH /api/webhooks/{id}` with `"enabled": false` — Stops delivery without deleting config
- **Delete:** `DELETE /api/webhooks/{id}` — Removes webhook and its logs
---
## 4. Webhook Delivery
### How it works
When an event occurs (e.g. a game is added), the server:
1. Finds all enabled webhooks subscribed to that event
2. Sends an async HTTP POST to each webhook URL
3. Logs each delivery attempt in `webhook_logs` (status, error, payload)
### Payload format
Each POST body is JSON:
```json
{
"event": "game.added",
"timestamp": "2026-03-15T20:30:00.000Z",
"data": {
"session": { "id": 3, "is_active": true, "games_played": 2 },
"game": {
"id": 42,
"title": "Quiplash 3",
"pack_name": "Jackbox Party Pack 7",
"min_players": 3,
"max_players": 8,
"manually_added": false
}
}
}
```
Headers include:
- `Content-Type: application/json`
- `X-Webhook-Event: game.added`
- `X-Webhook-Signature: sha256=<hmac>` — Use your `secret` to verify the payload
### View delivery logs
`GET /api/webhooks/{id}/logs` returns recent delivery attempts (status, error message, payload).
### Test a webhook
`POST /api/webhooks/test/{id}` sends a dummy `game.added` event to the webhook URL. Delivery runs asynchronously; check logs for status.
---
## 5. WebSocket Events
The WebSocket server runs at `/api/sessions/live` on the same host and port as the HTTP API. See [WebSocket protocol](../websocket.md) for connection, authentication, and subscription details.
### Event types and audience
| Event | Broadcast to | Triggered by |
|-------|--------------|--------------|
| `session.started` | All authenticated clients | `POST /api/sessions` |
| `game.added` | Session subscribers | `POST /api/sessions/{id}/games` |
| `session.ended` | Session subscribers | `POST /api/sessions/{id}/close` |
| `player-count.updated` | Session subscribers | `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` |
| `vote.received` | Session subscribers | `POST /api/votes/live` (live votes only, not chat-import) |
`session.started` goes to every authenticated client. The others go only to clients that have subscribed to the relevant session via `{ "type": "subscribe", "sessionId": 3 }`.
### Envelope format
All events use this envelope:
```json
{
"type": "<event-type>",
"timestamp": "2026-03-15T20:30:00.000Z",
"data": { ... }
}
```
`data` contains event-specific fields (session, game, player count, etc.) as described in [WebSocket protocol](../websocket.md).
---
## 6. Comparison
| Feature | Webhooks | WebSocket |
|---------|----------|-----------|
| **Connection** | Stateless HTTP | Persistent |
| **Auth** | Secret in config | JWT per connection |
| **Events** | `game.added` | `session.started`, `game.added`, `session.ended`, `player-count.updated`, `vote.received` |
| **Latency** | Higher (HTTP round trip) | Lower (push) |
| **Reliability** | Logged, auditable | Best-effort |
---
## 7. Example: Discord Bot
Use a webhook to post game additions to a Discord channel. Youll need:
1. A webhook created in the Game Picker API pointing to your server
2. A small server that receives the webhook and forwards to Discords Incoming Webhook
**Webhook receiver (Node.js):**
```javascript
const crypto = require('crypto');
app.post('/webhooks/jackbox', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = JSON.stringify(req.body);
// Verify HMAC-SHA256 using your webhook secret
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
if (req.body.event === 'game.added') {
const { session, game } = req.body.data;
const discordPayload = {
content: `🎮 **${game.title}** added to session #${session.id} (${game.min_players}-${game.max_players} players)`
};
fetch(process.env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(discordPayload)
}).catch(err => console.error('Discord post failed:', err));
}
res.status(200).send('OK');
});
```
Register the Game Picker webhook with your servers URL (e.g. `https://my-bot.example.com/webhooks/jackbox`), set `events` to `["game.added"]`, and use the same `secret` in your servers `WEBHOOK_SECRET`.
---
## Cross-references
- **[Webhooks endpoints](../endpoints/webhooks.md)** — Full CRUD, request/response schemas, errors
- **[WebSocket protocol](../websocket.md)** — Connection, auth, subscriptions, event payloads

1799
docs/api/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

598
docs/api/websocket.md Normal file
View File

@@ -0,0 +1,598 @@
# WebSocket Protocol
## 1. Overview
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
- Receive notifications when sessions start, end, or when games are added
- Monitor Jackbox room state in real-time (lobby, player joins, game start/end)
- Track player counts automatically via shard connection
- Receive live vote updates (upvotes/downvotes) as viewers vote
- Avoid polling REST endpoints for session state changes
The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.
---
## 2. Connection Setup
**URL:** `ws://host:port/api/sessions/live`
- Use `ws://` for HTTP and `wss://` for HTTPS
- No query parameters are required
- Connection can be established without authentication (auth happens via a message after connect)
**JavaScript example:**
```javascript
const host = 'localhost';
const port = 5000;
const protocol = 'ws';
const ws = new WebSocket(`${protocol}://${host}:${port}/api/sessions/live`);
ws.onopen = () => {
console.log('Connected');
};
```
---
## 3. Authentication
Authentication is required for subscribing to sessions and for receiving most events. Send your JWT token in an `auth` message after connecting.
**Send (client → server):**
```json
{ "type": "auth", "token": "<jwt>" }
```
**Success response:**
```json
{ "type": "auth_success", "message": "Authenticated successfully" }
```
**Failure responses:**
```json
{ "type": "auth_error", "message": "Invalid or expired token" }
```
```json
{ "type": "auth_error", "message": "Token required" }
```
**JavaScript example:**
```javascript
// After opening the connection...
ws.send(JSON.stringify({
type: 'auth',
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_success') {
console.log('Authenticated');
} else if (msg.type === 'auth_error') {
console.error('Auth failed:', msg.message);
}
};
```
Obtain a JWT by calling `POST /api/auth/login` with your admin key.
---
## 4. Message Types — Client to Server
| Type | Required Fields | Description |
|-------------|-----------------|--------------------------------------|
| `auth` | `token` | Authenticate with a JWT |
| `subscribe` | `sessionId` | Subscribe to a session's events |
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
| `ping` | — | Heartbeat; server responds with `pong` |
### auth
```json
{ "type": "auth", "token": "<jwt>" }
```
### subscribe
Must be authenticated. You can subscribe to multiple sessions.
```json
{ "type": "subscribe", "sessionId": 3 }
```
### unsubscribe
Must be authenticated.
```json
{ "type": "unsubscribe", "sessionId": 3 }
```
### ping
```json
{ "type": "ping" }
```
---
## 5. Message Types — Server to Client
| Type | Description |
|---------------|------------------------------------------|
| `auth_success`| Authentication succeeded |
| `auth_error` | Authentication failed |
| `subscribed` | Successfully subscribed to a session |
| `unsubscribed`| Successfully unsubscribed from a session |
| `pong` | Response to client `ping` |
| `error` | General error (e.g., not authenticated) |
| `session.started` | New session created (broadcast to all authenticated clients) |
| `game.added` | Game added to a session (broadcast to subscribers) |
| `session.ended` | Session closed (broadcast to subscribers) |
| `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) |
| `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) |
| `lobby.updated` | Lobby state changed (broadcast to subscribers) |
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
| `game.ended` | Game finished (broadcast to subscribers) |
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
| `vote.received` | Live vote recorded (broadcast to subscribers) |
---
## 6. Event Reference
All server-sent events use this envelope:
```json
{
"type": "<event-type>",
"timestamp": "2026-03-15T20:30:00.000Z",
"data": { ... }
}
```
### session.started
- **Broadcast to:** All authenticated clients (not session-specific)
- **Triggered by:** `POST /api/sessions` (creating a new session)
**Data:**
```json
{
"session": {
"id": 3,
"is_active": 1,
"created_at": "2026-03-15T20:00:00",
"notes": "Friday game night"
}
}
```
### game.added
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/{id}/games` (adding a game)
**Data:**
```json
{
"session": {
"id": 3,
"is_active": true,
"games_played": 5
},
"game": {
"id": 42,
"title": "Quiplash 3",
"pack_name": "Jackbox Party Pack 7",
"min_players": 3,
"max_players": 8,
"manually_added": false,
"room_code": "ABCD"
}
}
```
### session.ended
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/sessions/{id}/close` (closing a session)
**Data:**
```json
{
"session": {
"id": 3,
"is_active": 0,
"games_played": 8
}
}
```
### room.connected
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 2,
"players": ["Alice", "Bob"],
"lobbyState": "CanStart",
"gameState": "Lobby"
}
```
### lobby.player-joined
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerName": "Charlie",
"playerCount": 3,
"players": ["Alice", "Bob", "Charlie"],
"maxPlayers": 8
}
```
### lobby.updated
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"lobbyState": "Countdown",
"gameCanStart": true,
"gameIsStarting": true,
"playerCount": 4
}
```
### game.started
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"maxPlayers": 8
}
```
### game.ended
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"]
}
```
### room.disconnected
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"reason": "room_closed",
"finalPlayerCount": 4
}
```
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
### game.status
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Periodic 20-second heartbeat from an active shard monitor. Also available on demand via `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`.
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"lobbyState": "CanStart",
"gameState": "Lobby",
"gameStarted": false,
"gameFinished": false,
"monitoring": true
}
```
### player-count.updated
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)
**Data:**
```json
{
"sessionId": "3",
"gameId": "7",
"playerCount": 6,
"status": "completed"
}
```
### vote.received
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import.
**Data:**
```json
{
"sessionId": 5,
"game": {
"id": 42,
"title": "Quiplash 3",
"pack_name": "Party Pack 7"
},
"vote": {
"username": "viewer123",
"type": "up",
"timestamp": "2026-03-15T20:29:55.000Z"
},
"totals": {
"upvotes": 14,
"downvotes": 3,
"popularity_score": 11
}
}
```
---
## 7. Error Handling
| Type | Message | When |
|--------------|----------------------------------------|-----------------------------------------|
| `error` | `Not authenticated` | subscribe/unsubscribe without auth |
| `error` | `Session ID required` | subscribe without `sessionId` |
| `error` | `Unknown message type: foo` | Unknown `type` in client message |
| `error` | `Invalid message format` | Unparseable or non-JSON message |
| `auth_error` | `Token required` | auth without token |
| `auth_error` | `Invalid or expired token` | auth with invalid/expired JWT |
---
## 8. Heartbeat and Timeout
- **Client → Server:** Send `{ "type": "ping" }` periodically
- **Server → Client:** Responds with `{ "type": "pong" }`
- **Timeout:** If no ping is received for **60 seconds**, the server terminates the connection
- **Server check:** The server checks for stale connections every **30 seconds**
Implement a heartbeat on the client to keep the connection alive:
```javascript
let pingInterval;
function startHeartbeat() {
pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // every 30 seconds
}
ws.onopen = () => {
startHeartbeat();
};
ws.onclose = () => {
clearInterval(pingInterval);
};
```
---
## 9. Reconnection
The server does **not** maintain state across disconnects. After reconnecting:
1. **Re-authenticate** with an `auth` message
2. **Re-subscribe** to any sessions you were tracking
Implement exponential backoff for reconnection attempts:
```javascript
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const baseDelay = 1000;
function connect() {
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
ws.onopen = () => {
reconnectAttempts = 0;
ws.send(JSON.stringify({ type: 'auth', token: jwt }));
// After auth_success, re-subscribe to sessions...
};
ws.onclose = () => {
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 60000);
reconnectAttempts++;
setTimeout(connect, delay);
}
};
}
connect();
```
---
## 10. Complete Example
Full session lifecycle from connect to disconnect:
```javascript
const JWT = 'your-jwt-token';
const WS_URL = 'ws://localhost:5000/api/sessions/live';
const ws = new WebSocket(WS_URL);
let pingInterval;
let subscribedSessions = new Set();
function send(msg) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
ws.onopen = () => {
console.log('Connected');
send({ type: 'auth', token: JWT });
pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
send({ type: 'ping' });
}
}, 30000);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'auth_success':
console.log('Authenticated');
send({ type: 'subscribe', sessionId: 3 });
break;
case 'auth_error':
console.error('Auth failed:', msg.message);
break;
case 'subscribed':
subscribedSessions.add(msg.sessionId);
console.log('Subscribed to session', msg.sessionId);
break;
case 'unsubscribed':
subscribedSessions.delete(msg.sessionId);
console.log('Unsubscribed from session', msg.sessionId);
break;
case 'pong':
// Heartbeat acknowledged
break;
case 'session.started':
console.log('New session:', msg.data.session);
break;
case 'game.added':
console.log('Game added:', msg.data.game.title, 'to session', msg.data.session.id);
break;
case 'session.ended':
console.log('Session ended:', msg.data.session.id);
subscribedSessions.delete(msg.data.session.id);
break;
case 'room.connected':
console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', '));
break;
case 'lobby.player-joined':
console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount);
break;
case 'lobby.updated':
console.log('Lobby:', msg.data.lobbyState);
break;
case 'game.started':
console.log('Game started with', msg.data.playerCount, 'players');
break;
case 'game.ended':
console.log('Game ended with', msg.data.playerCount, 'players');
break;
case 'room.disconnected':
console.log('Room disconnected:', msg.data.reason);
break;
case 'game.status':
console.log('Status heartbeat:', msg.data.roomCode, '- players:', msg.data.playerCount, '- state:', msg.data.gameState);
break;
case 'player-count.updated':
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
break;
case 'vote.received':
console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals);
break;
case 'error':
case 'auth_error':
console.error('Error:', msg.message);
break;
default:
console.log('Unknown message:', msg);
}
};
ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => {
clearInterval(pingInterval);
console.log('Disconnected');
};
// Later: unsubscribe and close
function disconnect() {
subscribedSessions.forEach((sessionId) => {
send({ type: 'unsubscribe', sessionId });
});
ws.close();
}
```

View File

@@ -85,13 +85,53 @@ ws://localhost:5000/api/sessions/live
}
}
// Vote received (live votes only, not chat-import)
{
"type": "vote.received",
"timestamp": "2025-11-01T...",
"data": {
"sessionId": 123,
"game": { "id": 42, "title": "Quiplash 3", "pack_name": "Party Pack 7" },
"vote": { "username": "viewer123", "type": "up", "timestamp": "2025-11-01T20:30:00Z" },
"totals": { "upvotes": 14, "downvotes": 3, "popularity_score": 11 }
}
}
// Pong
{ "type": "pong" }
```
---
## Live Voting
## Votes
### Get Vote History
```http
GET /api/votes?session_id=5&game_id=42&username=viewer123&vote_type=up&page=1&limit=50
```
**Response (200)**:
```json
{
"votes": [
{
"id": 891,
"session_id": 5,
"game_id": 42,
"game_title": "Quiplash 3",
"pack_name": "Party Pack 7",
"username": "viewer123",
"vote_type": "up",
"timestamp": "2025-11-01T20:30:00.000Z",
"created_at": "2025-11-01T20:30:01.000Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 237, "total_pages": 5 }
}
```
All query parameters are optional. No authentication required.
### Submit Live Vote

View File

@@ -693,13 +693,26 @@ curl -X GET "http://localhost:5000/api/webhooks/1/logs" \
- `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`.
- `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required).
- `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session.
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member. Sent to clients subscribed to that session. This confirms the room code is valid and the game is being monitored.
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds via the room monitor. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member for player count tracking. Sent to clients subscribed to that session.
- `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session.
- `vote.received` - Triggered when a live vote is recorded via `POST /api/votes/live`. Sent to clients subscribed to that session. Includes voter username, vote direction, game info, and updated global vote totals. Does **not** fire for chat-import votes.
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added` and `session.ended` events for it.
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added`, `vote.received`, and `session.ended` events for it.
More events may be added in the future (e.g., `vote.recorded`).
### Event Lifecycle (for a game with room code)
When a game is added with a room code, events fire in this order:
1. **`game.added`** — Game added to the session (immediate).
2. **`game.started`** — Jackbox room becomes locked, gameplay has begun. Detected by a room monitor that polls the Jackbox REST API every 10 seconds. This is independent of the player count system.
3. **`audience.joined`** — The player count bot successfully joined the Jackbox room as an audience member (seconds after `game.started`).
4. **`player-count.updated`** (status: `checking`) — Player count data received from the game's WebSocket traffic (ongoing).
5. **`player-count.updated`** (status: `completed`) — Game ended, final player count confirmed.
Room monitoring and player counting are separate systems. The room monitor (`room-monitor.js`) handles steps 1-2 and then hands off to the player count checker (`player-count-checker.js`) for steps 3-5.
More events may be added in the future.
---

1046
docs/jackbox-ecast-api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
# API Documentation Design
**Date:** 2026-03-15
**Status:** Approved
## Goal
Create comprehensive, accurate API documentation for the Jackbox Game Picker by reading the source code directly — not relying on existing docs which may be stale or incorrect. The documentation serves both internal maintainers and external integrators (bot developers, extension authors, etc.).
## Scope
- All 41 REST/HTTP endpoints across 7 route groups (Auth, Games, Sessions, Picker, Stats, Votes, Webhooks) plus the health check
- WebSocket protocol at `/api/sessions/live` (auth, subscriptions, event broadcasting)
- Does NOT cover: Chrome extension internals, deployment/Docker setup, frontend
## Approach
**OpenAPI-first with generated Markdown** (Approach A from brainstorming).
- `openapi.yaml` (OpenAPI 3.1) is the single source of truth for REST endpoints
- Human-readable Markdown endpoint docs are derived from the spec and enriched with guide-style prose, curl examples, and workflow explanations
- WebSocket protocol documented separately in Markdown (outside OpenAPI's scope)
- Existing `docs/` files archived to `docs/archive/`
## File Structure
```
docs/
├── archive/ # Old docs preserved here
│ ├── API_QUICK_REFERENCE.md
│ ├── BOT_INTEGRATION.md
│ ├── SESSION_END_QUICK_START.md
│ ├── SESSION_END_WEBSOCKET.md
│ ├── SESSION_START_WEBSOCKET.md
│ ├── WEBSOCKET_FLOW_DIAGRAM.md
│ ├── WEBSOCKET_SUBSCRIPTION_GUIDE.md
│ ├── WEBSOCKET_TESTING.md
│ └── todos.md
├── api/
│ ├── openapi.yaml # OpenAPI 3.1 spec (source of truth)
│ ├── README.md # API overview, auth, base URL, error conventions
│ ├── endpoints/
│ │ ├── auth.md
│ │ ├── games.md
│ │ ├── sessions.md
│ │ ├── picker.md
│ │ ├── stats.md
│ │ ├── votes.md
│ │ └── webhooks.md
│ ├── websocket.md # WebSocket protocol documentation
│ └── guides/
│ ├── getting-started.md # Quick start: auth, pick a game, run a session
│ ├── session-lifecycle.md # Sessions end-to-end
│ ├── voting-and-popularity.md # Chat import, live votes, popularity scoring
│ └── webhooks-and-events.md # Webhooks + WS event system
└── plans/
```
## OpenAPI Spec Design
### Info & Servers
- Title: "Jackbox Game Picker API"
- Servers: local dev (`http://localhost:5000`), Docker proxy (`http://localhost:3000/api`)
### Security
- `bearerAuth` scheme (JWT via `Authorization: Bearer <token>`)
- Applied per-operation; public endpoints explicitly marked
### Tags
Auth, Games, Sessions, Picker, Stats, Votes, Webhooks
### Schemas (components)
- `Game`, `Session`, `SessionGame`, `Pack`, `PackMeta`
- `Webhook`, `WebhookLog`
- `ChatMessage`, `LiveVote`
- `Error` (reusable error response)
- Enums: `status` (playing/played/skipped), `vote_type` (up/down), `favor_bias` (-1/0/1), `drawing` (only/exclude), `length` (short/medium/long)
### Per-operation
Each path operation includes: `operationId`, `summary`, `description`, `parameters`, `requestBody`, `responses` (success + all documented error codes)
## Markdown Endpoint Template
Each file in `docs/api/endpoints/` follows:
1. **Header:** Resource overview, what it represents, common use cases
2. **Summary table:** Method | Path | Auth | Description
3. **Per-endpoint sections:**
- Description and when to use it
- Authentication requirement
- Parameters table (Name | In | Type | Required | Description)
- Request body (JSON schema with field descriptions)
- Success response (JSON example with annotations)
- Error responses table (Status | Body | When)
- curl example + sample response
## WebSocket Documentation Structure
`docs/api/websocket.md` covers:
- Connection URL and setup
- Authentication flow (send `auth` message with JWT)
- Client-to-server message types: `auth`, `subscribe`, `unsubscribe`, `ping`
- Server-to-client message types: `auth_success`, `subscribed`, `unsubscribed`, `pong`, `session.started`, `game.added`, `session.ended`, `player-count.updated`, `error`, `auth_error`
- Subscription model (per-session)
- Event payloads with full JSON examples
- Heartbeat/timeout (60s) and reconnection guidance
- Complete session lifecycle example
## Guide Documents
Each guide uses narrative prose connecting endpoints into workflows:
- **getting-started.md:** Authenticate, browse games, pick a game, start a session — minimum viable integration path
- **session-lifecycle.md:** Create session → add games → track status → room codes → player counts → close session
- **voting-and-popularity.md:** How `popularity_score`, `upvotes`, `downvotes` work; chat import flow; live vote endpoint; how voting affects the picker
- **webhooks-and-events.md:** Create/manage webhooks, event types, delivery logs, relationship between webhook events and WebSocket events
## Maintenance Strategy
- `openapi.yaml` is the source of truth for REST endpoints
- When endpoints change: update spec first, then update Markdown
- WebSocket and guide docs are maintained manually
- No build-time generation tooling — Markdown committed directly
## Validation Plan
After writing, cross-reference:
1. Every route file in `backend/routes/` against the spec — no endpoints missed
2. Request/response shapes against database schema (`backend/database.js`) and route handlers
3. Auth requirements against middleware usage in each route

View File

@@ -0,0 +1,768 @@
# API Documentation Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Create comprehensive, accurate API documentation from source code — OpenAPI 3.1 spec as source of truth, plus human-readable Markdown with examples and guide-style prose.
**Architecture:** OpenAPI YAML spec covers all 41 REST endpoints. Separate Markdown files per route group with curl examples and response samples. WebSocket protocol documented in dedicated Markdown. Guide files connect endpoints into workflows.
**Tech Stack:** OpenAPI 3.1 YAML, Markdown, curl for examples
---
### Task 1: Archive existing docs and create directory structure
**Files:**
- Move: `docs/*.md``docs/archive/`
- Create directories: `docs/api/`, `docs/api/endpoints/`, `docs/api/guides/`
**Step 1: Create the archive directory**
```bash
mkdir -p docs/archive docs/api/endpoints docs/api/guides
```
**Step 2: Move existing docs to archive**
```bash
mv docs/API_QUICK_REFERENCE.md docs/archive/
mv docs/BOT_INTEGRATION.md docs/archive/
mv docs/SESSION_END_QUICK_START.md docs/archive/
mv docs/SESSION_END_WEBSOCKET.md docs/archive/
mv docs/SESSION_START_WEBSOCKET.md docs/archive/
mv docs/WEBSOCKET_FLOW_DIAGRAM.md docs/archive/
mv docs/WEBSOCKET_SUBSCRIPTION_GUIDE.md docs/archive/
mv docs/WEBSOCKET_TESTING.md docs/archive/
mv docs/todos.md docs/archive/
```
**Step 3: Verify structure**
```bash
ls -R docs/
```
Expected: `archive/` with old files, `api/endpoints/` and `api/guides/` empty, `plans/` with design docs.
**Step 4: Commit**
```bash
git add docs/
git commit -m "docs: archive old documentation, create new docs structure"
```
---
### Task 2: Write OpenAPI spec — info, servers, security, and component schemas
**Files:**
- Create: `docs/api/openapi.yaml`
**Step 1: Write the OpenAPI header, servers, security schemes, and all reusable component schemas**
Write `docs/api/openapi.yaml` with:
```yaml
openapi: 3.1.0
info:
title: Jackbox Game Picker API
description: API for managing Jackbox Party Pack games, sessions, voting, and integrations.
version: "1.0"
license:
name: MIT
servers:
- url: http://localhost:5000
description: Local development (backend direct)
- url: http://localhost:3000/api
description: Docker Compose (via Vite/Nginx proxy)
security: []
tags:
- name: Auth
description: Authentication endpoints
- name: Games
description: Game management and filtering
- name: Sessions
description: Session lifecycle and game tracking
- name: Picker
description: Weighted random game selection
- name: Stats
description: Aggregate statistics
- name: Votes
description: Real-time popularity voting
- name: Webhooks
description: Webhook management for external integrations
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
JWT token obtained from POST /api/auth/login.
Pass as `Authorization: Bearer <token>`. Tokens expire after 24 hours.
schemas:
Error:
type: object
properties:
error:
type: string
required:
- error
Game:
type: object
properties:
id:
type: integer
pack_name:
type: string
title:
type: string
min_players:
type: integer
max_players:
type: integer
length_minutes:
type: integer
nullable: true
has_audience:
type: integer
enum: [0, 1]
family_friendly:
type: integer
enum: [0, 1]
game_type:
type: string
nullable: true
secondary_type:
type: string
nullable: true
play_count:
type: integer
popularity_score:
type: integer
upvotes:
type: integer
downvotes:
type: integer
enabled:
type: integer
enum: [0, 1]
favor_bias:
type: integer
enum: [-1, 0, 1]
created_at:
type: string
format: date-time
Session:
type: object
properties:
id:
type: integer
created_at:
type: string
format: date-time
closed_at:
type: string
format: date-time
nullable: true
is_active:
type: integer
enum: [0, 1]
notes:
type: string
nullable: true
games_played:
type: integer
SessionGame:
type: object
properties:
id:
type: integer
session_id:
type: integer
game_id:
type: integer
played_at:
type: string
format: date-time
manually_added:
type: integer
enum: [0, 1]
status:
type: string
enum: [playing, played, skipped]
room_code:
type: string
nullable: true
player_count:
type: integer
nullable: true
player_count_check_status:
type: string
nullable: true
pack_name:
type: string
title:
type: string
game_type:
type: string
nullable: true
min_players:
type: integer
max_players:
type: integer
popularity_score:
type: integer
upvotes:
type: integer
downvotes:
type: integer
Pack:
type: object
properties:
id:
type: integer
name:
type: string
favor_bias:
type: integer
enum: [-1, 0, 1]
created_at:
type: string
format: date-time
PackMeta:
type: object
properties:
name:
type: string
total_count:
type: integer
enabled_count:
type: integer
total_plays:
type: integer
Webhook:
type: object
properties:
id:
type: integer
name:
type: string
url:
type: string
format: uri
events:
type: array
items:
type: string
enabled:
type: boolean
created_at:
type: string
format: date-time
WebhookLog:
type: object
properties:
id:
type: integer
webhook_id:
type: integer
event_type:
type: string
payload:
type: object
response_status:
type: integer
nullable: true
error_message:
type: string
nullable: true
created_at:
type: string
format: date-time
```
**Step 2: Validate YAML syntax**
```bash
node -e "const fs=require('fs'); const y=require('yaml'); y.parse(fs.readFileSync('docs/api/openapi.yaml','utf8')); console.log('Valid YAML')"
```
If `yaml` module not available, use: `npx -y yaml-cli docs/api/openapi.yaml` or manually verify structure.
**Step 3: Commit**
```bash
git add docs/api/openapi.yaml
git commit -m "docs: add OpenAPI spec with schemas and security definitions"
```
---
### Task 3: Write OpenAPI paths — Auth and Games endpoints
**Files:**
- Modify: `docs/api/openapi.yaml`
**Step 1: Add `paths` section with Auth endpoints**
Source: `backend/routes/auth.js` — 2 endpoints.
Add paths for:
- `POST /api/auth/login` — Body: `{ key }`, responses: 200 (token+message+expiresIn), 400 (missing key), 401 (invalid key)
- `POST /api/auth/verify` — Security: bearerAuth, responses: 200 (`{ valid, user: { role, timestamp } }`)
**Step 2: Add Games endpoints**
Source: `backend/routes/games.js` — 13 endpoints.
Add paths for:
- `GET /api/games` — Query params: `enabled`, `minPlayers`, `maxPlayers`, `playerCount`, `drawing` (only/exclude), `length` (short/medium/long), `familyFriendly`, `pack`. Response: array of Game.
- `GET /api/games/packs` — Response: array of Pack.
- `GET /api/games/meta/packs` — Response: array of PackMeta.
- `GET /api/games/export/csv` — Security: bearerAuth. Response: CSV file (text/csv).
- `PATCH /api/games/packs/{name}/favor` — Security: bearerAuth. Body: `{ favor_bias }` (-1/0/1). Response: `{ message, favor_bias }`. Error 400 for invalid value.
- `GET /api/games/{id}` — Response: Game or 404.
- `POST /api/games` — Security: bearerAuth. Body: `{ pack_name, title, min_players, max_players, length_minutes?, has_audience?, family_friendly?, game_type?, secondary_type? }`. Response 201: Game. Error 400: missing fields.
- `PUT /api/games/{id}` — Security: bearerAuth. Body: same fields (all optional). Response: Game or 404.
- `DELETE /api/games/{id}` — Security: bearerAuth. Response: `{ message }` or 404.
- `PATCH /api/games/{id}/toggle` — Security: bearerAuth. Response: Game (with toggled enabled) or 404.
- `PATCH /api/games/packs/{name}/toggle` — Security: bearerAuth. Body: `{ enabled }`. Response: `{ message, gamesAffected }`. Error 400: missing enabled.
- `POST /api/games/import/csv` — Security: bearerAuth. Body: `{ csvData, mode }` (mode: "append" or "replace"). Response: `{ message, count, mode }`. Error 400: missing csvData. CSV columns: Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type.
- `PATCH /api/games/{id}/favor` — Security: bearerAuth. Body: `{ favor_bias }` (-1/0/1). Response: `{ message, favor_bias }`. Error 400/404.
**Step 3: Validate YAML syntax**
Same validation command as Task 2 Step 2.
**Step 4: Commit**
```bash
git add docs/api/openapi.yaml
git commit -m "docs: add Auth and Games endpoint paths to OpenAPI spec"
```
---
### Task 4: Write OpenAPI paths — Sessions, Picker, Stats, Votes, Webhooks
**Files:**
- Modify: `docs/api/openapi.yaml`
**Step 1: Add Sessions endpoints (15 endpoints)**
Source: `backend/routes/sessions.js`
Add paths for:
- `GET /api/sessions` — Response: array of Session (with games_played count).
- `GET /api/sessions/active` — Response: Session object or `{ session: null, message }`.
- `GET /api/sessions/{id}` — Response: Session or 404.
- `POST /api/sessions` — Security: bearerAuth. Body: `{ notes? }`. Response 201: Session. Error 400: active session already exists (`{ error, activeSessionId }`). Triggers WebSocket `session.started` broadcast.
- `POST /api/sessions/{id}/close` — Security: bearerAuth. Body: `{ notes? }`. Response: closed Session (with games_played). Error 404/400 (already closed). Sets all 'playing' games to 'played'. Triggers WebSocket `session.ended`.
- `DELETE /api/sessions/{id}` — Security: bearerAuth. Response: `{ message, sessionId }`. Error 404/400 (cannot delete active). Cascades: deletes chat_logs and session_games.
- `GET /api/sessions/{id}/games` — Response: array of SessionGame (joined with game data).
- `POST /api/sessions/{id}/games` — Security: bearerAuth. Body: `{ game_id, manually_added?, room_code? }`. Response 201: SessionGame. Error 400 (closed session, missing game_id), 404 (session/game not found). Side effects: increments play_count, sets previous 'playing' games to 'played', triggers `game.added` webhook + WebSocket, auto-starts room monitor if room_code provided.
- `POST /api/sessions/{id}/chat-import` — Security: bearerAuth. Body: `{ chatData: [{ username, message, timestamp }] }`. Response: `{ message, messagesImported, duplicatesSkipped, votesProcessed, votesByGame, debug }`. Vote patterns: "thisgame++" = upvote, "thisgame--" = downvote. Matches votes to games by timestamp intervals.
- `PATCH /api/sessions/{sessionId}/games/{gameId}/status` — Security: bearerAuth. Body: `{ status }` (playing/played/skipped). Response: `{ message, status }`. Error 400/404. If setting to 'playing', auto-sets other playing games to 'played'.
- `DELETE /api/sessions/{sessionId}/games/{gameId}` — Security: bearerAuth. Response: `{ message }`. Error 404. Stops room monitor/player count check.
- `PATCH /api/sessions/{sessionId}/games/{gameId}/room-code` — Security: bearerAuth. Body: `{ room_code }` (exactly 4 chars, A-Z0-9). Response: SessionGame. Error 400 (invalid format)/404.
- `GET /api/sessions/{id}/export` — Security: bearerAuth. Query: `format` ("json" or "txt", default "txt"). Response: file download (application/json or text/plain).
- `POST /api/sessions/{sessionId}/games/{gameId}/start-player-check` — Security: bearerAuth. Response: `{ message, status: "monitoring" }`. Error 400 (no room code)/404.
- `POST /api/sessions/{sessionId}/games/{gameId}/stop-player-check` — Security: bearerAuth. Response: `{ message, status: "stopped" }`.
- `PATCH /api/sessions/{sessionId}/games/{gameId}/player-count` — Security: bearerAuth. Body: `{ player_count }` (non-negative integer). Response: `{ message, player_count }`. Error 400/404. Triggers WebSocket `player-count.updated`.
**Step 2: Add Picker endpoint**
Source: `backend/routes/picker.js` — mounted at `/api` (not `/api/picker`).
- `POST /api/pick` — No auth. Body: `{ playerCount?, drawing?, length?, familyFriendly?, sessionId?, excludePlayed? }`. Response 200: `{ game: Game, poolSize, totalEnabled }`. Response 404: `{ error, suggestion, recentlyPlayed? }`. Bias: game favor_bias 1=3x, -1=0.2x; pack favor_bias 1=2x, -1=0.3x. Repeat avoidance: excludes last 2 played games by default, or all played if excludePlayed=true.
**Step 3: Add Stats endpoint**
Source: `backend/routes/stats.js`
- `GET /api/stats` — No auth. Response: `{ games: { count }, gamesEnabled: { count }, packs: { count }, sessions: { count }, activeSessions: { count }, totalGamesPlayed: { count }, mostPlayedGames: [...], topRatedGames: [...] }`. The game arrays include: id, title, pack_name, play_count, popularity_score, upvotes, downvotes. Limited to top 10.
**Step 4: Add Votes endpoint**
Source: `backend/routes/votes.js`
- `POST /api/votes/live` — Security: bearerAuth. Body: `{ username, vote, timestamp }` where vote is "up" or "down", timestamp is ISO 8601. Response 200: `{ success, message, session: { id, games_played }, game: { id, title, upvotes, downvotes, popularity_score }, vote: { username, type, timestamp } }`. Error 400 (missing fields, invalid vote/timestamp), 404 (no active session, no games, vote doesn't match a game), 409 (duplicate within 1 second).
**Step 5: Add Webhooks endpoints (7 endpoints)**
Source: `backend/routes/webhooks.js`
- `GET /api/webhooks` — Security: bearerAuth. Response: array of Webhook (events parsed from JSON, enabled as boolean).
- `GET /api/webhooks/{id}` — Security: bearerAuth. Response: Webhook or 404.
- `POST /api/webhooks` — Security: bearerAuth. Body: `{ name, url, secret, events }` where events is string array. Response 201: Webhook + `{ message }`. Error 400: missing fields, invalid URL, events not array.
- `PATCH /api/webhooks/{id}` — Security: bearerAuth. Body: `{ name?, url?, secret?, events?, enabled? }`. Response: Webhook + `{ message }`. Error 400 (no fields, invalid URL, events not array)/404.
- `DELETE /api/webhooks/{id}` — Security: bearerAuth. Response: `{ message, webhookId }`. Error 404.
- `POST /api/webhooks/test/{id}` — Security: bearerAuth. Sends test `game.added` payload. Response: `{ message, note }`. Error 404.
- `GET /api/webhooks/{id}/logs` — Security: bearerAuth. Query: `limit` (default 50). Response: array of WebhookLog (payload parsed from JSON).
**Step 6: Add Health endpoint**
- `GET /health` — No auth. Response: `{ status: "ok", message: "Jackbox Game Picker API is running" }`.
**Step 7: Validate YAML syntax**
Same validation as before.
**Step 8: Commit**
```bash
git add docs/api/openapi.yaml
git commit -m "docs: complete all OpenAPI endpoint paths"
```
---
### Task 5: Write API README
**Files:**
- Create: `docs/api/README.md`
**Step 1: Write the API overview document**
Content should include:
- **Overview:** What the API does (manage Jackbox games, run sessions, track popularity, pick games with weighted randomness)
- **Base URL:** `http://localhost:5000` (direct) or `http://localhost:3000/api` (Docker proxy). All REST endpoints prefixed with `/api/` except `/health`.
- **Authentication:** POST to `/api/auth/login` with admin key → receive JWT. Include as `Authorization: Bearer <token>`. Tokens expire in 24 hours. Public endpoints (GET games, GET sessions, GET stats, POST pick, GET health) don't require auth. All write operations require auth.
- **Request/Response format:** JSON request/response. `Content-Type: application/json`. Exceptions: CSV export returns `text/csv`, session export can return `text/plain`.
- **Error handling:** All errors return `{ "error": "message" }`. HTTP status codes: 400 (bad request/validation), 401 (no token), 403 (invalid/expired token), 404 (not found), 409 (conflict/duplicate), 500 (server error).
- **Boolean fields:** SQLite stores booleans as integers (0/1). In request bodies, pass JS booleans; the API converts. In responses, expect 0/1 except for Webhook.enabled which returns a JS boolean.
- **Pagination:** No pagination — all list endpoints return full result sets.
- **Quick reference table:** All 41 endpoints in a single table: Method | Path | Auth | Description
- **Links:** to endpoint docs, WebSocket docs, and guides
**Step 2: Commit**
```bash
git add docs/api/README.md
git commit -m "docs: add API README with overview, auth, and quick reference"
```
---
### Task 6: Write endpoint docs — Auth and Games
**Files:**
- Create: `docs/api/endpoints/auth.md`
- Create: `docs/api/endpoints/games.md`
**Step 1: Write auth.md**
Cover the 2 auth endpoints with the template from the design doc. Include:
- Overview: simple admin-key authentication. One role (admin). No user management.
- Endpoint table
- For each endpoint: description, auth, parameters, request body, response, errors, curl example
- curl examples must use realistic sample data
**Step 2: Write games.md**
Cover all 13 games endpoints with the template. Important details to include from source code:
- GET /api/games filter behavior: `drawing=only` matches `game_type='Drawing'`, `drawing=exclude` excludes Drawing. `length=short` is ≤15min (including NULL), `medium` is 16-25min, `long` is >25min. Results ordered by pack_name, title.
- POST /api/games requires: pack_name, title, min_players, max_players. Optional: length_minutes, has_audience, family_friendly, game_type, secondary_type.
- PUT /api/games/:id uses COALESCE for most fields (only updates what's provided), but length_minutes, game_type, secondary_type accept explicit null.
- CSV import expects columns: Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type. Mode "replace" deletes ALL existing games first.
- Favor bias: 1=favor, -1=disfavor, 0=neutral. Applies to picker weighting.
**Step 3: Commit**
```bash
git add docs/api/endpoints/auth.md docs/api/endpoints/games.md
git commit -m "docs: add Auth and Games endpoint documentation"
```
---
### Task 7: Write endpoint docs — Sessions
**Files:**
- Create: `docs/api/endpoints/sessions.md`
**Step 1: Write sessions.md**
Cover all 15 sessions endpoints. Important details from source code:
- Only one active session at a time. Creating a session when one exists returns 400 with activeSessionId.
- Closing a session auto-sets all 'playing' games to 'played'.
- Cannot delete active sessions — must close first. Delete cascades chat_logs and session_games.
- Adding a game to session: auto-sets previous 'playing' games to 'played' (skipped games stay skipped), increments game play_count, triggers `game.added` webhook + WebSocket, auto-starts room monitor if room_code provided.
- Chat import: matches vote timestamps to games using interval logic (vote belongs to game whose played_at is most recent before vote timestamp). Deduplicates by SHA-256 hash of `username:message:timestamp`.
- Status update to 'playing' auto-sets other 'playing' games to 'played'.
- Room code: exactly 4 chars, uppercase A-Z and 0-9 only. Regex: `/^[A-Z0-9]{4}$/`.
- Export formats: JSON (structured with session+games+chat_logs) and TXT (human-readable plaintext).
- Player count: body is `{ player_count }`, must be non-negative integer. Sets `player_count_check_status` to 'completed'. Broadcasts `player-count.updated` via WebSocket.
- The `gameId` parameter in session game sub-routes refers to `session_games.id`, NOT `games.id`.
**Step 2: Commit**
```bash
git add docs/api/endpoints/sessions.md
git commit -m "docs: add Sessions endpoint documentation"
```
---
### Task 8: Write endpoint docs — Picker, Stats, Votes, Webhooks
**Files:**
- Create: `docs/api/endpoints/picker.md`
- Create: `docs/api/endpoints/stats.md`
- Create: `docs/api/endpoints/votes.md`
- Create: `docs/api/endpoints/webhooks.md`
**Step 1: Write picker.md**
Key details from source code:
- Filters enabled games only (enabled=1)
- Weighted random: game favor_bias 1 = 3x weight, -1 = 0.2x weight. Pack favor_bias 1 = 2x weight, -1 = 0.3x weight. Biases multiply.
- Repeat avoidance: with sessionId, excludes last 2 games by default. With excludePlayed=true, excludes ALL games played in session.
- 404 when no games match filters (with suggestion), or when all eligible games have been played.
**Step 2: Write stats.md**
Key details: single endpoint, no auth, returns aggregate counts and top-10 lists (most played, top rated). mostPlayedGames sorted by play_count DESC, topRatedGames sorted by popularity_score DESC, both only include games with > 0 in respective metric.
**Step 3: Write votes.md**
Key details:
- Requires auth. Body: `{ username, vote, timestamp }`.
- `vote` must be "up" or "down". `timestamp` must be valid ISO 8601.
- Automatically matches vote to the correct game in the active session using timestamp interval logic.
- Deduplication: rejects votes from same username within 1 second (409).
- Updates game upvotes/downvotes/popularity_score atomically in a transaction.
**Step 4: Write webhooks.md**
Key details:
- All endpoints require auth.
- Events stored as JSON string in DB, returned as parsed array.
- `enabled` stored as 0/1 in DB, returned as JS boolean.
- Secret is never returned in GET responses (excluded from SELECT).
- Test sends a `game.added` event with dummy data.
- Logs include parsed payload, limited by `limit` query param (default 50).
**Step 5: Commit**
```bash
git add docs/api/endpoints/picker.md docs/api/endpoints/stats.md docs/api/endpoints/votes.md docs/api/endpoints/webhooks.md
git commit -m "docs: add Picker, Stats, Votes, and Webhooks endpoint documentation"
```
---
### Task 9: Write WebSocket documentation
**Files:**
- Create: `docs/api/websocket.md`
**Step 1: Write websocket.md**
Cover the full WebSocket protocol from `backend/utils/websocket-manager.js`:
- **Connection:** `ws://host:port/api/sessions/live`. No query params needed. Connection established without auth.
- **Authentication:** Send `{ "type": "auth", "token": "<jwt>" }`. Server responds with `{ "type": "auth_success", "message": "Authenticated successfully" }` or `{ "type": "auth_error", "message": "..." }`. Auth required for subscribe/unsubscribe.
- **Subscription model:** Subscribe to a specific session's events with `{ "type": "subscribe", "sessionId": <number> }`. Response: `{ "type": "subscribed", "sessionId": <number>, "message": "..." }`. Can subscribe to multiple sessions. Unsubscribe with `{ "type": "unsubscribe", "sessionId": <number> }`.
- **Heartbeat:** Client sends `{ "type": "ping" }`, server responds `{ "type": "pong" }`. Timeout: 60 seconds since last ping — server terminates connection. Heartbeat check runs every 30 seconds.
- **Events (server → client):**
- `session.started` — broadcast to ALL authenticated clients (not session-specific). Data: `{ session: { id, is_active, created_at, notes } }`. Triggered when `POST /api/sessions` creates a new session.
- `game.added` — broadcast to session subscribers. Data: `{ session: { id, is_active, games_played }, game: { id, title, pack_name, min_players, max_players, manually_added, room_code } }`. Triggered when `POST /api/sessions/:id/games` adds a game.
- `session.ended` — broadcast to session subscribers. Data: `{ session: { id, is_active, games_played } }`. Triggered when `POST /api/sessions/:id/close` closes session.
- `player-count.updated` — broadcast to session subscribers. Data: `{ sessionId, gameId, playerCount, status }`. Triggered when player count is updated.
- **Event envelope:** `{ "type": "<event-type>", "timestamp": "<ISO 8601>", "data": { ... } }`
- **Error messages:** `{ "type": "error", "message": "..." }` for general errors, `{ "type": "auth_error", "message": "..." }` for auth failures.
- **Connection lifecycle example:** Connect → auth → subscribe → receive events → ping/pong loop → unsubscribe → close.
- **Reconnection:** Server doesn't maintain state across disconnects. Client must re-authenticate and re-subscribe after reconnecting.
**Step 2: Commit**
```bash
git add docs/api/websocket.md
git commit -m "docs: add WebSocket protocol documentation"
```
---
### Task 10: Write guide — Getting Started
**Files:**
- Create: `docs/api/guides/getting-started.md`
**Step 1: Write getting-started.md**
Narrative guide walking through the minimum viable integration:
1. Health check — verify API is running
2. Authenticate — get a JWT token
3. Browse games — list all games, filter by player count
4. Pick a game — use the picker with filters
5. Start a session — create session, add the picked game
6. Close the session
Each step includes a curl example and expected response. Cross-references the endpoint docs for full details.
**Step 2: Commit**
```bash
git add docs/api/guides/getting-started.md
git commit -m "docs: add Getting Started guide"
```
---
### Task 11: Write guide — Session Lifecycle
**Files:**
- Create: `docs/api/guides/session-lifecycle.md`
**Step 1: Write session-lifecycle.md**
Narrative guide covering:
1. Creating a session (one active at a time constraint)
2. Adding games (via picker or manual), understanding auto-status-transitions
3. Tracking game status (playing → played/skipped)
4. Room codes and player count monitoring
5. Closing sessions (auto-finalizes playing games)
6. Exporting session data (JSON and TXT formats)
7. Deleting old sessions (must close first, cascades)
8. WebSocket integration for real-time updates
**Step 2: Commit**
```bash
git add docs/api/guides/session-lifecycle.md
git commit -m "docs: add Session Lifecycle guide"
```
---
### Task 12: Write guide — Voting and Popularity
**Files:**
- Create: `docs/api/guides/voting-and-popularity.md`
**Step 1: Write voting-and-popularity.md**
Narrative guide covering:
1. How popularity works: `popularity_score = upvotes - downvotes`
2. Two voting mechanisms: chat import (batch, after-the-fact) and live votes (real-time, from bots)
3. Chat import flow: collect chat logs with `thisgame++`/`thisgame--` patterns, POST to chat-import, timestamp-matching algorithm explained
4. Live vote flow: bot sends votes in real-time via POST /api/votes/live, same timestamp-matching logic, deduplication within 1 second
5. How voting affects the picker: popularity_score doesn't directly affect picker weights (favor_bias does), but topRatedGames in stats uses it
6. Favor bias vs popularity: favor_bias is admin-controlled weighting for the picker; popularity is community-driven sentiment tracking
**Step 2: Commit**
```bash
git add docs/api/guides/voting-and-popularity.md
git commit -m "docs: add Voting and Popularity guide"
```
---
### Task 13: Write guide — Webhooks and Events
**Files:**
- Create: `docs/api/guides/webhooks-and-events.md`
**Step 1: Write webhooks-and-events.md**
Narrative guide covering:
1. Two notification systems: Webhooks (HTTP callbacks) and WebSocket (persistent connections)
2. When to use which: Webhooks for server-to-server integrations (bots, Discord); WebSocket for real-time UI or tools that maintain persistent connections
3. Webhook setup: create webhook, specify events, provide URL and secret
4. Webhook events: `game.added` (currently the only webhook event triggered in code — verify this). Payload shape.
5. Webhook delivery: async, logged in webhook_logs. Test with POST /api/webhooks/test/:id.
6. WebSocket events: `session.started`, `game.added`, `session.ended`, `player-count.updated`. Which are broadcast to all clients vs session subscribers.
7. Event payload reference (linking to websocket.md)
**Step 2: Commit**
```bash
git add docs/api/guides/webhooks-and-events.md
git commit -m "docs: add Webhooks and Events guide"
```
---
### Task 14: Validate documentation against source code
**Files:**
- Read: all `backend/routes/*.js` files
- Read: `docs/api/openapi.yaml`
- Read: all `docs/api/endpoints/*.md` files
**Step 1: Cross-reference every route handler against the OpenAPI spec**
For each file in `backend/routes/`:
- Count endpoints in source code
- Count corresponding paths in `openapi.yaml`
- Verify HTTP methods match
- Verify path patterns match (including parameter names)
- Verify auth requirements match (`authenticateToken` usage)
**Step 2: Verify request/response shapes**
For each endpoint:
- Compare request body fields to what the route handler destructures from `req.body`
- Compare response shapes to what the route handler actually sends via `res.json()`
- Check error status codes and messages match
**Step 3: Fix any discrepancies found**
Edit the OpenAPI spec and/or Markdown files to match the source code.
**Step 4: Commit any fixes**
```bash
git add docs/
git commit -m "docs: fix discrepancies found during validation"
```
---
### Task 15: Final review and summary commit
**Step 1: Verify all files exist**
```bash
ls -la docs/api/
ls -la docs/api/endpoints/
ls -la docs/api/guides/
ls -la docs/archive/
```
Expected:
- `docs/api/`: openapi.yaml, README.md, websocket.md
- `docs/api/endpoints/`: auth.md, games.md, sessions.md, picker.md, stats.md, votes.md, webhooks.md
- `docs/api/guides/`: getting-started.md, session-lifecycle.md, voting-and-popularity.md, webhooks-and-events.md
- `docs/archive/`: 9 old doc files
**Step 2: Verify total endpoint count in OpenAPI spec**
```bash
grep -c "operationId:" docs/api/openapi.yaml
```
Expected: 42 (41 API endpoints + 1 health check)
**Step 3: Final commit if any files were missed**
```bash
git add docs/
git status
git commit -m "docs: complete API documentation with OpenAPI spec, endpoint docs, WebSocket, and guides"
```

View File

@@ -0,0 +1,196 @@
# Vote Tracking API Design
## Overview
Extend the REST and WebSocket APIs so clients can track votes at both session and global levels. The primary consumer is a stream overlay (ticker-style display) that already has the admin JWT.
## Approach
**Approach B — Split by resource ownership.** Session-scoped vote data lives under the session resource. Global vote history lives under the vote resource. The WebSocket emits real-time events for live votes only.
## WebSocket: `vote.received` Event
**Trigger:** `POST /api/votes/live` — fires after the vote transaction succeeds, before the HTTP response. Only live votes emit this event; chat-import does not.
**Broadcast target:** Session subscribers via `broadcastEvent('vote.received', data, sessionId)`.
**Payload:**
```json
{
"type": "vote.received",
"timestamp": "2026-03-15T20:30:00.000Z",
"data": {
"sessionId": 5,
"game": {
"id": 42,
"title": "Quiplash 3",
"pack_name": "Party Pack 7"
},
"vote": {
"username": "viewer123",
"type": "up",
"timestamp": "2026-03-15T20:29:55.000Z"
},
"totals": {
"upvotes": 14,
"downvotes": 3,
"popularity_score": 11
}
}
}
```
**Implementation notes:**
- `votes.js` needs access to the WebSocket manager singleton via `getWebSocketManager()`.
- The existing session games JOIN needs to select `pack_name` from the `games` table.
## REST: `GET /api/sessions/:id/votes`
Per-game vote breakdown for a specific session.
**Location:** `backend/routes/sessions.js`
**Auth:** None (matches `GET /api/sessions/:id/games`).
**Response:**
```json
{
"session_id": 5,
"votes": [
{
"game_id": 42,
"title": "Quiplash 3",
"pack_name": "Party Pack 7",
"upvotes": 14,
"downvotes": 3,
"net_score": 11,
"total_votes": 17
}
]
}
```
**Query:**
```sql
SELECT
lv.game_id,
g.title,
g.pack_name,
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
SUM(lv.vote_type) AS net_score,
COUNT(*) AS total_votes
FROM live_votes lv
JOIN games g ON lv.game_id = g.id
WHERE lv.session_id = ?
GROUP BY lv.game_id
ORDER BY net_score DESC
```
**Error handling:**
- Session not found → 404
- Session exists, no votes → 200 with empty `votes` array
## REST: `GET /api/votes`
Paginated global vote history with flexible filtering.
**Location:** `backend/routes/votes.js`
**Auth:** None.
**Query parameters:**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `session_id` | integer | — | Filter by session |
| `game_id` | integer | — | Filter by game |
| `username` | string | — | Filter by voter |
| `vote_type` | `up` or `down` | — | Filter by direction |
| `page` | integer | 1 | Page number |
| `limit` | integer | 50 | Results per page (max 100) |
**Response:**
```json
{
"votes": [
{
"id": 891,
"session_id": 5,
"game_id": 42,
"game_title": "Quiplash 3",
"pack_name": "Party Pack 7",
"username": "viewer123",
"vote_type": "up",
"timestamp": "2026-03-15T20:29:55.000Z",
"created_at": "2026-03-15T20:29:56.000Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 237,
"total_pages": 5
}
}
```
**Design notes:**
- `vote_type` returned as `"up"` / `"down"`, not raw `1` / `-1`.
- `game_title` and `pack_name` included via JOIN.
- Ordered by `timestamp DESC`.
- `limit` capped at 100 server-side.
**Error handling:**
- Invalid filter values → 400
- No results → 200 with empty array and `total: 0`
## Testing Strategy
### Phase 1: Regression tests (pre-implementation)
Written and passing before any code changes to lock down existing behavior.
**`tests/api/regression-votes-live.test.js`** — existing `POST /api/votes/live`:
- Returns 200 with correct response shape (`success`, `session`, `game`, `vote`)
- `game` includes `id`, `title`, `upvotes`, `downvotes`, `popularity_score`
- Increments `upvotes`/`popularity_score` for upvote
- Increments `downvotes`/decrements `popularity_score` for downvote
- 400 for missing fields, invalid vote value, invalid timestamp
- 404 when no active session or timestamp doesn't match a game
- 409 for duplicate within 1-second window
- 401 without JWT
**`tests/api/regression-games.test.js`** — game aggregate fields:
- `GET /api/games` returns `upvotes`, `downvotes`, `popularity_score`
- `GET /api/games/:id` returns same fields
- Aggregates accurate after votes
**`tests/api/regression-sessions.test.js`** — session endpoints:
- `GET /api/sessions/:id` returns session object
- `GET /api/sessions/:id` returns 404 for nonexistent session
- `GET /api/sessions/:id/games` returns game list with expected shape
**`tests/api/regression-websocket.test.js`** — existing WebSocket events:
- Auth flow (auth → auth_success)
- Subscribe/unsubscribe flow
- `session.started` broadcast on session create
- `session.ended` broadcast on session close
- `game.added` broadcast on game add
### Phase 2: New feature tests (TDD — written before implementation)
- **`tests/api/votes-get.test.js`** — `GET /api/votes` history endpoint
- **`tests/api/sessions-votes.test.js`** — `GET /api/sessions/:id/votes` breakdown
- **`tests/api/votes-live-websocket.test.js`** — `vote.received` WebSocket event
### Workflow
1. Write Phase 1 regression tests → run → all green
2. Write Phase 2 feature tests → run → all red
3. Implement features
4. Run all tests → Phase 1 still green, Phase 2 now green

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
# Jackbox Ecast API Reverse Engineering — Design
**Date:** 2026-03-20
**Goal:** Produce comprehensive documentation of the Jackbox ecast API (REST + WebSocket) through live traffic analysis.
**Deliverable:** `docs/jackbox-ecast-api.md`
## Scope
### In Scope
1. **Ecast REST API** — All discoverable endpoints at `ecast.jackboxgames.com`, including undocumented paths, multiple API versions, and different HTTP methods.
2. **Ecast WebSocket Protocol** — Full bidirectional message protocol between `jackbox.tv` and ecast servers: connection handshake, all opcodes/message types, entity model, and state transitions through the complete game lifecycle.
3. **Player & Room Management** — Join/leave detection, real-time player count, max players, lobby lock/game start signals, game completion signals, player and game stats/results.
### Out of Scope
- Kosmi integration (chrome extension)
- This application's own API
- Game-specific mechanics (drawing, voting within Drawful 2) unless they reveal useful metadata
## Approach
**Primary: REST probing + browser console WebSocket interception (Approach A)**
**Fallback: Puppeteer + CDP automated capture script (Approach B)**
### Phase 1 — REST API Probing
- Probe known endpoint `GET /api/v2/rooms/{code}` and document full response
- Discover new paths: `/players`, `/audience`, `/state`, etc.
- Try API versions `v1`, `v3`
- Check for discovery endpoints (`/api`, `/swagger`, `/openapi`, `/health`)
- Try different HTTP methods (POST, PUT, OPTIONS)
- Document all response schemas, headers, status codes
### Phase 2 — WebSocket Interception
- Navigate to `jackbox.tv` in browser
- Inject WebSocket interceptor (monkey-patch `WebSocket`) before joining room
- Join as player — capture full connection lifecycle
- Join as audience — capture audience-specific messages
- If injection races with connection, reload page after patching
### Phase 3 — Game Lifecycle Capture
Walk through entire Drawful 2 lifecycle with interceptor running:
- Lobby: join/leave messages, player list updates
- Game start: lock signal, round info
- Gameplay: state transitions, metadata
- Game end: results, stats, disconnection sequence
- Multiple players to observe multi-player messages
### Fallback (Approach B)
If browser console interception is unreliable, write a Node.js script using Puppeteer + CDP `Network.webSocketFrame*` events for automated structured capture.
## Documentation Structure
The deliverable (`docs/jackbox-ecast-api.md`) will contain:
1. **Overview** — What ecast is, base URLs, architecture
2. **REST API Reference** — Each endpoint with method, URL, params, response schema, notes
3. **WebSocket Protocol Reference** — Connection details, message format, message catalog (all opcodes), entity model
4. **Game Lifecycle** — Sequence diagram of message flow from room creation through completion
5. **Player & Room Management** — Answers to specific questions (join/leave detection, player count, max players, lock/start, completion, stats) with supporting evidence
6. **Appendix: Raw Captures** — Sanitized example payloads

View File

@@ -0,0 +1,345 @@
# Ecast Shard Monitor — Design Document
**Date:** 2026-03-20
**Status:** Approved
**Replaces:** `room-monitor.js` (REST polling for lock) + `player-count-checker.js` (Puppeteer audience join)
## Problem
The current player count approach launches a headless Chrome instance via Puppeteer, navigates to `jackbox.tv`, joins as an audience member through the UI, and sniffs WebSocket frames via CDP. This is fragile, resource-heavy, and occupies an audience slot. The room monitor is a separate module that polls the REST API until the room locks, then hands off to the Puppeteer checker. Two modules, two connection strategies, a circular dependency workaround.
## Solution
Replace both modules with a single `EcastShardClient` that connects to the Jackbox ecast server as a **shard** via a direct Node.js WebSocket. The shard role:
- Gets the full `here` map (authoritative player list with names and roles)
- Receives real-time entity updates (room state, player joins, game end)
- Can query entities via `object/get`
- Does NOT count toward `maxPlayers` or trigger `full: true`
- Does NOT require a browser
One REST call upfront validates the room and retrieves the `host` field needed for the WebSocket URL. After that, the shard connection handles everything.
## Architecture
### Lifecycle
```
Room code registered
REST: GET /rooms/{code} ──── 404 ──→ Mark failed, stop
│ (get host, maxPlayers, locked, appTag)
WSS: Connect as shard
wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json
client/welcome received
├── Parse `here` → initial player count (filter for `player` roles)
├── Parse `entities.room` → lobby state, gameCanStart, etc.
├── Store `secret` + `id` for reconnection
└── Broadcast initial state to our clients
┌─── Event loop (listening for server messages) ───┐
│ │
│ `object` (key: textDescriptions) │
│ → Parse latestDescriptions for player joins │
│ → Broadcast `lobby.player-joined` to clients │
│ │
│ `object` (key: room) │
│ → Detect state transitions: │
│ lobbyState changes → broadcast lobby updates │
│ state: "Gameplay" → broadcast `game.started` │
│ gameFinished: true → broadcast `game.ended` │
│ gameResults → extract final player count │
│ │
│ `client/connected` (if delivered to shards) │
│ → Update here map, recount players │
│ │
│ WebSocket close/error │
│ → REST check: room exists? │
│ Yes → reconnect with secret/id │
│ No → game ended, finalize │
└────────────────────────────────────────────────────┘
```
### Internal State
| Field | Type | Source |
|-------|------|--------|
| `playerCount` | number | `here` map filtered for `player` roles |
| `playerNames` | string[] | `here` map player role `name` fields |
| `lobbyState` | string | `room` entity `lobbyState` |
| `gameState` | string | `room` entity `state` (`"Lobby"`, `"Gameplay"`) |
| `gameStarted` | boolean | Derived from `state === "Gameplay"` |
| `gameFinished` | boolean | `room` entity `gameFinished` |
| `maxPlayers` | number | REST response + `room` entity |
| `secret` / `id` | string/number | `client/welcome` for reconnection |
### Player Counting
The `here` map from `client/welcome` is the authoritative source. It lists all registered connections with their roles. Count entries where `roles` contains `player`. The shard itself is excluded (it has `roles: {shard: {}}`). The host (ID 1, `roles: {host: {}}`) is also excluded. Since Jackbox holds slots for disconnected players, `here` always reflects the true occupied slot count.
For subsequent joins after connect, `textDescriptions` entity updates provide join notifications. Since shards have `here` visibility, `client/connected` messages may also be delivered — both paths are handled, with `here` as source of truth.
## WebSocket Events (Game Picker → Connected Clients)
### `room.connected`
Shard successfully connected to the Jackbox room. Sent once on initial connect. Replaces the old `audience.joined` event.
```json
{
"type": "room.connected",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 2,
"players": ["Alice", "Bob"],
"lobbyState": "CanStart",
"gameState": "Lobby"
}
}
```
### `lobby.player-joined`
A new player joined the lobby.
```json
{
"type": "lobby.player-joined",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerName": "Charlie",
"playerCount": 3,
"players": ["Alice", "Bob", "Charlie"],
"maxPlayers": 8
}
}
```
### `lobby.updated`
Lobby state changed (enough players to start, countdown started, etc.).
```json
{
"type": "lobby.updated",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"lobbyState": "Countdown",
"gameCanStart": true,
"gameIsStarting": true,
"playerCount": 4
}
}
```
### `game.started`
The game transitioned from Lobby to Gameplay.
```json
{
"type": "game.started",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"maxPlayers": 8
}
}
```
### `game.ended`
The game finished.
```json
{
"type": "game.ended",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"]
}
}
```
### `room.disconnected`
Shard lost connection to the Jackbox room.
```json
{
"type": "room.disconnected",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"reason": "room_closed",
"finalPlayerCount": 4
}
}
```
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
### Dropped Events
| Old event | Replacement |
|-----------|-------------|
| `audience.joined` | `room.connected` (richer payload) |
| `player-count.updated` (automated) | `lobby.player-joined`, `game.started`, `game.ended` carry `playerCount` |
The manual `PATCH .../player-count` endpoint keeps broadcasting `player-count.updated` for its specific use case.
### DB Persistence
The `session_games` table columns `player_count` and `player_count_check_status` continue to be updated:
- `player_count` — updated on each join and at game end
- `player_count_check_status``'monitoring'` (shard connected), `'completed'` (game ended with count), `'failed'` (couldn't connect), `'stopped'` (manual stop)
The old `'checking'` status becomes `'monitoring'`.
## Integration Points
### Files Deleted
- `backend/utils/player-count-checker.js` — Puppeteer audience approach
- `backend/utils/room-monitor.js` — REST polling for lock state
### Files Created
- `backend/utils/ecast-shard-client.js``EcastShardClient` class + module exports: `startMonitor`, `stopMonitor`, `cleanupAllShards`
### Files Modified
**`backend/utils/jackbox-api.js`** — Add `getRoomInfo(roomCode)` returning the full room response including `host`, `appTag`, `audienceEnabled`.
**`backend/routes/sessions.js`** — Replace imports:
```javascript
// Old
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
// New
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
```
All call sites change from two-function calls to one:
| Route | Old | New |
|-------|-----|-----|
| `POST /:id/games` (with room_code) | `startRoomMonitor(...)` | `startMonitor(...)` |
| `PATCH .../status` (away from playing) | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
| `DELETE .../games/:gameId` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
| `POST .../start-player-check` | `startRoomMonitor(...)` | `startMonitor(...)` |
| `POST .../stop-player-check` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
Endpoint paths stay the same for backwards compatibility.
**`backend/server.js`** — Wire `cleanupAllShards()` into `SIGTERM`/`SIGINT` handlers.
## Error Handling and Reconnection
### Connection Failures
1. **REST validation fails** (room not found, network error): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'room_not_found'` or `'connection_failed'`. No automatic retry.
2. **Shard WebSocket fails to connect**: Retry up to 3 times with exponential backoff (2s, 4s, 8s). On exhaustion, set status `'failed'`, broadcast `room.disconnected` with `reason: 'connection_failed'`.
3. **Ecast rejects the shard role** (error opcode received): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'role_rejected'`. No retry.
### Mid-Session Disconnections
4. **WebSocket closes unexpectedly**: REST check `GET /rooms/{code}`:
- Room exists → reconnect with stored `secret`/`id` (up to 3 attempts, exponential backoff). Transparent to clients on success.
- Room gone → finalize with last known count, status `'completed'`, broadcast `game.ended` + `room.disconnected`.
5. **Ecast error 2027 "room already closed"**: Same as room-gone path.
### Manual Stop
6. **`stop-player-check` called or game status changes**: Close WebSocket gracefully, set status `'stopped'` (unless already `'completed'`), broadcast `room.disconnected` with `reason: 'manually_stopped'`.
### Server Shutdown
7. **`SIGTERM`/`SIGINT`**: `cleanupAllShards()` closes all WebSocket connections. No DB updates on shutdown.
### State Machine
```
startMonitor()
┌───────────┐
┌────────│ not_started│
│ └───────────┘
│ │
REST fails REST succeeds
│ │
▼ ▼
┌────────┐ ┌────────────┐
│ failed │ │ monitoring │◄──── reconnect success
└────────┘ └─────┬──────┘
▲ │
│ ┌────┴─────┬──────────────┐
reconnect │ │ │
exhausted game ends WS drops manual stop
│ │ │ │
│ ▼ ▼ ▼
│ ┌──────────┐ REST check ┌─────────┐
│ │ completed │ │ │ stopped │
│ └──────────┘ │ └─────────┘
│ │
└──── room gone? ────┘
room exists?
reconnect...
```
### Timeouts
| Concern | Value | Rationale |
|---------|-------|-----------|
| WebSocket connect timeout | 10s | Ecast servers respond fast |
| Reconnect backoff | 2s, 4s, 8s | Three attempts, ~14s total |
| Max reconnect attempts | 3 | Fail fast, user can retry manually |
| WebSocket inactivity timeout | None | Shard connections receive periodic `shard/sync` CRDT messages |
## Dependencies
**Added:** `ws` (Node.js WebSocket library) — already a dependency (used by `websocket-manager.js`).
**Removed:** `puppeteer` — no longer needed for room monitoring.
## Non-Goals
- Renaming REST endpoint paths (`start-player-check` / `stop-player-check`) — kept for backwards compatibility
- Auto-starting monitoring when room code is set via `PATCH .../room-code` — kept as manual trigger only
- Frontend `Picker.jsx` changes — tracked separately (existing bugs: `message.event` vs `message.type`, subscribe without auth, `'waiting'` status that's never set)

View File

@@ -0,0 +1,722 @@
# Ecast Shard Monitor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.
**Architecture:** A new `EcastShardClient` class connects as a shard to the Jackbox ecast server via the `ws` library. One REST call validates the room and gets the `host` field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports `startMonitor`, `stopMonitor`, and `cleanupAllShards` as drop-in replacements for the old two-module API.
**Tech Stack:** Node.js, `ws` library (already installed), ecast WebSocket protocol (`ecast-v0`), Jest for tests.
**Design doc:** `docs/plans/2026-03-20-shard-monitor-design.md`
**Ecast API reference:** `docs/jackbox-ecast-api.md`
---
### Task 1: Extend `jackbox-api.js` with `getRoomInfo`
**Files:**
- Modify: `backend/utils/jackbox-api.js`
- Test: `tests/api/jackbox-api.test.js` (create)
**Step 1: Write the failing test**
Create `tests/api/jackbox-api.test.js`:
```javascript
const { getRoomInfo } = require('../../backend/utils/jackbox-api');
describe('getRoomInfo', () => {
test('is exported as a function', () => {
expect(typeof getRoomInfo).toBe('function');
});
});
```
**Step 2: Run test to verify it fails**
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
Expected: FAIL — `getRoomInfo` is not exported.
**Step 3: Implement `getRoomInfo`**
In `backend/utils/jackbox-api.js`, add a new function that calls `GET /api/v2/rooms/{code}` and returns the full room body including `host`, `appTag`, `audienceEnabled`, `maxPlayers`, `locked`, `full`. On failure, return `{ exists: false }`.
The existing `checkRoomStatus` stays for now (other code may still reference it during migration).
```javascript
async function getRoomInfo(roomCode) {
try {
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
headers: DEFAULT_HEADERS
});
if (!response.ok) {
return { exists: false };
}
const data = await response.json();
const body = data.body || data;
return {
exists: true,
host: body.host,
audienceHost: body.audienceHost,
appTag: body.appTag,
appId: body.appId,
code: body.code,
locked: body.locked || false,
full: body.full || false,
maxPlayers: body.maxPlayers || 8,
minPlayers: body.minPlayers || 0,
audienceEnabled: body.audienceEnabled || false,
};
} catch (e) {
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
return { exists: false };
}
}
```
Export it alongside `checkRoomStatus`:
```javascript
module.exports = { checkRoomStatus, getRoomInfo };
```
**Step 4: Run test to verify it passes**
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"
```
---
### Task 2: Create `EcastShardClient` — connection and welcome handling
**Files:**
- Create: `backend/utils/ecast-shard-client.js`
- Test: `tests/api/ecast-shard-client.test.js` (create)
This task builds the core class with: constructor, `connect()`, `client/welcome` parsing, `here` map player counting, and the `disconnect()` method. No event broadcasting yet — that's Task 3.
**Step 1: Write failing tests**
Create `tests/api/ecast-shard-client.test.js`. Since we can't connect to real Jackbox servers in tests, test the pure logic: `here` map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.
```javascript
const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');
describe('EcastShardClient', () => {
describe('parsePlayersFromHere', () => {
test('counts only player roles, excludes host and shard', () => {
const here = {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
'3': { id: 3, roles: { player: { name: 'Bob' } } },
'5': { id: 5, roles: { shard: {} } },
};
const result = EcastShardClient.parsePlayersFromHere(here);
expect(result.playerCount).toBe(2);
expect(result.playerNames).toEqual(['Alice', 'Bob']);
});
test('returns zero for empty here or host-only', () => {
const here = { '1': { id: 1, roles: { host: {} } } };
const result = EcastShardClient.parsePlayersFromHere(here);
expect(result.playerCount).toBe(0);
expect(result.playerNames).toEqual([]);
});
test('handles null or undefined here', () => {
expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
});
});
describe('parseRoomEntity', () => {
test('extracts lobby state from room entity val', () => {
const roomVal = {
state: 'Lobby',
lobbyState: 'CanStart',
gameCanStart: true,
gameIsStarting: false,
gameFinished: false,
};
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameState).toBe('Lobby');
expect(result.lobbyState).toBe('CanStart');
expect(result.gameCanStart).toBe(true);
expect(result.gameStarted).toBe(false);
expect(result.gameFinished).toBe(false);
});
test('detects game started from Gameplay state', () => {
const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameStarted).toBe(true);
});
test('detects game finished', () => {
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameFinished).toBe(true);
});
});
describe('parsePlayerJoinFromTextDescriptions', () => {
test('extracts player name from join description', () => {
const val = {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
]
};
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
});
test('extracts VIP join', () => {
const val = {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
]
};
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
});
test('returns empty array for no joins', () => {
const val = { latestDescriptions: [] };
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
});
});
});
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL — module does not exist.
**Step 3: Implement EcastShardClient**
Create `backend/utils/ecast-shard-client.js` with:
1. **Static utility methods** (`parsePlayersFromHere`, `parseRoomEntity`, `parsePlayerJoinFromTextDescriptions`) — pure functions, tested above.
2. **Constructor** — takes `{ sessionId, gameId, roomCode, maxPlayers }`, initializes internal state.
3. **`connect(roomInfo)`** — accepts the result of `getRoomInfo()`. Opens a WebSocket to `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json` with `Sec-WebSocket-Protocol: ecast-v0` and `Origin: https://jackbox.tv`.
4. **`handleMessage(data)`** — dispatcher that routes `client/welcome`, `object`, `error`, `client/connected`, `client/disconnected` to handler methods.
5. **`handleWelcome(result)`** — parses `here`, `entities.room`, stores `secret`/`id`.
6. **`disconnect()`** — closes the WebSocket gracefully.
7. **Internal state:** `playerCount`, `playerNames`, `lobbyState`, `gameState`, `gameStarted`, `gameFinished`, `maxPlayers`, `secret`, `id`, `ws`.
Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.
Key implementation details for the WebSocket connection:
```javascript
const WebSocket = require('ws');
// In connect():
this.ws = new WebSocket(url, ['ecast-v0'], {
headers: { 'Origin': 'https://jackbox.tv' },
handshakeTimeout: 10000,
});
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS (all 8 tests)
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"
```
---
### Task 3: Add event broadcasting and entity update handling
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
This task wires up the WebSocket message handlers to broadcast events via `WebSocketManager` and update the `session_games` DB row.
**Step 1: Write failing tests for entity update handlers**
Add tests to `tests/api/ecast-shard-client.test.js`:
```javascript
describe('handleRoomUpdate', () => {
test('detects game start transition', () => {
// Create client instance, set initial state to Lobby
// Call handleRoomUpdate with Gameplay state
// Verify gameStarted flipped and handler would broadcast
});
test('detects game end transition', () => {
// Create client, set gameStarted = true
// Call handleRoomUpdate with gameFinished: true
// Verify gameFinished flipped
});
test('detects lobby state change', () => {
// Create client, set lobbyState to WaitingForMore
// Call handleRoomUpdate with CanStart
// Verify lobbyState updated
});
});
```
Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a `broadcaster` callback in its constructor options. The callback receives `(eventType, data)`. This makes the class testable without mocking the WebSocketManager singleton.
Constructor signature becomes:
```javascript
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })
```
Where `onEvent` is `(eventType, eventData) => void`. The module-level `startMonitor` function provides a default `onEvent` that calls `wsManager.broadcastEvent(...)` and writes to the DB.
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL on new tests.
**Step 3: Implement entity update handlers**
Add to `EcastShardClient`:
- **`handleRoomUpdate(roomVal)`** — called when an `object` message arrives with `key: "room"` (or `key: "bc:room"` for some games). Compares new state against stored state. Broadcasts:
- `lobby.updated` if `lobbyState` changed
- `game.started` if `state` changed to `"Gameplay"` and `gameStarted` was false
- `game.ended` if `gameFinished` changed to true
- Updates `playerCount` in DB via `updatePlayerCount()` on game start and end.
- **`handleTextDescriptionsUpdate(val)`** — called when `object` with `key: "textDescriptions"` arrives. Uses `parsePlayerJoinFromTextDescriptions` to detect joins. Broadcasts `lobby.player-joined` for each new join. Updates internal `playerNames` list.
- **`handleClientConnected(result)`** — if shards receive `client/connected`, update internal `here` tracking and recount players. Broadcast `lobby.player-joined` if the new connection is a player.
- **`updatePlayerCount(count, status)`** — writes to `session_games` and calls `this.onEvent('player-count.updated', ...)` for DB-triggered updates.
Add the module-level `startMonitor` function:
```javascript
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
const monitorKey = `${sessionId}-${gameId}`;
if (activeShards.has(monitorKey)) return;
const roomInfo = await getRoomInfo(roomCode);
if (!roomInfo.exists) {
// set failed status in DB, broadcast room.disconnected
return;
}
const client = new EcastShardClient({
sessionId, gameId, roomCode,
maxPlayers: roomInfo.maxPlayers || maxPlayers,
onEvent: (type, data) => {
const wsManager = getWebSocketManager();
if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
}
});
activeShards.set(monitorKey, client);
await client.connect(roomInfo);
}
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add event broadcasting and entity update handlers to shard client"
```
---
### Task 4: Add reconnection logic
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
**Step 1: Write failing test**
```javascript
describe('reconnection state machine', () => {
test('buildReconnectUrl uses stored secret and id', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 1, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.secret = 'abc-123';
client.shardId = 5;
client.host = 'ecast-prod-use2.jackboxgames.com';
const url = client.buildReconnectUrl();
expect(url).toContain('secret=abc-123');
expect(url).toContain('id=5');
expect(url).toContain('role=shard');
expect(url).toContain('ecast-prod-use2.jackboxgames.com');
});
});
```
**Step 2: Run test to verify it fails**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL — `buildReconnectUrl` doesn't exist.
**Step 3: Implement reconnection**
Add to `EcastShardClient`:
- **`handleClose(code, reason)`** — called on WebSocket `close` event. If `gameFinished` or `manuallyStopped`, do nothing. Otherwise, call `attemptReconnect()`.
- **`attemptReconnect()`** — calls `getRoomInfo(roomCode)`. If room gone, finalize. If room exists, try `reconnectWithBackoff()`.
- **`reconnectWithBackoff()`** — attempts up to 3 reconnections with 2s/4s/8s delays. Uses `buildReconnectUrl()` with stored `secret`/`id`. On success, resumes message handling transparently. On failure, set status `'failed'`, broadcast `room.disconnected`.
- **`buildReconnectUrl()`** — constructs `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}`.
- **`handleError(err)`** — logs the error, defers to `handleClose` for reconnection decisions.
Also handle ecast error opcode 2027 ("room already closed") in `handleMessage` — treat as game-ended.
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add reconnection logic with exponential backoff to shard client"
```
---
### Task 5: Add module exports (`startMonitor`, `stopMonitor`, `cleanupAllShards`)
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
**Step 1: Write failing tests**
```javascript
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
describe('module exports', () => {
test('startMonitor is exported', () => {
expect(typeof startMonitor).toBe('function');
});
test('stopMonitor is exported', () => {
expect(typeof stopMonitor).toBe('function');
});
test('cleanupAllShards is exported', () => {
expect(typeof cleanupAllShards).toBe('function');
});
});
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL if not yet exported.
**Step 3: Finalize module exports**
Ensure these are all exported from `backend/utils/ecast-shard-client.js`:
```javascript
const activeShards = new Map();
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
// ... (implemented in Task 3)
}
async function stopMonitor(sessionId, gameId) {
const monitorKey = `${sessionId}-${gameId}`;
const client = activeShards.get(monitorKey);
if (client) {
client.manuallyStopped = true;
client.disconnect();
activeShards.delete(monitorKey);
// Update DB status unless already completed
const game = db.prepare(
'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
).get(sessionId, gameId);
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
db.prepare(
'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
).run('stopped', sessionId, gameId);
}
client.onEvent('room.disconnected', {
sessionId, gameId, roomCode: client.roomCode,
reason: 'manually_stopped',
finalPlayerCount: client.playerCount
});
}
}
async function cleanupAllShards() {
for (const [, client] of activeShards) {
client.manuallyStopped = true;
client.disconnect();
}
activeShards.clear();
console.log('[Shard Monitor] Cleaned up all active shards');
}
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"
```
---
### Task 6: Rewire `sessions.js` routes
**Files:**
- Modify: `backend/routes/sessions.js` (lines 78 imports, lines 394401, 617624, 638644, 844875, 877893)
- Test: `tests/api/regression-sessions.test.js` (verify existing tests still pass)
**Step 1: Run existing session tests as baseline**
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
Expected: PASS (capture baseline)
**Step 2: Replace imports**
In `backend/routes/sessions.js`, replace lines 78:
```javascript
// Old
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
// New
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
```
**Step 3: Replace call sites**
Search and replace across the file. There are 5 call sites:
1. **Line ~397** (`POST /:id/games`): `startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)``startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)`
2. **Lines ~620621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)`
3. **Lines ~640641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)`
4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)``startMonitor(...)`
5. **Lines ~883884** (`POST .../stop-player-check`): Replace two stop calls with single `stopMonitor(sessionId, gameId)`
**Step 4: Run tests to verify nothing broke**
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)
Also run the full test suite:
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/routes/sessions.js
git commit -m "refactor: rewire sessions routes to use ecast shard client"
```
---
### Task 7: Wire graceful shutdown in `server.js`
**Files:**
- Modify: `backend/server.js`
**Step 1: Add shutdown handler**
In `backend/server.js`, import `cleanupAllShards` and add signal handlers inside the `if (require.main === module)` block:
```javascript
const { cleanupAllShards } = require('./utils/ecast-shard-client');
// Inside the if (require.main === module) block, after server.listen:
const shutdown = async () => {
console.log('Shutting down gracefully...');
await cleanupAllShards();
server.close(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```
**Step 2: Verify server still starts**
Run: `cd backend && timeout 5 node server.js || true`
Expected: Server starts, prints port message, exits on timeout.
**Step 3: Run full test suite**
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS
**Step 4: Commit**
```bash
git add backend/server.js
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"
```
---
### Task 8: Delete old files and remove Puppeteer dependency
**Files:**
- Delete: `backend/utils/player-count-checker.js`
- Delete: `backend/utils/room-monitor.js`
- Modify: `backend/package.json` (remove `puppeteer` from dependencies)
**Step 1: Verify no remaining imports of old modules**
Search the codebase for any remaining `require('./player-count-checker')`, `require('./room-monitor')`, `require('../utils/player-count-checker')`, `require('../utils/room-monitor')`. After Task 6, `sessions.js` should be the only file that imported them and it now imports from `ecast-shard-client`. The old `room-monitor.js` had a lazy require of `player-count-checker` which is going away with it.
If any other files reference these modules, update them first.
**Step 2: Delete the files**
```bash
rm backend/utils/player-count-checker.js backend/utils/room-monitor.js
```
**Step 3: Remove Puppeteer dependency**
```bash
cd backend && npm uninstall puppeteer
```
**Step 4: Run full test suite**
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS — no test should depend on the deleted files.
**Step 5: Commit**
```bash
git add -A
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"
```
---
### Task 9: Update WebSocket documentation
**Files:**
- Modify: `docs/api/websocket.md`
**Step 1: Read current websocket.md**
Read `docs/api/websocket.md` and identify the server-to-client event table.
**Step 2: Update the event table**
Replace the old events with the new contract:
| Event | Description |
|-------|-------------|
| `room.connected` | Shard connected to Jackbox room (replaces `audience.joined`) |
| `lobby.player-joined` | A player joined the lobby |
| `lobby.updated` | Lobby state changed |
| `game.started` | Game transitioned to Gameplay |
| `game.ended` | Game finished |
| `room.disconnected` | Shard lost connection to room |
| `game.added` | New game added to session (unchanged) |
| `session.started` | Session created (unchanged) |
| `session.ended` | Session closed (unchanged) |
| `vote.received` | Vote recorded (unchanged) |
| `player-count.updated` | Manual player count override (unchanged) |
Add payload examples for each new event (from design doc).
**Step 3: Commit**
```bash
git add docs/api/websocket.md
git commit -m "docs: update websocket event reference with new shard monitor events"
```
---
### Task 10: Smoke test with a real Jackbox room (manual)
This task is manual verification — not automated.
**Steps:**
1. Start the backend: `cd backend && npm run dev`
2. Create a session via API, add a game with a room code from an active Jackbox game
3. Watch backend logs for `[Shard Monitor]` messages:
- REST room info fetched
- WebSocket connected as shard
- `client/welcome` parsed, player count reported
- Player join detected when someone joins the lobby
- Game start detected when the game begins
- Game end detected when the game finishes
4. Connect a WebSocket client to `/api/sessions/live`, authenticate, subscribe to the session, and verify events arrive:
- `room.connected`
- `lobby.player-joined`
- `game.started`
- `game.ended`
- `room.disconnected`
5. Test `stop-player-check` endpoint — verify shard disconnects cleanly
6. Test reconnection — kill and restart the backend mid-game, call `start-player-check` again
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | `getRoomInfo` in jackbox-api | `jackbox-api.js`, test |
| 2 | `EcastShardClient` core + parsing | `ecast-shard-client.js`, test |
| 3 | Event broadcasting + entity handlers | `ecast-shard-client.js`, test |
| 4 | Reconnection logic | `ecast-shard-client.js`, test |
| 5 | Module exports | `ecast-shard-client.js`, test |
| 6 | Rewire sessions routes | `sessions.js` |
| 7 | Graceful shutdown | `server.js` |
| 8 | Delete old files + remove Puppeteer | `player-count-checker.js`, `room-monitor.js`, `package.json` |
| 9 | Update docs | `websocket.md` |
| 10 | Manual smoke test | — |

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'Jackbox Game Picker',
version: '0.5.1 - Thode Goes Wild Edition',
version: '0.6.0 - Fish Tank Edition',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {

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';
@@ -8,7 +8,7 @@ import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function Picker() {
const { isAuthenticated, loading: authLoading } = useAuth();
const { isAuthenticated, loading: authLoading, token } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
@@ -124,13 +124,13 @@ function Picker() {
loadData();
}, [isAuthenticated, authLoading, navigate, loadData]);
// Poll for active session status changes
// Fallback poll for session status — WebSocket events handle most updates
useEffect(() => {
if (!isAuthenticated || authLoading) return;
const interval = setInterval(() => {
checkActiveSession();
}, 3000);
}, 60000);
return () => clearInterval(interval);
}, [isAuthenticated, authLoading, checkActiveSession]);
@@ -939,7 +939,7 @@ function Picker() {
}
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
const { isAuthenticated } = useAuth();
const { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
@@ -952,33 +952,51 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
const [newPlayerCount, setNewPlayerCount] = useState('');
const playingGameRef = useRef(playingGame);
playingGameRef.current = playingGame;
const loadGames = useCallback(async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
setGames(response.data.reverse());
const freshGames = response.data;
setGames(freshGames.reverse());
const currentPlaying = freshGames.find(g => g.status === 'playing');
const prev = playingGameRef.current;
if (currentPlaying) {
if (!prev || prev.id !== currentPlaying.id || prev.player_count !== currentPlaying.player_count) {
setPlayingGame(currentPlaying);
}
} else if (prev) {
const still = freshGames.find(g => g.id === prev.id);
if (!still || still.status !== 'playing') {
setPlayingGame(null);
}
}
} catch (err) {
console.error('Failed to load session games');
} finally {
setLoading(false);
}
}, [sessionId]);
}, [sessionId, setPlayingGame]);
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate, loadGames]);
// Auto-refresh games list every 3 seconds
// Fallback polling — WebSocket events handle most updates; this is a safety net
useEffect(() => {
const interval = setInterval(() => {
loadGames();
}, 3000);
}, 60000);
return () => clearInterval(interval);
}, [loadGames]);
// Setup WebSocket connection for real-time player count updates
// Setup WebSocket connection for real-time session updates
useEffect(() => {
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
@@ -986,22 +1004,34 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WebSocket] Connected for player count updates');
// Subscribe to session events
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: parseInt(sessionId)
}));
console.log('[WebSocket] Connected, authenticating...');
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Handle player count updates
if (message.event === 'player-count.updated') {
console.log('[WebSocket] Player count updated:', message.data);
// Reload games to get updated player counts
if (message.type === 'auth_success') {
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
return;
}
const reloadEvents = [
'room.connected',
'lobby.player-joined',
'lobby.updated',
'game.started',
'game.ended',
'room.disconnected',
'player-count.updated',
'game.added',
'game.status',
];
if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data);
loadGames();
}
} catch (error) {
@@ -1027,7 +1057,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
}
}, [sessionId, loadGames]);
}, [sessionId, token, loadGames]);
const handleUpdateStatus = async (gameId, newStatus) => {
try {
@@ -1303,14 +1333,14 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
{/* Player Count Display */}
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
<div className="flex items-center gap-1">
{game.player_count_check_status === 'waiting' && (
{game.player_count_check_status === 'monitoring' && !game.player_count && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Waiting...
📡 Monitoring...
</span>
)}
{game.player_count_check_status === 'checking' && (
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
</span>
)}
{game.player_count_check_status === 'completed' && game.player_count && (
@@ -1406,7 +1436,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
</>
)}
{/* Stop button for active checks */}
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
<button
onClick={() => handleStopPlayerCountCheck(game.id)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"

8
jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
setupFiles: ['<rootDir>/tests/jest.setup.js'],
testMatch: ['**/*.test.js'],
testTimeout: 10000,
moduleDirectories: ['node_modules', '<rootDir>/backend/node_modules'],
};

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
const WebSocket = require('ws');
const https = require('https');
const ROOM = process.argv[2] || 'SCWX';
const HOST = 'ecast-prod-use2.jackboxgames.com';
function ts() { return new Date().toISOString().slice(11, 23); }
function log(tag, ...args) { console.log(`[${ts()}][${tag}]`, ...args); }
function connect(name, opts = {}) {
return new Promise((resolve, reject) => {
let params = `role=player&name=${encodeURIComponent(name)}&format=json`;
if (opts.secret) {
params += `&secret=${opts.secret}`;
params += `&id=${opts.id}`;
} else {
params += `&userId=${name}-${Date.now()}`;
}
const url = `wss://${HOST}/api/v2/rooms/${ROOM}/play?${params}`;
log(name, 'Connecting:', url);
const ws = new WebSocket(url, ['ecast-v0'], {
headers: { 'Origin': 'https://jackbox.tv' }
});
const allMsgs = [];
ws.on('message', (raw) => {
const m = JSON.parse(raw.toString());
allMsgs.push(m);
if (m.opcode === 'client/welcome') {
const r = m.result;
const hereList = r.here ? Object.entries(r.here).map(([k, v]) => {
const role = Object.keys(v.roles)[0];
const detail = v.roles.player ? `(${v.roles.player.name})` : '';
return `${k}:${role}${detail}`;
}).join(', ') : 'null';
log(name, `WELCOME id=${r.id} reconnect=${r.reconnect} secret=${r.secret} here=[${hereList}]`);
resolve({ ws, id: r.id, secret: r.secret, msgs: allMsgs, name });
} else if (m.opcode === 'client/connected') {
const r = m.result;
log(name, `*** CLIENT/CONNECTED id=${r.id} userId=${r.userId} name=${r.name} role=${JSON.stringify(r.roles || r.role)}`);
} else if (m.opcode === 'client/disconnected') {
const r = m.result;
log(name, `*** CLIENT/DISCONNECTED id=${r.id} role=${JSON.stringify(r.roles || r.role)}`);
} else if (m.opcode === 'client/kicked') {
log(name, `*** CLIENT/KICKED:`, JSON.stringify(m.result));
} else if (m.opcode === 'error') {
log(name, `ERROR code=${m.result.code}: ${m.result.msg}`);
reject(new Error(m.result.msg));
} else if (m.opcode === 'object') {
const r = m.result;
if (r.key === 'room') {
log(name, `ROOM state=${r.val?.state} lobbyState=${r.val?.lobbyState} gameCanStart=${r.val?.gameCanStart} gameFinished=${r.val?.gameFinished} v${r.version}`);
} else if (r.key === 'textDescriptions') {
const latest = r.val?.latestDescriptions?.[0];
if (latest) log(name, `TEXT: "${latest.text}" (${latest.category})`);
} else if (r.key?.startsWith('player:')) {
log(name, `PLAYER ${r.key} state=${r.val?.state || 'init'} v${r.version}`);
} else {
log(name, `ENTITY ${r.key} v${r.version}`);
}
} else if (m.opcode === 'ok') {
log(name, `OK seq response`);
} else if (m.opcode === 'room/get-audience') {
log(name, `AUDIENCE connections=${m.result?.connections}`);
} else {
log(name, `OTHER op=${m.opcode}`, JSON.stringify(m.result).slice(0, 200));
}
});
ws.on('close', (code, reason) => {
log(name, `CLOSED code=${code} reason=${reason.toString()}`);
});
ws.on('error', (e) => {
log(name, `WS_ERROR: ${e.message}`);
reject(e);
});
});
}
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
async function main() {
log('TEST', `=== Phase 1: Connect P1 to ${ROOM} ===`);
const p1 = await connect('P1');
log('TEST', `P1 connected as id=${p1.id}, secret=${p1.secret}`);
log('TEST', `=== Phase 2: Check connections ===`);
const connBefore = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections: ${JSON.stringify(connBefore)}`);
await wait(2000);
log('TEST', `=== Phase 3: Connect P2 (watch P1 for client/connected) ===`);
const p2 = await connect('P2');
log('TEST', `P2 connected as id=${p2.id}, secret=${p2.secret}`);
const connAfterJoin = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 join: ${JSON.stringify(connAfterJoin)}`);
await wait(2000);
log('TEST', `=== Phase 4: P2 gracefully disconnects (watch P1 for client/disconnected) ===`);
p2.ws.close(1000, 'test-disconnect');
await wait(3000);
const connAfterLeave = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 leave: ${JSON.stringify(connAfterLeave)}`);
log('TEST', `=== Phase 5: Reconnect P2 using secret ===`);
const p2r = await connect('P2-RECONNECT', { secret: p2.secret, id: p2.id });
log('TEST', `P2 reconnected as id=${p2r.id}, reconnect should be true`);
const connAfterReconnect = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 reconnect: ${JSON.stringify(connAfterReconnect)}`);
await wait(2000);
log('TEST', `=== Phase 6: Connect P3 (reach 3 players for CanStart) ===`);
const p3 = await connect('P3');
log('TEST', `P3 connected as id=${p3.id}`);
await wait(2000);
log('TEST', `=== Phase 7: Query audience count ===`);
p1.ws.send(JSON.stringify({ seq: 100, opcode: 'room/get-audience', params: {} }));
await wait(1000);
log('TEST', `=== Phase 8: All messages received by P1 ===`);
const p1Opcodes = p1.msgs.map(m => `pc:${m.pc} ${m.opcode}${m.result?.key ? ':' + m.result.key : ''}`);
log('TEST', `P1 received ${p1.msgs.length} messages: ${p1Opcodes.join(', ')}`);
log('TEST', `=== Cleanup ===`);
p1.ws.close(1000);
p2r.ws.close(1000);
p3.ws.close(1000);
await wait(1000);
const connFinal = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Final connections: ${JSON.stringify(connFinal)}`);
log('TEST', `=== DONE ===`);
process.exit(0);
}
function fetchJSON(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
}).on('error', reject);
});
}
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });

109
scripts/ws-probe.js Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
const WebSocket = require('ws');
const https = require('https');
const ROOM_CODE = process.argv[2] || 'LSBN';
const PLAYER_NAME = process.argv[3] || 'PROBE_WS';
const ROLE = process.argv[4] || 'player'; // 'player' or 'audience'
const USER_ID = `probe-${Date.now()}`;
function getRoomInfo(code) {
return new Promise((resolve, reject) => {
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${code}`, res => {
let data = '';
res.on('data', c => data += c);
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);
});
}
function ts() {
return new Date().toISOString().slice(11, 23);
}
async function main() {
console.log(`[${ts()}] Fetching room info for ${ROOM_CODE}...`);
const room = await getRoomInfo(ROOM_CODE);
console.log(`[${ts()}] Room: ${room.appTag}, host: ${room.host}, locked: ${room.locked}`);
let wsUrl;
if (ROLE === 'audience') {
wsUrl = `wss://${room.audienceHost}/api/v2/audience/${ROOM_CODE}/play`;
} else {
wsUrl = `wss://${room.host}/api/v2/rooms/${ROOM_CODE}/play?role=${ROLE}&name=${encodeURIComponent(PLAYER_NAME)}&userId=${USER_ID}&format=json`;
}
console.log(`[${ts()}] Connecting: ${wsUrl}`);
const ws = new WebSocket(wsUrl, ['ecast-v0'], {
headers: {
'Origin': 'https://jackbox.tv',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
let msgCount = 0;
ws.on('open', () => {
console.log(`[${ts()}] CONNECTED`);
});
ws.on('message', (raw) => {
msgCount++;
try {
const msg = JSON.parse(raw.toString());
const summary = summarize(msg);
console.log(`[${ts()}] RECV #${msgCount} | pc:${msg.pc} | opcode:${msg.opcode} | ${summary}`);
if (process.env.VERBOSE === 'true') {
console.log(JSON.stringify(msg, null, 2));
}
} catch (e) {
console.log(`[${ts()}] RECV #${msgCount} | raw: ${raw.toString().slice(0, 200)}`);
}
});
ws.on('close', (code, reason) => {
console.log(`[${ts()}] CLOSED code=${code} reason=${reason}`);
process.exit(0);
});
ws.on('error', (err) => {
console.error(`[${ts()}] ERROR: ${err.message}`);
});
process.on('SIGINT', () => {
console.log(`\n[${ts()}] Closing (${msgCount} messages received)`);
ws.close();
});
}
function summarize(msg) {
if (msg.opcode === 'client/welcome') {
const r = msg.result || {};
const hereIds = r.here ? Object.keys(r.here) : [];
const entityKeys = r.entities ? Object.keys(r.entities) : [];
return `id=${r.id} name=${r.name} reconnect=${r.reconnect} here=[${hereIds}] entities=[${entityKeys}]`;
}
if (msg.opcode === 'object') {
const r = msg.result || {};
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
return `key=${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
}
if (msg.opcode === 'client/connected') {
const r = msg.result || {};
return `id=${r.id} userId=${r.userId} name=${r.name} role=${r.role}`;
}
if (msg.opcode === 'client/disconnected') {
const r = msg.result || {};
return `id=${r.id} role=${r.role}`;
}
return JSON.stringify(msg.result || msg).slice(0, 150);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,576 @@
const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');
describe('EcastShardClient', () => {
describe('parsePlayersFromHere', () => {
test('counts only player roles, excludes host and shard', () => {
const here = {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
'3': { id: 3, roles: { player: { name: 'Bob' } } },
'5': { id: 5, roles: { shard: {} } },
};
const result = EcastShardClient.parsePlayersFromHere(here);
expect(result.playerCount).toBe(2);
expect(result.playerNames).toEqual(['Alice', 'Bob']);
});
test('returns zero for empty here or host-only', () => {
const here = { '1': { id: 1, roles: { host: {} } } };
const result = EcastShardClient.parsePlayersFromHere(here);
expect(result.playerCount).toBe(0);
expect(result.playerNames).toEqual([]);
});
test('handles null or undefined here', () => {
expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
});
});
describe('parseRoomEntity', () => {
test('extracts lobby state from room entity val', () => {
const roomVal = {
state: 'Lobby',
lobbyState: 'CanStart',
gameCanStart: true,
gameIsStarting: false,
gameFinished: false,
};
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameState).toBe('Lobby');
expect(result.lobbyState).toBe('CanStart');
expect(result.gameCanStart).toBe(true);
expect(result.gameStarted).toBe(false);
expect(result.gameFinished).toBe(false);
});
test('detects game started from Gameplay state', () => {
const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameStarted).toBe(true);
});
test('detects game finished', () => {
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
const result = EcastShardClient.parseRoomEntity(roomVal);
expect(result.gameFinished).toBe(true);
});
});
describe('parsePlayerJoinFromTextDescriptions', () => {
test('extracts player name from join description', () => {
const val = {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
]
};
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
});
test('extracts VIP join', () => {
const val = {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
]
};
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
});
test('returns empty array for no joins', () => {
const val = { latestDescriptions: [] };
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
});
test('handles null/undefined val', () => {
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(null)).toEqual([]);
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(undefined)).toEqual([]);
});
});
describe('constructor', () => {
test('initializes with correct defaults', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
expect(client.sessionId).toBe(1);
expect(client.gameId).toBe(5);
expect(client.roomCode).toBe('TEST');
expect(client.maxPlayers).toBe(8);
expect(client.playerCount).toBe(0);
expect(client.playerNames).toEqual([]);
expect(client.gameStarted).toBe(false);
expect(client.gameFinished).toBe(false);
expect(client.appTag).toBeNull();
expect(client.ws).toBeNull();
});
});
describe('handleWelcome', () => {
test('parses welcome message and sets internal state', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.handleWelcome({
id: 7,
secret: 'abc-123',
reconnect: false,
entities: {
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
},
here: {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
'3': { id: 3, roles: { player: { name: 'Bob' } } },
}
});
expect(client.shardId).toBe(7);
expect(client.secret).toBe('abc-123');
expect(client.playerCount).toBe(2);
expect(client.playerNames).toEqual(['Alice', 'Bob']);
expect(client.gameState).toBe('Lobby');
expect(client.lobbyState).toBe('CanStart');
expect(client.gameStarted).toBe(false);
});
test('parses bc:room entity when room key is absent', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.handleWelcome({
id: 5,
secret: 'xyz-789',
reconnect: false,
entities: {
'bc:room': ['object', { key: 'bc:room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }],
audience: ['crdt/pn-counter', [], { locked: false }],
},
here: {
'1': { id: 1, roles: { host: {} } },
'3': { id: 3, roles: { player: { name: 'HÂM' } } },
'4': { id: 4, roles: { player: { name: 'FGHFGHY' } } },
}
});
expect(client.playerCount).toBe(2);
expect(client.playerNames).toEqual(['HÂM', 'FGHFGHY']);
expect(client.gameState).toBe('Lobby');
expect(client.lobbyState).toBe('CanStart');
});
});
describe('handleEntityUpdate', () => {
test('updates room state on room entity update', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.gameState = 'Lobby';
client.lobbyState = 'WaitingForMore';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
version: 5,
from: 1
});
expect(client.gameState).toBe('Gameplay');
expect(client.gameStarted).toBe(true);
});
test('handles bc:room key as room update', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.handleEntityUpdate({
key: 'bc:room',
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 1,
from: 1
});
expect(client.lobbyState).toBe('CanStart');
});
});
describe('event broadcasting', () => {
let events;
let client;
beforeEach(() => {
events = [];
client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
});
describe('handleWelcome broadcasts room.connected', () => {
test('broadcasts room.connected with initial state', () => {
client.appTag = 'drawful2international';
client.handleWelcome({
id: 7,
secret: 'abc',
reconnect: false,
entities: {
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
},
here: {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
}
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('room.connected');
expect(events[0].data.playerCount).toBe(1);
expect(events[0].data.players).toEqual(['Alice']);
expect(events[0].data.lobbyState).toBe('CanStart');
});
});
describe('handleEntityUpdate broadcasts events', () => {
test('broadcasts lobby.updated on lobbyState change', () => {
client.lobbyState = 'WaitingForMore';
client.gameState = 'Lobby';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 2, from: 1,
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.updated');
expect(events[0].data.lobbyState).toBe('CanStart');
});
test('broadcasts game.started on state transition to Gameplay', () => {
client.lobbyState = 'Countdown';
client.gameState = 'Lobby';
client.gameStarted = false;
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
version: 5, from: 1,
});
const startEvents = events.filter(e => e.type === 'game.started');
expect(startEvents).toHaveLength(1);
expect(startEvents[0].data.playerCount).toBe(4);
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
});
test('does not broadcast game.started if already started', () => {
client.gameStarted = true;
client.gameState = 'Gameplay';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 10, from: 1,
});
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
});
test('broadcasts game.ended on gameFinished transition', () => {
client.gameStarted = true;
client.gameState = 'Gameplay';
client.gameFinished = false;
client.playerCount = 3;
client.playerNames = ['X', 'Y', 'Z'];
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true },
version: 20, from: 1,
});
const endEvents = events.filter(e => e.type === 'game.ended');
expect(endEvents).toHaveLength(1);
expect(endEvents[0].data.playerCount).toBe(3);
});
test('broadcasts lobby.player-joined from textDescriptions', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleEntityUpdate({
key: 'textDescriptions',
val: {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
]
},
version: 3, from: 1,
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.player-joined');
expect(events[0].data.playerName).toBe('Bob');
expect(events[0].data.playerCount).toBe(2);
expect(events[0].data.players).toEqual(['Alice', 'Bob']);
});
test('does not broadcast duplicate player join', () => {
client.playerNames = ['Alice', 'Bob'];
client.playerCount = 2;
client.handleEntityUpdate({
key: 'textDescriptions',
val: {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
]
},
version: 4, from: 1,
});
expect(events).toHaveLength(0);
});
});
describe('handleClientConnected', () => {
test('broadcasts lobby.player-joined for new player connection', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleClientConnected({
id: 3,
roles: { player: { name: 'Charlie' } },
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.player-joined');
expect(events[0].data.playerName).toBe('Charlie');
expect(events[0].data.playerCount).toBe(2);
});
test('ignores non-player connections', () => {
client.handleClientConnected({
id: 5,
roles: { shard: {} },
});
expect(events).toHaveLength(0);
});
test('ignores duplicate player connection', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleClientConnected({
id: 2,
roles: { player: { name: 'Alice' } },
});
expect(events).toHaveLength(0);
});
});
});
describe('buildReconnectUrl', () => {
test('uses stored secret and id', () => {
const client = new EcastShardClient({
sessionId: 1,
gameId: 1,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: () => {},
});
client.secret = 'abc-123';
client.shardId = 5;
client.host = 'ecast-prod-use2.jackboxgames.com';
const url = client.buildReconnectUrl();
expect(url).toContain('secret=abc-123');
expect(url).toContain('id=5');
expect(url).toContain('role=shard');
expect(url).toContain('ecast-prod-use2.jackboxgames.com');
});
});
describe('getSnapshot', () => {
test('returns correct shape with current state', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'LSBN', maxPlayers: 8, onEvent: () => {}
});
client.appTag = 'drawful2international';
client.playerCount = 3;
client.playerNames = ['Alice', 'Bob', 'Charlie'];
client.lobbyState = 'CanStart';
client.gameState = 'Lobby';
client.gameStarted = false;
client.gameFinished = false;
const snapshot = client.getSnapshot();
expect(snapshot).toEqual({
sessionId: 1,
gameId: 5,
roomCode: 'LSBN',
appTag: 'drawful2international',
maxPlayers: 8,
playerCount: 3,
players: ['Alice', 'Bob', 'Charlie'],
lobbyState: 'CanStart',
gameState: 'Lobby',
gameStarted: false,
gameFinished: false,
monitoring: true,
});
});
test('returns a defensive copy of playerNames', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.playerNames = ['Alice'];
const snapshot = client.getSnapshot();
snapshot.players.push('Mutated');
expect(client.playerNames).toEqual(['Alice']);
});
});
describe('startStatusBroadcast / stopStatusBroadcast', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
function stubRefresh(client) {
client._refreshPlayerCount = () => Promise.resolve();
}
test('broadcasts game.status every 20 seconds', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.playerCount = 2;
client.playerNames = ['A', 'B'];
client.gameState = 'Lobby';
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(1);
expect(events[0].type).toBe('game.status');
expect(events[0].data.monitoring).toBe(true);
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(2);
client.stopStatusBroadcast();
jest.advanceTimersByTime(40000);
await Promise.resolve();
expect(events).toHaveLength(2);
});
test('disconnect stops the status broadcast', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
await Promise.resolve();
expect(events).toHaveLength(1);
client.disconnect();
jest.advanceTimersByTime(40000);
await Promise.resolve();
expect(events).toHaveLength(1);
});
test('handleWelcome starts status broadcast', async () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
stubRefresh(client);
client.handleWelcome({
id: 7,
secret: 'abc',
reconnect: false,
entities: {},
here: {},
});
jest.advanceTimersByTime(20000);
await Promise.resolve();
const statusEvents = events.filter(e => e.type === 'game.status');
expect(statusEvents).toHaveLength(1);
});
});
describe('module exports', () => {
const { startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot } = require('../../backend/utils/ecast-shard-client');
test('startMonitor is exported', () => {
expect(typeof startMonitor).toBe('function');
});
test('stopMonitor is exported', () => {
expect(typeof stopMonitor).toBe('function');
});
test('cleanupAllShards is exported', () => {
expect(typeof cleanupAllShards).toBe('function');
});
test('getMonitorSnapshot is exported', () => {
expect(typeof getMonitorSnapshot).toBe('function');
});
test('getMonitorSnapshot returns null when no shard active', () => {
const snapshot = getMonitorSnapshot(999, 999);
expect(snapshot).toBeNull();
});
});
describe('handleError with code 2027', () => {
test('marks game as finished and emits events on room-closed error', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleError({ code: 2027, msg: 'the room has already been closed' });
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);
});
});
});

View File

@@ -0,0 +1,11 @@
const { getRoomInfo, checkRoomStatus } = require('../../backend/utils/jackbox-api');
describe('jackbox-api exports', () => {
test('getRoomInfo is exported as a function', () => {
expect(typeof getRoomInfo).toBe('function');
});
test('checkRoomStatus is still exported', () => {
expect(typeof checkRoomStatus).toBe('function');
});
});

View File

@@ -0,0 +1,62 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame } = require('../helpers/test-utils');
const db = require('../../backend/database');
describe('GET /api/games (regression)', () => {
beforeEach(() => {
cleanDb();
});
test('returns games with vote fields', async () => {
seedGame({
title: 'Quiplash 3',
upvotes: 10,
downvotes: 3,
popularity_score: 7,
});
const res = await request(app).get('/api/games');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toEqual(
expect.objectContaining({
title: 'Quiplash 3',
upvotes: 10,
downvotes: 3,
popularity_score: 7,
})
);
});
test('GET /api/games/:id returns vote fields', async () => {
const game = seedGame({
title: 'Drawful 2',
upvotes: 5,
downvotes: 2,
popularity_score: 3,
});
const res = await request(app).get(`/api/games/${game.id}`);
expect(res.status).toBe(200);
expect(res.body.upvotes).toBe(5);
expect(res.body.downvotes).toBe(2);
expect(res.body.popularity_score).toBe(3);
});
test('vote aggregates update correctly after recording votes', async () => {
const game = seedGame({ title: 'Fibbage 4' });
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
db.prepare('UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?').run(game.id);
const res = await request(app).get(`/api/games/${game.id}`);
expect(res.body.upvotes).toBe(2);
expect(res.body.downvotes).toBe(1);
expect(res.body.popularity_score).toBe(1);
});
});

View File

@@ -0,0 +1,71 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
describe('GET /api/sessions (regression)', () => {
beforeEach(() => {
cleanDb();
});
test('GET /api/sessions/:id returns session object', async () => {
const session = seedSession({ is_active: 1, notes: 'Test session' });
const res = await request(app).get(`/api/sessions/${session.id}`);
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.objectContaining({
id: session.id,
is_active: 1,
notes: 'Test session',
})
);
expect(res.body).toHaveProperty('games_played');
});
test('GET /api/sessions/:id returns 404 for nonexistent session', async () => {
const res = await request(app).get('/api/sessions/99999');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Session not found');
});
test('GET /api/sessions/:id/games returns games with expected shape', async () => {
const game = seedGame({
title: 'Quiplash 3',
pack_name: 'Party Pack 7',
min_players: 3,
max_players: 8,
});
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game.id, { status: 'playing' });
const res = await request(app).get(`/api/sessions/${session.id}/games`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toEqual(
expect.objectContaining({
game_id: game.id,
session_id: session.id,
pack_name: 'Party Pack 7',
title: 'Quiplash 3',
min_players: 3,
max_players: 8,
status: 'playing',
})
);
expect(res.body[0]).toHaveProperty('upvotes');
expect(res.body[0]).toHaveProperty('downvotes');
expect(res.body[0]).toHaveProperty('popularity_score');
});
test('GET /api/sessions/:id/games returns empty array for session with no games', async () => {
const session = seedSession({ is_active: 1 });
const res = await request(app).get(`/api/sessions/${session.id}/games`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});

View File

@@ -0,0 +1,205 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils');
describe('POST /api/votes/live (regression)', () => {
let game, session, sessionGame;
const baseTime = '2026-03-15T20:00:00.000Z';
beforeEach(() => {
cleanDb();
game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
session = seedSession({ is_active: 1 });
sessionGame = seedSessionGame(session.id, game.id, {
status: 'playing',
played_at: baseTime,
});
});
test('returns 200 with correct response shape for upvote', 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',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.session).toEqual(
expect.objectContaining({ id: session.id })
);
expect(res.body.game).toEqual(
expect.objectContaining({
id: game.id,
title: 'Quiplash 3',
upvotes: 1,
downvotes: 0,
popularity_score: 1,
})
);
expect(res.body.vote).toEqual({
username: 'viewer1',
type: 'up',
timestamp: '2026-03-15T20:05:00.000Z',
});
const voteRow = db.prepare('SELECT * FROM live_votes WHERE username = ?').get('viewer1');
expect(voteRow).toBeDefined();
expect(voteRow.session_id).toBe(session.id);
expect(voteRow.game_id).toBe(game.id);
expect(voteRow.vote_type).toBe(1);
});
test('increments downvotes and decrements popularity_score for downvote', async () => {
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',
});
expect(res.status).toBe(200);
expect(res.body.game.downvotes).toBe(1);
expect(res.body.game.popularity_score).toBe(-1);
});
test('returns 400 for missing username', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ vote: 'up', timestamp: '2026-03-15T20:05:00.000Z' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Missing required fields/);
});
test('returns 400 for missing vote', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ username: 'viewer1', timestamp: '2026-03-15T20:05:00.000Z' });
expect(res.status).toBe(400);
});
test('returns 400 for missing timestamp', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ username: 'viewer1', vote: 'up' });
expect(res.status).toBe(400);
});
test('returns 400 for invalid vote value', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({
username: 'viewer1',
vote: 'maybe',
timestamp: '2026-03-15T20:05:00.000Z',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/vote must be either/);
});
test('returns 400 for invalid timestamp format', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({
username: 'viewer1',
vote: 'up',
timestamp: 'not-a-date',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid timestamp/);
});
test('returns 404 when no active session', async () => {
cleanDb();
seedGame({ title: 'Unused' });
seedSession({ is_active: 0 });
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',
});
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/No active session/);
});
test('returns 404 when vote timestamp does not match any 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: '2020-01-01T00:00:00.000Z',
});
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/does not match any game/);
});
test('returns 409 for duplicate vote within 1 second', 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',
});
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.500Z',
});
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/Duplicate vote/);
});
test('returns 401 without auth token', async () => {
const res = await request(app)
.post('/api/votes/live')
.set('Content-Type', 'application/json')
.send({
username: 'viewer1',
vote: 'up',
timestamp: '2026-03-15T20:05:00.000Z',
});
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,153 @@
const WebSocket = require('ws');
const request = require('supertest');
const { app, server } = require('../../backend/server');
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession } = require('../helpers/test-utils');
function connectWs() {
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
}
function waitForMessage(ws, type, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
ws.on('message', function handler(data) {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timeout);
ws.removeListener('message', handler);
resolve(msg);
}
});
});
}
function authenticateAndSubscribe(ws, sessionId) {
return new Promise(async (resolve, reject) => {
try {
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
await waitForMessage(ws, 'subscribed');
resolve();
} catch (err) {
reject(err);
}
});
}
beforeAll((done) => {
server.listen(0, () => done());
});
afterAll((done) => {
server.close(done);
});
describe('WebSocket events (regression)', () => {
beforeEach(() => {
cleanDb();
});
test('auth flow: auth -> auth_success', (done) => {
const ws = connectWs();
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
ws.close();
done();
}
});
});
test('subscribe/unsubscribe flow', async () => {
const session = seedSession({ is_active: 1 });
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
const subMsg = await waitForMessage(ws, 'subscribed');
expect(subMsg.sessionId).toBe(session.id);
ws.send(JSON.stringify({ type: 'unsubscribe', sessionId: session.id }));
const unsubMsg = await waitForMessage(ws, 'unsubscribed');
expect(unsubMsg.sessionId).toBe(session.id);
ws.close();
});
test('session.started broadcasts to all authenticated clients', async () => {
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
const eventPromise = waitForMessage(ws, 'session.started');
await request(app)
.post('/api/sessions')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ notes: 'Test session' });
const event = await eventPromise;
expect(event.data.session).toEqual(
expect.objectContaining({
is_active: 1,
notes: 'Test session',
})
);
ws.close();
});
test('session.ended broadcasts to session subscribers', async () => {
const session = seedSession({ is_active: 1 });
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
await authenticateAndSubscribe(ws, session.id);
const eventPromise = waitForMessage(ws, 'session.ended');
await request(app)
.post(`/api/sessions/${session.id}/close`)
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json');
const event = await eventPromise;
expect(event.data.session.id).toBe(session.id);
expect(event.data.session.is_active).toBe(0);
ws.close();
});
test('game.added broadcasts to session subscribers', async () => {
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
const session = seedSession({ is_active: 1 });
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
await authenticateAndSubscribe(ws, session.id);
const eventPromise = waitForMessage(ws, 'game.added');
await request(app)
.post(`/api/sessions/${session.id}/games`)
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({ game_id: game.id });
const event = await eventPromise;
expect(event.data.game.title).toBe('Quiplash 3');
expect(event.data.session.id).toBe(session.id);
ws.close();
});
});

View File

@@ -0,0 +1,94 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
describe('GET /api/sessions/:id/votes', () => {
beforeEach(() => {
cleanDb();
});
test('returns per-game vote breakdown for a session', async () => {
const game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
const game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id);
seedSessionGame(session.id, game2.id);
seedVote(session.id, game1.id, 'user1', 'up');
seedVote(session.id, game1.id, 'user2', 'up');
seedVote(session.id, game1.id, 'user3', 'down');
seedVote(session.id, game2.id, 'user1', 'down');
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.status).toBe(200);
expect(res.body.session_id).toBe(session.id);
expect(res.body.votes).toHaveLength(2);
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
expect(q3.title).toBe('Quiplash 3');
expect(q3.pack_name).toBe('Party Pack 7');
expect(q3.upvotes).toBe(2);
expect(q3.downvotes).toBe(1);
expect(q3.net_score).toBe(1);
expect(q3.total_votes).toBe(3);
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
expect(d2.upvotes).toBe(0);
expect(d2.downvotes).toBe(1);
expect(d2.net_score).toBe(-1);
expect(d2.total_votes).toBe(1);
});
test('returns empty votes array when session has no votes', async () => {
const session = seedSession({ is_active: 1 });
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.status).toBe(200);
expect(res.body.session_id).toBe(session.id);
expect(res.body.votes).toEqual([]);
});
test('returns 404 for nonexistent session', async () => {
const res = await request(app).get('/api/sessions/99999/votes');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Session not found');
});
test('only includes votes from the requested session', async () => {
const game = seedGame({ title: 'Quiplash 3' });
const session1 = seedSession({ is_active: 0 });
const session2 = seedSession({ is_active: 1 });
seedSessionGame(session1.id, game.id);
seedSessionGame(session2.id, game.id);
seedVote(session1.id, game.id, 'user1', 'up');
seedVote(session1.id, game.id, 'user2', 'up');
seedVote(session2.id, game.id, 'user3', 'down');
const res = await request(app).get(`/api/sessions/${session1.id}/votes`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].upvotes).toBe(2);
expect(res.body.votes[0].downvotes).toBe(0);
});
test('results are ordered by net_score descending', async () => {
const game1 = seedGame({ title: 'Good Game' });
const game2 = seedGame({ title: 'Bad Game' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id);
seedSessionGame(session.id, game2.id);
seedVote(session.id, game2.id, 'user1', 'down');
seedVote(session.id, game2.id, 'user2', 'down');
seedVote(session.id, game1.id, 'user1', 'up');
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.body.votes[0].title).toBe('Good Game');
expect(res.body.votes[1].title).toBe('Bad Game');
});
});

166
tests/api/votes-get.test.js Normal file
View File

@@ -0,0 +1,166 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
describe('GET /api/votes', () => {
let game1, game2, session;
beforeEach(() => {
cleanDb();
game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id);
seedSessionGame(session.id, game2.id);
});
test('returns all votes with pagination metadata', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes');
expect(res.status).toBe(200);
expect(res.body.votes).toHaveLength(2);
expect(res.body.pagination).toEqual({
page: 1,
limit: 50,
total: 2,
total_pages: 1,
});
});
test('returns vote_type as "up"/"down" not raw integers', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes');
const types = res.body.votes.map((v) => v.vote_type);
expect(types).toContain('up');
expect(types).toContain('down');
expect(types).not.toContain(1);
expect(types).not.toContain(-1);
});
test('includes game_title and pack_name via join', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
const res = await request(app).get('/api/votes');
expect(res.body.votes[0].game_title).toBe('Quiplash 3');
expect(res.body.votes[0].pack_name).toBe('Party Pack 7');
});
test('filters by session_id', async () => {
const session2 = seedSession({ is_active: 0 });
seedSessionGame(session2.id, game1.id);
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session2.id, game1.id, 'user2', 'up', '2026-03-15T21:01:00.000Z');
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].session_id).toBe(session.id);
expect(res.body.pagination.total).toBe(1);
});
test('filters by game_id', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game2.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get(`/api/votes?game_id=${game1.id}`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].game_id).toBe(game1.id);
});
test('filters by username', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes?username=user1');
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].username).toBe('user1');
});
test('filters by vote_type', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes?vote_type=up');
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].vote_type).toBe('up');
});
test('combines multiple filters', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
seedVote(session.id, game2.id, 'user1', 'up', '2026-03-15T20:03:00.000Z');
const res = await request(app).get(
`/api/votes?game_id=${game1.id}&username=user1`
);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].username).toBe('user1');
expect(res.body.votes[0].game_id).toBe(game1.id);
});
test('respects page and limit', async () => {
for (let i = 0; i < 5; i++) {
seedVote(session.id, game1.id, `user${i}`, 'up', `2026-03-15T20:0${i}:00.000Z`);
}
const res = await request(app).get('/api/votes?page=2&limit=2');
expect(res.body.votes).toHaveLength(2);
expect(res.body.pagination).toEqual({
page: 2,
limit: 2,
total: 5,
total_pages: 3,
});
});
test('caps limit at 100', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
const res = await request(app).get('/api/votes?limit=500');
expect(res.body.pagination.limit).toBe(100);
});
test('returns 200 with empty array when no votes match', async () => {
const res = await request(app).get('/api/votes?username=nonexistent');
expect(res.status).toBe(200);
expect(res.body.votes).toEqual([]);
expect(res.body.pagination.total).toBe(0);
});
test('returns 400 for invalid session_id', async () => {
const res = await request(app).get('/api/votes?session_id=abc');
expect(res.status).toBe(400);
});
test('returns 400 for invalid vote_type', async () => {
const res = await request(app).get('/api/votes?vote_type=maybe');
expect(res.status).toBe(400);
});
test('orders by timestamp descending', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:05:00.000Z');
const res = await request(app).get('/api/votes');
const timestamps = res.body.votes.map((v) => v.timestamp);
expect(timestamps[0]).toBe('2026-03-15T20:05:00.000Z');
expect(timestamps[1]).toBe('2026-03-15T20:01:00.000Z');
});
});

View File

@@ -0,0 +1,165 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils');
describe('POST /api/votes/live -> read-back (end-to-end)', () => {
let game1, game2, session;
const baseTime = '2026-03-15T20:00:00.000Z';
const game2Time = '2026-03-15T20:30:00.000Z';
beforeEach(() => {
cleanDb();
game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id, { status: 'played', played_at: baseTime });
seedSessionGame(session.id, game2.id, { status: 'playing', played_at: game2Time });
});
test('vote via POST populates live_votes table (direct DB check)', 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:35:00.000Z',
});
expect(res.status).toBe(200);
const row = db.prepare(
'SELECT * FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ?'
).get(session.id, game2.id, 'viewer1');
expect(row).toBeDefined();
expect(row.session_id).toBe(session.id);
expect(row.game_id).toBe(game2.id);
expect(row.username).toBe('viewer1');
expect(row.vote_type).toBe(1);
expect(row.timestamp).toBe('2026-03-15T20:35:00.000Z');
});
test('vote via POST is visible in GET /api/sessions/:id/votes', 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:35:00.000Z',
});
await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({
username: 'viewer2',
vote: 'down',
timestamp: '2026-03-15T20:36:00.000Z',
});
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.status).toBe(200);
expect(res.body.session_id).toBe(session.id);
expect(res.body.votes).toHaveLength(1);
const gameVotes = res.body.votes[0];
expect(gameVotes.game_id).toBe(game2.id);
expect(gameVotes.title).toBe('Drawful 2');
expect(gameVotes.upvotes).toBe(1);
expect(gameVotes.downvotes).toBe(1);
expect(gameVotes.net_score).toBe(0);
expect(gameVotes.total_votes).toBe(2);
});
test('vote via POST is visible in GET /api/votes', 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:35:00.000Z',
});
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
expect(res.status).toBe(200);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].username).toBe('viewer1');
expect(res.body.votes[0].vote_type).toBe('up');
expect(res.body.votes[0].game_id).toBe(game2.id);
expect(res.body.votes[0].game_title).toBe('Drawful 2');
expect(res.body.votes[0].session_id).toBe(session.id);
});
test('votes for different games in same session are tracked separately', 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:10:00.000Z',
});
await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({
username: 'viewer2',
vote: 'down',
timestamp: '2026-03-15T20:35:00.000Z',
});
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.status).toBe(200);
expect(res.body.votes).toHaveLength(2);
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
expect(q3.upvotes).toBe(1);
expect(q3.downvotes).toBe(0);
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
expect(d2.upvotes).toBe(0);
expect(d2.downvotes).toBe(1);
});
test('live_votes row count matches number of accepted votes', async () => {
const timestamps = [
'2026-03-15T20:35:00.000Z',
'2026-03-15T20:36:05.000Z',
'2026-03-15T20:37:10.000Z',
];
for (let i = 0; i < timestamps.length; i++) {
const res = await request(app)
.post('/api/votes/live')
.set('Authorization', getAuthHeader())
.set('Content-Type', 'application/json')
.send({
username: `viewer${i}`,
vote: i % 2 === 0 ? 'up' : 'down',
timestamp: timestamps[i],
});
expect(res.status).toBe(200);
}
const count = db.prepare(
'SELECT COUNT(*) as cnt FROM live_votes WHERE session_id = ?'
).get(session.id);
expect(count.cnt).toBe(3);
const sessionVotes = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
});
});

View File

@@ -0,0 +1,166 @@
const WebSocket = require('ws');
const request = require('supertest');
const { app, server } = require('../../backend/server');
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
function connectWs() {
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
}
function waitForMessage(ws, type, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
ws.on('message', function handler(data) {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timeout);
ws.removeListener('message', handler);
resolve(msg);
}
});
});
}
beforeAll((done) => {
server.listen(0, () => done());
});
afterAll((done) => {
server.close(done);
});
describe('vote.received WebSocket event', () => {
const baseTime = '2026-03-15T20:00:00.000Z';
beforeEach(() => {
cleanDb();
});
test('broadcasts vote.received to session subscribers on live vote', async () => {
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
await waitForMessage(ws, 'subscribed');
const eventPromise = waitForMessage(ws, 'vote.received');
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',
});
const event = await eventPromise;
expect(event.data.sessionId).toBe(session.id);
expect(event.data.game).toEqual({
id: game.id,
title: 'Quiplash 3',
pack_name: 'Party Pack 7',
});
expect(event.data.vote).toEqual({
username: 'viewer1',
type: 'up',
timestamp: '2026-03-15T20:05:00.000Z',
});
expect(event.data.totals).toEqual({
upvotes: 1,
downvotes: 0,
popularity_score: 1,
});
ws.close();
});
test('does not broadcast on duplicate vote (409)', async () => {
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
await waitForMessage(ws, 'subscribed');
// First vote succeeds - set up listener before POST to catch the event
const firstEventPromise = waitForMessage(ws, 'vote.received');
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',
});
await firstEventPromise;
// Duplicate vote (within 1 second)
const dupRes = 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.500Z',
});
expect(dupRes.status).toBe(409);
// Verify no vote.received event comes (wait briefly)
const noEvent = await Promise.race([
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
]);
expect(noEvent).toBe('timeout');
ws.close();
});
test('does not broadcast when no active session (404)', async () => {
const ws = connectWs();
await new Promise((resolve) => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
await waitForMessage(ws, 'auth_success');
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',
});
expect(res.status).toBe(404);
const noEvent = await Promise.race([
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
]);
expect(noEvent).toBe('timeout');
ws.close();
});
});

View File

@@ -0,0 +1,81 @@
const jwt = require('jsonwebtoken');
const db = require('../../backend/database');
function getAuthToken() {
return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
}
function getAuthHeader() {
return `Bearer ${getAuthToken()}`;
}
function cleanDb() {
db.exec('DELETE FROM live_votes');
db.exec('DELETE FROM chat_logs');
db.exec('DELETE FROM session_games');
db.exec('DELETE FROM sessions');
db.exec('DELETE FROM webhook_logs');
db.exec('DELETE FROM webhooks');
db.exec('DELETE FROM games');
db.exec('DELETE FROM packs');
}
function seedGame(overrides = {}) {
const defaults = {
pack_name: 'Party Pack 7',
title: 'Quiplash 3',
min_players: 3,
max_players: 8,
length_minutes: 15,
has_audience: 1,
family_friendly: 1,
game_type: 'Writing',
enabled: 1,
upvotes: 0,
downvotes: 0,
popularity_score: 0,
};
const g = { ...defaults, ...overrides };
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)
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);
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
}
function seedSession(overrides = {}) {
const defaults = { is_active: 1, notes: null };
const s = { ...defaults, ...overrides };
const result = db.prepare('INSERT INTO sessions (is_active, notes) VALUES (?, ?)').run(s.is_active, s.notes);
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
}
function seedSessionGame(sessionId, gameId, overrides = {}) {
const defaults = { status: 'playing', played_at: new Date().toISOString() };
const sg = { ...defaults, ...overrides };
const result = db.prepare(`
INSERT INTO session_games (session_id, game_id, status, played_at)
VALUES (?, ?, ?, ?)
`).run(sessionId, gameId, sg.status, sg.played_at);
return db.prepare('SELECT * FROM session_games WHERE id = ?').get(result.lastInsertRowid);
}
function seedVote(sessionId, gameId, username, voteType, timestamp) {
const vt = voteType === 'up' ? 1 : -1;
const ts = timestamp || new Date().toISOString();
db.prepare(`
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
VALUES (?, ?, ?, ?, ?)
`).run(sessionId, gameId, username, vt, ts);
}
module.exports = {
getAuthToken,
getAuthHeader,
cleanDb,
seedGame,
seedSession,
seedSessionGame,
seedVote,
db,
};

4
tests/jest.setup.js Normal file
View File

@@ -0,0 +1,4 @@
process.env.DB_PATH = ':memory:';
process.env.JWT_SECRET = 'test-jwt-secret-do-not-use-in-prod';
process.env.ADMIN_KEY = 'test-admin-key';
process.env.PORT = '0';