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
This commit is contained in:
cottongin
2026-04-05 04:27:58 -04:00
parent b2bb2989e9
commit ea6e8db90b
9 changed files with 322 additions and 61 deletions

29
backend/bootstrap.js vendored
View File

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

View File

@@ -0,0 +1,60 @@
{
"QPL3": "Quiplash 3",
"QPL2": "Quiplash 2",
"QLXL": "Quiplash XL",
"FBXL": "Fibbage XL",
"FBG2": "Fibbage 2",
"FBG3": "Fibbage 3",
"FBG4": "Fibbage 4",
"TMP1": "Trivia Murder Party",
"TMP2": "Trivia Murder Party 2",
"DRWF": "Drawful",
"DRWA": "Drawful Animate",
"DD": "Dirty Drawful",
"DOOM": "Doominate",
"JJ": "Job Job",
"TKO2": "Tee K.O. 2",
"TKOX": "Tee K.O. T-Shirt Knock Out",
"CU": "Champ'd Up",
"BR": "Blather 'Round",
"STR": "Split the Room",
"ROOM": "Roomerang",
"BRKT": "Bracketeering",
"NNSR": "Nonsensory",
"QXRT": "Quixort",
"JNKT": "Junktopia",
"TP": "Talking Points",
"PS": "Patently Stupid",
"PTB": "Push the Button",
"WD": "Weapons Drawn",
"HPNT": "Hypnotorious",
"DCTN": "Dictionarium",
"RM": "Role Models",
"JB": "Joke Boat",
"GSPN": "Guesspionage",
"MVC": "Mad Verse City",
"HRSY": "Hear Say",
"CH": "Cookie Haus",
"SPCT": "Suspectives",
"LOT": "Legends of Trivia",
"STI": "Survive the Internet",
"CVDL": "Civic Doodle",
"MSM": "Monster Seeking Monster",
"TPM": "The Poll Mine",
"TWEP": "The Wheel of Enormous Proportions",
"TJ": "Time Jinx",
"DRM": "Dodo Re Mi",
"FT": "Fixy Text",
"SS": "Survey Scramble",
"WS": "Word Spud",
"LS": "Lie Swatter",
"FI": "Fakin' It!",
"FANL": "Fakin' It All Night Long",
"LMF": "Let Me Finish",
"BDTS": "Bidiots",
"BC": "Bomb Corp.",
"YDK1": "You Don't Know Jack\u00ae 2015",
"YDKJ": "You Don't Know Jack\u00ae Full Stream",
"ZPDM": "Zeeple Dome",
"EW": "Earwax\u2122"
}

View File

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

View File

@@ -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,
}
});

View File

@@ -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`);