we're about to port the chrome-extension. everything else mostly works

This commit is contained in:
cottongin
2025-10-30 13:27:55 -04:00
parent 2db707961c
commit db2a8abe66
29 changed files with 2490 additions and 562 deletions

View File

@@ -2,11 +2,14 @@ FROM node:18-alpine
WORKDIR /app
# Install wget for healthcheck
RUN apk add --no-cache wget
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
RUN npm install --omit=dev
# Copy application files
COPY . .

View File

@@ -5,9 +5,17 @@ const fs = require('fs');
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
const dbDir = path.dirname(dbPath);
// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
// Ensure data directory exists with proper permissions
try {
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true, mode: 0o777 });
}
// Also ensure the directory is writable
fs.accessSync(dbDir, fs.constants.W_OK);
} catch (err) {
console.error(`Error with database directory ${dbDir}:`, err.message);
console.error('Please ensure the directory exists and is writable');
process.exit(1);
}
const db = new Database(dbPath);
@@ -56,10 +64,41 @@ function initializeDatabase() {
game_id INTEGER NOT NULL,
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
manually_added INTEGER DEFAULT 0,
status TEXT DEFAULT 'played',
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
)
`);
// Add status column if it doesn't exist (for existing databases)
try {
db.exec(`ALTER TABLE session_games ADD COLUMN status TEXT DEFAULT 'played'`);
} catch (err) {
// Column already exists, ignore error
}
// Add favor_bias column to games if it doesn't exist
try {
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
} catch (err) {
// Column already exists, ignore error
}
// Packs table for pack-level favoriting
db.exec(`
CREATE TABLE IF NOT EXISTS packs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
favor_bias INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Populate packs table with unique pack names from games
db.exec(`
INSERT OR IGNORE INTO packs (name)
SELECT DISTINCT pack_name FROM games
`);
// Chat logs table
db.exec(`

View File

@@ -73,6 +73,91 @@ router.get('/', (req, res) => {
}
});
// Get all packs with their favor bias (MUST be before /:id)
router.get('/packs', (req, res) => {
try {
const packs = db.prepare('SELECT * FROM packs ORDER BY name').all();
res.json(packs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get pack metadata (MUST be before /:id)
router.get('/meta/packs', (req, res) => {
try {
const packs = db.prepare(`
SELECT
pack_name as name,
COUNT(*) as total_count,
SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) as enabled_count,
SUM(play_count) as total_plays
FROM games
GROUP BY pack_name
ORDER BY pack_name
`).all();
res.json(packs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Export games as CSV (MUST be before /:id)
router.get('/export/csv', authenticateToken, (req, res) => {
try {
const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
const records = games.map(game => ({
'Pack Name': game.pack_name,
'Title': game.title,
'Min Players': game.min_players,
'Max Players': game.max_players,
'Length (minutes)': game.length_minutes || '',
'Audience': game.has_audience ? 'Yes' : 'No',
'Family Friendly': game.family_friendly ? 'Yes' : 'No',
'Game Type': game.game_type || '',
'Secondary Type': game.secondary_type || ''
}));
const csv = stringify(records, { header: true });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="jackbox-games.csv"');
res.send(csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Set favor bias for a pack (MUST be before /:id)
router.patch('/packs/:name/favor', authenticateToken, (req, res) => {
try {
const { favor_bias } = req.body;
// Validate favor_bias value
if (![1, -1, 0].includes(favor_bias)) {
return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' });
}
// Update pack favor bias
const packStmt = db.prepare('UPDATE packs SET favor_bias = ? WHERE name = ?');
const packResult = packStmt.run(favor_bias, req.params.name);
if (packResult.changes === 0) {
// Pack doesn't exist, create it
const insertStmt = db.prepare('INSERT INTO packs (name, favor_bias) VALUES (?, ?)');
insertStmt.run(req.params.name, favor_bias);
}
res.json({
message: 'Pack favor bias updated successfully',
favor_bias
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single game by ID
router.get('/:id', (req, res) => {
try {
@@ -224,25 +309,6 @@ router.patch('/:id/toggle', authenticateToken, (req, res) => {
}
});
// Get list of unique pack names
router.get('/meta/packs', (req, res) => {
try {
const packs = db.prepare(`
SELECT
pack_name,
COUNT(*) as game_count,
SUM(enabled) as enabled_count
FROM games
GROUP BY pack_name
ORDER BY pack_name
`).all();
res.json(packs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Toggle entire pack (admin only)
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
try {
@@ -264,33 +330,6 @@ router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
}
});
// Export games to CSV (admin only)
router.get('/export/csv', authenticateToken, (req, res) => {
try {
const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
const csvData = games.map(game => ({
'Game Pack': game.pack_name,
'Game Title': game.title,
'Min. Players': game.min_players,
'Max. Players': game.max_players,
'Length': game.length_minutes ? `${game.length_minutes} minutes` : '????',
'Audience': game.has_audience ? 'Yes' : 'No',
'Family Friendly?': game.family_friendly ? 'Yes' : 'No',
'Game Type': game.game_type || '',
'Secondary Type': game.secondary_type || ''
}));
const csv = stringify(csvData, { header: true });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=games-export.csv');
res.send(csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Import games from CSV (admin only)
router.post('/import/csv', authenticateToken, (req, res) => {
try {
@@ -360,5 +399,31 @@ function parseBoolean(value) {
return value.toLowerCase() === 'yes' ? 1 : 0;
}
// Set favor bias for a game (1 = favor, -1 = disfavor, 0 = neutral)
router.patch('/:id/favor', authenticateToken, (req, res) => {
try {
const { favor_bias } = req.body;
// Validate favor_bias value
if (![1, -1, 0].includes(favor_bias)) {
return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' });
}
const stmt = db.prepare('UPDATE games SET favor_bias = ? WHERE id = ?');
const result = stmt.run(favor_bias, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Game not found' });
}
res.json({
message: 'Favor bias updated successfully',
favor_bias
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -3,6 +3,68 @@ const db = require('../database');
const router = express.Router();
/**
* Select a game using weighted random selection based on favor bias
*
* Bias system:
* - favor_bias = 1 (favored): 3x weight
* - favor_bias = 0 (neutral): 1x weight
* - favor_bias = -1 (disfavored): 0.2x weight (still possible but less likely)
*
* Also considers pack-level bias
*/
function selectGameWithBias(games) {
if (games.length === 0) return null;
if (games.length === 1) return games[0];
// Get pack biases
const packs = db.prepare('SELECT name, favor_bias FROM packs').all();
const packBiasMap = {};
packs.forEach(pack => {
packBiasMap[pack.name] = pack.favor_bias || 0;
});
// Calculate weights for each game
const weights = games.map(game => {
let weight = 1.0; // Base weight
// Apply game-level bias
const gameBias = game.favor_bias || 0;
if (gameBias === 1) {
weight *= 3.0; // Favored games are 3x more likely
} else if (gameBias === -1) {
weight *= 0.2; // Disfavored games are 5x less likely
}
// Apply pack-level bias
const packBias = packBiasMap[game.pack_name] || 0;
if (packBias === 1) {
weight *= 2.0; // Favored packs are 2x more likely
} else if (packBias === -1) {
weight *= 0.3; // Disfavored packs are ~3x less likely
}
return weight;
});
// Calculate total weight
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
// Pick a random number between 0 and totalWeight
let random = Math.random() * totalWeight;
// Select game based on weighted random
for (let i = 0; i < games.length; i++) {
random -= weights[i];
if (random <= 0) {
return games[i];
}
}
// Fallback (shouldn't reach here)
return games[games.length - 1];
}
// Pick a random game with filters and repeat avoidance
router.post('/pick', (req, res) => {
try {
@@ -11,7 +73,8 @@ router.post('/pick', (req, res) => {
drawing,
length,
familyFriendly,
sessionId
sessionId,
excludePlayed
} = req.body;
// Build query for eligible games
@@ -60,14 +123,25 @@ router.post('/pick', (req, res) => {
// Apply repeat avoidance if session provided
if (sessionId) {
const lastGames = db.prepare(`
SELECT game_id FROM session_games
WHERE session_id = ?
ORDER BY played_at DESC
LIMIT 2
`).all(sessionId);
let lastGamesQuery;
if (excludePlayed) {
// Exclude ALL previously played games in this session
lastGamesQuery = db.prepare(`
SELECT DISTINCT game_id FROM session_games
WHERE session_id = ?
`).all(sessionId);
} else {
// Default: only exclude last 2 games
lastGamesQuery = db.prepare(`
SELECT game_id FROM session_games
WHERE session_id = ?
ORDER BY played_at DESC
LIMIT 2
`).all(sessionId);
}
const excludeIds = lastGames.map(g => g.game_id);
const excludeIds = lastGamesQuery.map(g => g.game_id);
if (excludeIds.length > 0) {
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
@@ -75,16 +149,17 @@ router.post('/pick', (req, res) => {
if (eligibleGames.length === 0) {
return res.status(404).json({
error: 'All eligible games have been played recently',
error: excludePlayed
? 'All eligible games have been played in this session'
: 'All eligible games have been played recently',
suggestion: 'Enable more games or adjust your filters',
recentlyPlayed: excludeIds
});
}
}
// Pick random game from eligible pool
const randomIndex = Math.floor(Math.random() * eligibleGames.length);
const selectedGame = eligibleGames[randomIndex];
// Apply favor biasing to selection
const selectedGame = selectGameWithBias(eligibleGames);
res.json({
game: selectedGame,

View File

@@ -37,8 +37,9 @@ router.get('/active', (req, res) => {
LIMIT 1
`).get();
// Return null instead of 404 when no active session
if (!session) {
return res.status(404).json({ error: 'No active session found' });
return res.json({ session: null, message: 'No active session' });
}
res.json(session);
@@ -114,6 +115,13 @@ router.post('/:id/close', authenticateToken, (req, res) => {
return res.status(400).json({ error: 'Session is already closed' });
}
// Set all 'playing' games to 'played' before closing
db.prepare(`
UPDATE session_games
SET status = 'played'
WHERE session_id = ? AND status = 'playing'
`).run(req.params.id);
const stmt = db.prepare(`
UPDATE sessions
SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes)
@@ -129,6 +137,33 @@ router.post('/:id/close', authenticateToken, (req, res) => {
}
});
// Delete session (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Prevent deletion of active sessions
if (session.is_active === 1) {
return res.status(400).json({ error: 'Cannot delete an active session. Please close it first.' });
}
// Delete related data first (cascade)
db.prepare('DELETE FROM chat_logs WHERE session_id = ?').run(req.params.id);
db.prepare('DELETE FROM session_games WHERE session_id = ?').run(req.params.id);
// Delete the session
db.prepare('DELETE FROM sessions WHERE id = ?').run(req.params.id);
res.json({ message: 'Session deleted successfully', sessionId: parseInt(req.params.id) });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get games played in a session
router.get('/:id/games', (req, res) => {
try {
@@ -180,10 +215,20 @@ router.post('/:id/games', authenticateToken, (req, res) => {
return res.status(404).json({ error: 'Game not found' });
}
// Add game to session
// Set all current 'playing' games to 'played' (except skipped ones)
db.prepare(`
UPDATE session_games
SET status = CASE
WHEN status = 'skipped' THEN 'skipped'
ELSE 'played'
END
WHERE session_id = ? AND status = 'playing'
`).run(req.params.id);
// Add game to session with 'playing' status
const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added)
VALUES (?, ?, ?)
INSERT INTO session_games (session_id, game_id, manually_added, status)
VALUES (?, ?, ?, 'playing')
`);
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
@@ -336,5 +381,64 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
}
});
// Update session game status (admin only)
router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) => {
try {
const { status } = req.body;
const { sessionId, gameId } = req.params;
if (!status || !['playing', 'played', 'skipped'].includes(status)) {
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 (status === 'playing') {
db.prepare(`
UPDATE session_games
SET status = CASE
WHEN status = 'skipped' THEN 'skipped'
ELSE 'played'
END
WHERE session_id = ? AND status = 'playing'
`).run(sessionId);
}
// Update the specific game
const result = db.prepare(`
UPDATE session_games
SET status = ?
WHERE session_id = ? AND id = ?
`).run(status, sessionId, gameId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Session game not found' });
}
res.json({ message: 'Status updated successfully', status });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete session game (admin only)
router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
try {
const { sessionId, gameId } = req.params;
const result = db.prepare(`
DELETE FROM session_games
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Session game not found' });
}
res.json({ message: 'Game removed from session successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;