we're about to port the chrome-extension. everything else mostly works
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user