From ea6e8db90b58de5b8ca8a8416822986bc02c400c Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 5 Apr 2026 04:27:58 -0400 Subject: [PATCH] feat: ticker symbol voting for live votes - Add ticker column to games table with migration - Bootstrap tickers from tickers.json config on startup - POST /api/votes/live accepts optional ticker field for direct game lookup (bypasses timestamp-interval matching) - Ticker votes work for any game, not just session games - Update API docs and add e2e tests for ticker voting - Version bump to 0.6.5 Made-with: Cursor --- backend/bootstrap.js | 29 +++++++- backend/config/tickers.json | 60 ++++++++++++++++ backend/database.js | 13 ++++ backend/routes/votes.js | 113 ++++++++++++++++++------------- backend/server.js | 3 +- docs/api/endpoints/votes.md | 55 +++++++++++++-- frontend/src/config/branding.js | 2 +- tests/api/votes-live-e2e.test.js | 101 +++++++++++++++++++++++++++ tests/helpers/test-utils.js | 7 +- 9 files changed, 322 insertions(+), 61 deletions(-) create mode 100644 backend/config/tickers.json diff --git a/backend/bootstrap.js b/backend/bootstrap.js index 09e516d..36d8141 100644 --- a/backend/bootstrap.js +++ b/backend/bootstrap.js @@ -54,6 +54,33 @@ function bootstrapGames() { console.log(`Successfully imported ${records.length} games from CSV`); } +function bootstrapTickers() { + const tickersPath = path.join(__dirname, 'config', 'tickers.json'); + + if (!fs.existsSync(tickersPath)) { + console.log('tickers.json not found. Skipping ticker bootstrap.'); + return; + } + + const tickers = JSON.parse(fs.readFileSync(tickersPath, 'utf-8')); + + const update = db.prepare('UPDATE games SET ticker = ? WHERE title = ? AND (ticker IS NULL OR ticker != ?)'); + + const updateMany = db.transaction((entries) => { + let updated = 0; + for (const [symbol, title] of entries) { + const result = update.run(symbol, title, symbol); + updated += result.changes; + } + return updated; + }); + + const updated = updateMany(Object.entries(tickers)); + if (updated > 0) { + console.log(`Updated ticker symbols for ${updated} games`); + } +} + function parseLengthMinutes(lengthStr) { if (!lengthStr || lengthStr === '????' || lengthStr === '?') { return null; @@ -69,5 +96,5 @@ function parseBoolean(value) { return value.toLowerCase() === 'yes' ? 1 : 0; } -module.exports = { bootstrapGames }; +module.exports = { bootstrapGames, bootstrapTickers }; diff --git a/backend/config/tickers.json b/backend/config/tickers.json new file mode 100644 index 0000000..b7ae607 --- /dev/null +++ b/backend/config/tickers.json @@ -0,0 +1,60 @@ +{ + "QPL3": "Quiplash 3", + "QPL2": "Quiplash 2", + "QLXL": "Quiplash XL", + "FBXL": "Fibbage XL", + "FBG2": "Fibbage 2", + "FBG3": "Fibbage 3", + "FBG4": "Fibbage 4", + "TMP1": "Trivia Murder Party", + "TMP2": "Trivia Murder Party 2", + "DRWF": "Drawful", + "DRWA": "Drawful Animate", + "DD": "Dirty Drawful", + "DOOM": "Doominate", + "JJ": "Job Job", + "TKO2": "Tee K.O. 2", + "TKOX": "Tee K.O. T-Shirt Knock Out", + "CU": "Champ'd Up", + "BR": "Blather 'Round", + "STR": "Split the Room", + "ROOM": "Roomerang", + "BRKT": "Bracketeering", + "NNSR": "Nonsensory", + "QXRT": "Quixort", + "JNKT": "Junktopia", + "TP": "Talking Points", + "PS": "Patently Stupid", + "PTB": "Push the Button", + "WD": "Weapons Drawn", + "HPNT": "Hypnotorious", + "DCTN": "Dictionarium", + "RM": "Role Models", + "JB": "Joke Boat", + "GSPN": "Guesspionage", + "MVC": "Mad Verse City", + "HRSY": "Hear Say", + "CH": "Cookie Haus", + "SPCT": "Suspectives", + "LOT": "Legends of Trivia", + "STI": "Survive the Internet", + "CVDL": "Civic Doodle", + "MSM": "Monster Seeking Monster", + "TPM": "The Poll Mine", + "TWEP": "The Wheel of Enormous Proportions", + "TJ": "Time Jinx", + "DRM": "Dodo Re Mi", + "FT": "Fixy Text", + "SS": "Survey Scramble", + "WS": "Word Spud", + "LS": "Lie Swatter", + "FI": "Fakin' It!", + "FANL": "Fakin' It All Night Long", + "LMF": "Let Me Finish", + "BDTS": "Bidiots", + "BC": "Bomb Corp.", + "YDK1": "You Don't Know Jack\u00ae 2015", + "YDKJ": "You Don't Know Jack\u00ae Full Stream", + "ZPDM": "Zeeple Dome", + "EW": "Earwax\u2122" +} diff --git a/backend/database.js b/backend/database.js index 867539f..5b28ffc 100644 --- a/backend/database.js +++ b/backend/database.js @@ -125,6 +125,19 @@ function initializeDatabase() { // Column already exists, ignore error } + // Add ticker column for ticker-symbol voting + try { + db.exec(`ALTER TABLE games ADD COLUMN ticker TEXT`); + } catch (err) { + // Column already exists, ignore error + } + + try { + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_games_ticker ON games(ticker)`); + } catch (err) { + // Index already exists, ignore error + } + // Migrate existing popularity_score to upvotes/downvotes if needed try { const gamesWithScore = db.prepare(` diff --git a/backend/routes/votes.js b/backend/routes/votes.js index 5eaebb1..141a341 100644 --- a/backend/routes/votes.js +++ b/backend/routes/votes.js @@ -89,7 +89,7 @@ router.get('/', (req, res) => { // Live vote endpoint - receives real-time votes from bot router.post('/live', authenticateToken, (req, res) => { try { - const { username, vote, timestamp } = req.body; + const { username, vote, timestamp, ticker } = req.body; // Validate payload if (!username || !vote || !timestamp) { @@ -123,57 +123,72 @@ router.post('/live', authenticateToken, (req, res) => { }); } - // Get all games played in this session with timestamps - const sessionGames = db.prepare(` - SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score - FROM session_games sg - JOIN games g ON sg.game_id = g.id - WHERE sg.session_id = ? - ORDER BY sg.played_at ASC - `).all(activeSession.id); - - if (sessionGames.length === 0) { - return res.status(404).json({ - error: 'No games have been played in the active session yet' - }); - } - - // Match vote timestamp to the correct game using interval logic - const voteTime = voteTimestamp.getTime(); let matchedGame = null; - for (let i = 0; i < sessionGames.length; i++) { - const currentGame = sessionGames[i]; - const nextGame = sessionGames[i + 1]; - - const currentGameTime = new Date(currentGame.played_at).getTime(); - - if (nextGame) { - const nextGameTime = new Date(nextGame.played_at).getTime(); - if (voteTime >= currentGameTime && voteTime < nextGameTime) { - matchedGame = currentGame; - break; - } - } else { - // Last game in session - vote belongs here if timestamp is after this game started - if (voteTime >= currentGameTime) { - matchedGame = currentGame; - break; + if (ticker) { + // Ticker voting: resolve game globally by ticker symbol + const game = db.prepare(` + SELECT id AS game_id, title, pack_name, upvotes, downvotes, popularity_score + FROM games WHERE ticker = ? + `).get(ticker); + + if (!game) { + return res.status(404).json({ + error: `Unknown ticker '${ticker}'`, + }); + } + + matchedGame = game; + } else { + // thisgame++/thisgame-- voting: resolve game by timestamp interval + const sessionGames = db.prepare(` + SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score + FROM session_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.session_id = ? + ORDER BY sg.played_at ASC + `).all(activeSession.id); + + if (sessionGames.length === 0) { + return res.status(404).json({ + error: 'No games have been played in the active session yet' + }); + } + + const voteTime = voteTimestamp.getTime(); + + for (let i = 0; i < sessionGames.length; i++) { + const currentGame = sessionGames[i]; + const nextGame = sessionGames[i + 1]; + + const currentGameTime = new Date(currentGame.played_at).getTime(); + + if (nextGame) { + const nextGameTime = new Date(nextGame.played_at).getTime(); + if (voteTime >= currentGameTime && voteTime < nextGameTime) { + matchedGame = currentGame; + break; + } + } else { + if (voteTime >= currentGameTime) { + matchedGame = currentGame; + break; + } } } - } - if (!matchedGame) { - return res.status(404).json({ - error: 'Vote timestamp does not match any game in the active session', - debug: { - voteTimestamp: timestamp, - sessionGames: sessionGames.map(g => ({ - title: g.title, - played_at: g.played_at - })) - } - }); + if (!matchedGame) { + return res.status(404).json({ + error: 'Vote timestamp does not match any game in the active session', + debug: { + voteTimestamp: timestamp, + sessionGames: sessionGames.map(g => ({ + title: g.title, + played_at: g.played_at + })) + } + }); + } } // Check for duplicate vote (within 1 second window) @@ -258,6 +273,7 @@ router.post('/live', authenticateToken, (req, res) => { id: updatedGame.id, title: updatedGame.title, pack_name: matchedGame.pack_name, + ticker: ticker || undefined, }, vote: { username: username, @@ -303,7 +319,8 @@ router.post('/live', authenticateToken, (req, res) => { vote: { username: username, type: vote, - timestamp: timestamp + timestamp: timestamp, + ticker: ticker || undefined, } }); diff --git a/backend/server.js b/backend/server.js index 29eeafd..ec19c66 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,7 +2,7 @@ require('dotenv').config(); const express = require('express'); const http = require('http'); const cors = require('cors'); -const { bootstrapGames } = require('./bootstrap'); +const { bootstrapGames, bootstrapTickers } = require('./bootstrap'); const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager'); const { cleanupAllShards } = require('./utils/ecast-shard-client'); @@ -50,6 +50,7 @@ setWebSocketManager(wsManager); if (require.main === module) { bootstrapGames(); + bootstrapTickers(); 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`); diff --git a/docs/api/endpoints/votes.md b/docs/api/endpoints/votes.md index c9ab1c8..29a0bd0 100644 --- a/docs/api/endpoints/votes.md +++ b/docs/api/endpoints/votes.md @@ -1,6 +1,9 @@ # Votes Endpoints -Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Votes are matched to the currently-playing game using timestamp intervals. +Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Two voting mechanisms are supported: + +- **`thisgame++`/`thisgame--`** — votes for the game currently being played, matched via timestamp intervals. +- **Ticker voting** — votes for a specific game by its ticker symbol (e.g. `QPL3` for Quiplash 3), regardless of what is currently being played. ## Endpoint Summary @@ -71,7 +74,10 @@ Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `" ## POST /api/votes/live -Submit a real-time up/down vote for the game currently being played. Automatically finds the active session and matches the vote to the correct game using the provided timestamp and session game intervals. +Submit a real-time up/down vote. Supports two independent voting mechanisms: + +- **Ticker voting** — include a `ticker` field to vote for a specific game by symbol. The game is resolved globally and does not need to be in the active session. +- **`thisgame++`/`thisgame--` voting** — omit `ticker` to vote for the game currently being played, matched via timestamp intervals against `session_games`. ### Authentication @@ -84,6 +90,20 @@ Bearer token required. Include in header: `Authorization: Bearer `. | 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 | +| ticker | string | No | Ticker symbol identifying the game (e.g. `QPL3`, `TMP2`). When provided, the game is resolved by ticker and timestamp matching is skipped. | + +**Ticker vote:** + +```json +{ + "username": "viewer123", + "vote": "up", + "timestamp": "2026-03-15T20:30:00Z", + "ticker": "QPL3" +} +``` + +**`thisgame++`/`thisgame--` vote (no ticker):** ```json { @@ -96,7 +116,8 @@ Bearer token required. Include in header: `Authorization: Bearer `. ### 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). +- **With `ticker`:** Looks up the game globally by ticker symbol. The game does not need to be part of the active session. +- **Without `ticker`:** Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps). - Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction. - **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. @@ -105,6 +126,8 @@ Bearer token required. Include in header: `Authorization: Bearer `. **200 OK** +The `ticker` field is included in the response when the vote was submitted with a ticker. + ```json { "success": true, @@ -120,7 +143,8 @@ Bearer token required. Include in header: `Authorization: Bearer `. "vote": { "username": "viewer123", "type": "up", - "timestamp": "2026-03-15T20:30:00Z" + "timestamp": "2026-03-15T20:30:00Z", + "ticker": "QPL3" } } ``` @@ -133,12 +157,29 @@ Bearer token required. Include in header: `Authorization: Bearer `. | 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 | +| 404 | `{ "error": "Unknown ticker 'XYZ'" }` | Ticker does not match any game | +| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games (timestamp voting only) | +| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { ... } }` | Timestamp outside any game interval (timestamp voting only) | | 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second | | 500 | `{ "error": "..." }` | Server error | -### Example +### Examples + +**Ticker vote:** + +```bash +curl -X POST "http://localhost:5000/api/votes/live" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "viewer123", + "vote": "up", + "timestamp": "2026-03-15T20:30:00Z", + "ticker": "QPL3" + }' +``` + +**`thisgame++` vote (no ticker):** ```bash curl -X POST "http://localhost:5000/api/votes/live" \ diff --git a/frontend/src/config/branding.js b/frontend/src/config/branding.js index 58ea4fc..c5f6d46 100644 --- a/frontend/src/config/branding.js +++ b/frontend/src/config/branding.js @@ -2,7 +2,7 @@ export const branding = { app: { name: 'HSO Jackbox Game Picker', shortName: 'Jackbox Game Picker', - version: '0.6.4 - Fish Tank Edition', + version: '0.6.5 - Fish Tank Edition', description: 'Spicing up Hyper Spaceout game nights!', }, meta: { diff --git a/tests/api/votes-live-e2e.test.js b/tests/api/votes-live-e2e.test.js index 212dbfb..23b9e3b 100644 --- a/tests/api/votes-live-e2e.test.js +++ b/tests/api/votes-live-e2e.test.js @@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => { expect(sessionVotes.body.votes[0].total_votes).toBe(3); }); }); + +describe('POST /api/votes/live — ticker voting', () => { + let session, tickerGame, sessionGame; + const baseTime = '2026-03-15T20:00:00.000Z'; + + beforeEach(() => { + cleanDb(); + tickerGame = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', ticker: 'QPL3' }); + sessionGame = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' }); + session = seedSession({ is_active: 1 }); + seedSessionGame(session.id, sessionGame.id, { status: 'playing', played_at: baseTime }); + }); + + test('vote with valid ticker resolves to the correct game', async () => { + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + ticker: 'QPL3', + }); + + expect(res.status).toBe(200); + expect(res.body.game.id).toBe(tickerGame.id); + expect(res.body.game.title).toBe('Quiplash 3'); + expect(res.body.vote.ticker).toBe('QPL3'); + + const row = db.prepare( + 'SELECT * FROM live_votes WHERE game_id = ? AND username = ?' + ).get(tickerGame.id, 'viewer1'); + expect(row).toBeDefined(); + expect(row.vote_type).toBe(1); + }); + + test('ticker vote works for a game not in the active session', async () => { + const outsideGame = seedGame({ title: 'Fibbage XL', pack_name: 'Party Pack 1', ticker: 'FBXL' }); + + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'down', + timestamp: '2026-03-15T20:05:00.000Z', + ticker: 'FBXL', + }); + + expect(res.status).toBe(200); + expect(res.body.game.id).toBe(outsideGame.id); + expect(res.body.game.title).toBe('Fibbage XL'); + }); + + test('unknown ticker returns 404', async () => { + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + ticker: 'NOPE', + }); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/Unknown ticker 'NOPE'/); + }); + + test('ticker vote updates game scores', async () => { + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + ticker: 'QPL3', + }); + + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer2', + vote: 'down', + timestamp: '2026-03-15T20:06:05.000Z', + ticker: 'QPL3', + }); + + const game = db.prepare('SELECT upvotes, downvotes, popularity_score FROM games WHERE id = ?').get(tickerGame.id); + expect(game.upvotes).toBe(1); + expect(game.downvotes).toBe(1); + expect(game.popularity_score).toBe(0); + }); +}); diff --git a/tests/helpers/test-utils.js b/tests/helpers/test-utils.js index df0d3be..16dc61e 100644 --- a/tests/helpers/test-utils.js +++ b/tests/helpers/test-utils.js @@ -34,12 +34,13 @@ function seedGame(overrides = {}) { upvotes: 0, downvotes: 0, popularity_score: 0, + ticker: null, }; 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); + INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score, ticker) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score, g.ticker); return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid); }