Files
helldivers/server.js
T

794 lines
36 KiB
JavaScript
Raw Normal View History

'use strict';
const path = require('path');
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const WebSocket = require('ws');
const Database = require('better-sqlite3');
const PORT = process.env.PORT || 3012;
const DATA_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
// ── Stratagems (mirrored in public/stratagems.js) ─────────────────────────────
const STRATAGEMS = [
// Patriotic Administration Center
{ name: 'Reinforce', category: 'Patriotic Administration Center', sequence: ['up','down','right','left','up'] },
{ name: 'Resupply', category: 'Patriotic Administration Center', sequence: ['down','down','up','right'] },
{ name: 'SOS Beacon', category: 'Patriotic Administration Center', sequence: ['up','down','right','up'] },
{ name: 'Hellbomb', category: 'Patriotic Administration Center', sequence: ['down','up','left','down','up','right','down','up'] },
{ name: 'SEAF Artillery', category: 'Patriotic Administration Center', sequence: ['right','up','up','down'] },
{ name: 'Upload Data', category: 'Patriotic Administration Center', sequence: ['right','right','left','up','up'] },
{ name: 'Eagle Rearm', category: 'Patriotic Administration Center', sequence: ['up','up','left','up','right'] },
{ name: 'Prospecting Drill', category: 'Patriotic Administration Center', sequence: ['down','down','left','right','down'] },
// Orbital Cannons
{ name: 'Orbital Gatling Barrage', category: 'Orbital Cannons', sequence: ['right','down','left','up','up'] },
{ name: 'Orbital Airburst Strike', category: 'Orbital Cannons', sequence: ['right','right','right'] },
{ name: 'Orbital 120MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','right','down','left','right','down'] },
{ name: 'Orbital 380MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','down','up','up','left','down','down'] },
{ name: 'Orbital Walking Barrage', category: 'Orbital Cannons', sequence: ['right','down','right','down','right','down'] },
{ name: 'Orbital Laser', category: 'Orbital Cannons', sequence: ['right','down','up','right','down'] },
{ name: 'Orbital Railcannon Strike', category: 'Orbital Cannons', sequence: ['right','up','down','down','right'] },
{ name: 'Orbital Precision Strike', category: 'Orbital Cannons', sequence: ['right','right','up'] },
{ name: 'Orbital Gas Strike', category: 'Orbital Cannons', sequence: ['right','right','down','right'] },
{ name: 'Orbital EMS Strike', category: 'Orbital Cannons', sequence: ['right','right','left','down'] },
{ name: 'Orbital Smoke Strike', category: 'Orbital Cannons', sequence: ['right','right','down','up'] },
{ name: 'Orbital Illumination Flare', category: 'Orbital Cannons', sequence: ['right','right','left','left'] },
// Hangar
{ name: 'Eagle Strafing Run', category: 'Hangar', sequence: ['up','right','right'] },
{ name: 'Eagle Airstrike', category: 'Hangar', sequence: ['up','right','down','right'] },
{ name: 'Eagle Cluster Bomb', category: 'Hangar', sequence: ['up','right','down','down','right'] },
{ name: 'Eagle Napalm Airstrike', category: 'Hangar', sequence: ['up','right','down','up'] },
{ name: 'LIFT-850 Jump Pack', category: 'Hangar', sequence: ['down','up','up','down','up'] },
{ name: 'Eagle Smoke Strike', category: 'Hangar', sequence: ['up','right','up','down'] },
{ name: 'Eagle 110MM Rocket Pods', category: 'Hangar', sequence: ['up','right','up','left'] },
{ name: 'Eagle 500KG Bomb', category: 'Hangar', sequence: ['up','right','down','down','down'] },
// Bridge
{ name: 'Patriot Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','right'] },
{ name: 'Emancipator Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','down'] },
// Engineering Bay Support Weapons
{ name: 'Machine Gun', category: 'Engineering Bay', sequence: ['down','left','down','up','right'] },
{ name: 'Anti-Materiel Rifle', category: 'Engineering Bay', sequence: ['down','left','right','up','down'] },
{ name: 'Stalwart', category: 'Engineering Bay', sequence: ['down','left','down','up','up','left'] },
{ name: 'Expendable Anti-Tank', category: 'Engineering Bay', sequence: ['down','down','left','up'] },
{ name: 'Recoilless Rifle', category: 'Engineering Bay', sequence: ['down','left','right','right','left'] },
{ name: 'Flamethrower', category: 'Engineering Bay', sequence: ['down','left','up','down','up'] },
{ name: 'Autocannon', category: 'Engineering Bay', sequence: ['down','left','down','up','up','right'] },
{ name: 'Heavy Machine Gun', category: 'Engineering Bay', sequence: ['down','left','up','down','down'] },
{ name: 'Airburst Rocket Launcher', category: 'Engineering Bay', sequence: ['down','up','up','left','right'] },
{ name: 'Commando', category: 'Engineering Bay', sequence: ['down','left','up','down','right'] },
{ name: 'Railgun', category: 'Engineering Bay', sequence: ['down','right','down','up','left','right'] },
{ name: 'Spear', category: 'Engineering Bay', sequence: ['down','down','up','down','down'] },
{ name: 'Quasar Cannon', category: 'Engineering Bay', sequence: ['down','down','up','left','right'] },
{ name: 'Arc Thrower', category: 'Engineering Bay', sequence: ['down','right','down','up','left','left'] },
{ name: 'Laser Cannon', category: 'Engineering Bay', sequence: ['down','left','down','up','left'] },
{ name: 'Grenade Launcher', category: 'Engineering Bay', sequence: ['down','left','up','left','down'] },
// Engineering Bay Equipment
{ name: 'Supply Pack', category: 'Engineering Bay', sequence: ['down','left','down','up','up','down'] },
{ name: 'Guard Dog Rover', category: 'Engineering Bay', sequence: ['down','up','left','up','right','right'] },
{ name: 'Guard Dog', category: 'Engineering Bay', sequence: ['down','up','left','up','right','down'] },
{ name: 'Ballistic Shield Backpack', category: 'Engineering Bay', sequence: ['down','left','down','down','up','left'] },
{ name: 'Shield Generator Pack', category: 'Engineering Bay', sequence: ['down','up','left','right','left','right'] },
{ name: 'Directional Shield', category: 'Engineering Bay', sequence: ['down','left','up','up','right'] },
// Engineering Bay Mines
{ name: 'Anti-Personnel Minefield', category: 'Engineering Bay', sequence: ['down','left','up','right'] },
{ name: 'Incendiary Mines', category: 'Engineering Bay', sequence: ['down','left','left','down'] },
{ name: 'Anti-Tank Mines', category: 'Engineering Bay', sequence: ['down','down','left','left'] },
// Robotics Workshop
{ name: 'Machine Gun Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','up'] },
{ name: 'Gatling Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','left'] },
{ name: 'Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','down'] },
{ name: 'Autocannon Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up'] },
{ name: 'Rocket Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','left'] },
{ name: 'EMS Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','down','right'] },
{ name: 'Tesla Tower', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up','up'] },
// Defensive
{ name: 'Shield Generator Relay', category: 'Defensive', sequence: ['down','up','left','right','left','down'] },
{ name: 'Anti-Tank Emplacement', category: 'Defensive', sequence: ['down','right','right','up','left'] },
{ name: 'Orbital Shield Generator', category: 'Defensive', sequence: ['right','right','left','down','left','down'] },
];
const VALID_NAMES = new Set(STRATAGEMS.map(s => s.name));
// ── SQLite ────────────────────────────────────────────────────────────────────
const db = new Database(path.join(DATA_DIR, 'helldivers.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
function initDB() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
mustChange INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS practice_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
stratagem TEXT NOT NULL,
category TEXT NOT NULL,
time_ms INTEGER NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
winner TEXT NOT NULL,
loser TEXT NOT NULL,
winner_rounds INTEGER NOT NULL,
loser_rounds INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS stratagem_stats (
username TEXT NOT NULL,
stratagem TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
completions INTEGER DEFAULT 0,
best_time INTEGER DEFAULT NULL,
PRIMARY KEY (username, stratagem)
);
CREATE INDEX IF NOT EXISTS idx_ps_user ON practice_sessions(username);
CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner);
CREATE INDEX IF NOT EXISTS idx_m_loser ON matches(loser);
`);
// Schema migrations (user_version tracks applied version)
const version = db.pragma('user_version', { simple: true });
if (version < 1) {
// Add elo column to users if missing
const cols = db.pragma('table_info(users)').map(c => c.name);
if (!cols.includes('elo')) {
db.exec('ALTER TABLE users ADD COLUMN elo INTEGER DEFAULT 1000');
}
// Add mode column to practice_sessions if missing
const psCols = db.pragma('table_info(practice_sessions)').map(c => c.name);
if (!psCols.includes('mode')) {
db.exec("ALTER TABLE practice_sessions ADD COLUMN mode TEXT DEFAULT 'timed'");
}
db.pragma('user_version = 1');
}
}
async function initUsers() {
const defaults = [
{ username: 'admin', role: 'admin' },
{ username: 'jeremy', role: 'user' },
];
for (const { username, role } of defaults) {
const exists = db.prepare('SELECT username FROM users WHERE username = ?').get(username);
if (!exists) {
const tempPw = crypto.randomBytes(6).toString('hex');
const hash = await bcrypt.hash(tempPw, 12);
db.prepare('INSERT INTO users (username, hash, role, mustChange, elo) VALUES (?, ?, ?, 1, 1000)')
.run(username, hash, role);
console.log(`[INIT] Created user '${username}' temp password: ${tempPw}`);
}
}
}
// ── ELO calculation ───────────────────────────────────────────────────────────
function calcElo(winnerElo, loserElo, k = 32) {
const expected = 1 / (1 + 10 ** ((loserElo - winnerElo) / 400));
const delta = Math.round(k * (1 - expected));
return {
winner: Math.max(0, winnerElo + delta),
loser: Math.max(0, loserElo - delta),
delta,
};
}
// ── ELO rank label ─────────────────────────────────────────────────────────────
function eloRank(elo) {
if (elo >= 1700) return 'GENERAL';
if (elo >= 1500) return 'CAPTAIN';
if (elo >= 1300) return 'LIEUTENANT';
if (elo >= 1100) return 'SERGEANT';
return 'PRIVATE';
}
// ── Session secret ────────────────────────────────────────────────────────────
function getSessionSecret() {
const file = path.join(DATA_DIR, '.session-secret');
if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim();
const secret = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(file, secret, { mode: 0o600 });
return secret;
}
// ── WebSocket shared state (module-level so routes can read userSockets) ─────
const userSockets = new Map(); // userId → ws
const pendingChallenges = new Map(); // challengerId → targetId
const rooms = new Map(); // roomId → roomState
// ── Express ───────────────────────────────────────────────────────────────────
const app = express();
app.set('trust proxy', 1);
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
upgradeInsecureRequests: null, // Nginx handles HTTPS; this breaks HTTP on LAN/dev
},
},
}));
const generalLimiter = rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false });
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, max: 10, message: { error: 'Too many login attempts, try again later' } });
app.use(generalLimiter);
app.use(express.json({ limit: '10kb' }));
const sessionMiddleware = session({
secret: getSessionSecret(),
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
},
});
app.use(sessionMiddleware);
// ── Middleware ────────────────────────────────────────────────────────────────
function requireAuth(req, res, next) {
if (!req.session.user) return res.status(401).json({ error: 'Not logged in' });
if (req.session.mustChange) {
const allowed = ['/api/change-password', '/api/logout', '/api/me'];
if (!allowed.includes(req.path)) {
return res.status(403).json({ error: 'Password change required', mustChange: true });
}
}
next();
}
function requireAdmin(req, res, next) {
if (req.session.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
next();
}
// ── Auth ──────────────────────────────────────────────────────────────────────
app.post('/api/login', loginLimiter, async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.user = user.username;
req.session.role = user.role;
req.session.mustChange = user.mustChange === 1;
res.json({ ok: true, user: user.username, role: user.role, mustChange: user.mustChange === 1 });
});
});
app.post('/api/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
app.get('/api/me', (req, res) => {
if (!req.session.user) return res.json({ user: null });
res.json({ user: req.session.user, role: req.session.role, mustChange: !!req.session.mustChange });
});
app.post('/api/change-password', requireAuth, async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
if (!oldPassword || !newPassword) return res.status(400).json({ error: 'Both passwords required' });
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(req.session.user);
if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(oldPassword, user.hash);
if (!valid) return res.status(401).json({ error: 'Current password incorrect' });
const hash = await bcrypt.hash(newPassword, 12);
db.prepare('UPDATE users SET hash = ?, mustChange = 0 WHERE username = ?').run(hash, req.session.user);
req.session.mustChange = false;
res.json({ ok: true });
});
// ── User management (admin) ───────────────────────────────────────────────────
app.get('/api/users', requireAuth, requireAdmin, (req, res) => {
const users = db.prepare('SELECT username, role, mustChange FROM users ORDER BY username').all();
res.json(users.map(u => ({ ...u, mustChange: u.mustChange === 1 })));
});
app.post('/api/users', requireAuth, requireAdmin, async (req, res) => {
const { username, role } = req.body || {};
if (!username || !['admin', 'user'].includes(role))
return res.status(400).json({ error: 'Valid username and role required' });
if (!/^[a-zA-Z0-9_-]{2,32}$/.test(username))
return res.status(400).json({ error: 'Username: 2-32 alphanumeric chars, _ or -' });
const exists = db.prepare('SELECT username FROM users WHERE username = ?').get(username);
if (exists) return res.status(409).json({ error: 'User already exists' });
const tempPw = crypto.randomBytes(6).toString('hex');
const hash = await bcrypt.hash(tempPw, 12);
db.prepare('INSERT INTO users (username, hash, role, mustChange) VALUES (?, ?, ?, 1)').run(username, hash, role);
res.json({ ok: true, tempPassword: tempPw });
});
app.delete('/api/users/:username', requireAuth, requireAdmin, (req, res) => {
const { username } = req.params;
if (username === req.session.user) return res.status(400).json({ error: 'Cannot delete yourself' });
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'").get().c;
if (adminCount <= 1) return res.status(400).json({ error: 'Cannot delete the last admin' });
}
db.prepare('DELETE FROM users WHERE username = ?').run(username);
res.json({ ok: true });
});
// ── Leaderboard cache ─────────────────────────────────────────────────────────
let lbCache = null;
let lbCacheTime = 0;
const LB_TTL = 30_000;
function invalidateLB() { lbCache = null; }
function getLeaderboard() {
if (lbCache && Date.now() - lbCacheTime < LB_TTL) return lbCache;
lbCache = db.prepare(`
SELECT
u.username,
COALESCE(u.elo, 1000) AS elo,
COALESCE(ps.sessions, 0) AS sessions,
COALESCE(ps.totalScore, 0) AS totalScore,
COALESCE(ps.fastestTime, 0) AS fastestTime,
COALESCE(mw.wins, 0) AS wins,
COALESCE(mw.wins, 0) + COALESCE(ml.losses, 0) AS matches
FROM users u
LEFT JOIN (
SELECT username, COUNT(*) AS sessions, SUM(score) AS totalScore, MIN(time_ms) AS fastestTime
FROM practice_sessions GROUP BY username
) ps ON ps.username = u.username
LEFT JOIN (SELECT winner AS username, COUNT(*) AS wins FROM matches GROUP BY winner) mw ON mw.username = u.username
LEFT JOIN (SELECT loser AS username, COUNT(*) AS losses FROM matches GROUP BY loser) ml ON ml.username = u.username
WHERE COALESCE(ps.sessions,0) > 0 OR COALESCE(mw.wins,0) > 0
ORDER BY totalScore DESC, fastestTime ASC
LIMIT 20
`).all();
lbCacheTime = Date.now();
return lbCache;
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
app.get('/api/dashboard', requireAuth, (req, res) => {
const u = req.session.user;
const stats = db.prepare(`
SELECT COUNT(*) AS sessions,
COALESCE(SUM(score),0) AS totalScore,
COALESCE(MAX(score),0) AS bestScore,
COALESCE(MIN(time_ms),0) AS fastestTime
FROM practice_sessions WHERE username = ?
`).get(u);
const matchStats = db.prepare(`
SELECT COUNT(*) AS matches,
SUM(CASE WHEN winner = ? THEN 1 ELSE 0 END) AS wins
FROM matches WHERE winner = ? OR loser = ?
`).get(u, u, u);
const lb = getLeaderboard();
const rankIdx = lb.findIndex(r => r.username === u);
const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null;
const recent = db.prepare(`
SELECT stratagem, category, score, time_ms, mode, created_at
FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 5
`).all(u);
const dayOfYear = Math.floor((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86_400_000);
const dailyStrat = STRATAGEMS[dayOfYear % STRATAGEMS.length];
const dailyBest = db.prepare(`
SELECT MIN(time_ms) AS bestTime FROM practice_sessions
WHERE stratagem = ? AND username = ?
`).get(dailyStrat.name, u);
const userRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(u);
const elo = userRow?.elo ?? 1000;
const onlineWithElo = [...userSockets.keys()].map(name => {
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
});
res.json({
stats: { ...stats, ...matchStats },
rank,
elo,
eloRank: eloRank(elo),
online: onlineWithElo,
recent,
daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null },
});
});
// ── Scores ────────────────────────────────────────────────────────────────────
app.post('/api/scores/practice', requireAuth, (req, res) => {
const { stratagem, category, time_ms, score, mode } = req.body || {};
if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' });
if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 600_000) return res.status(400).json({ error: 'Invalid time' });
if (typeof score !== 'number' || score < 0 || score > 50_000) return res.status(400).json({ error: 'Invalid score' });
const safeMode = ['timed','endless','drill','speedrun'].includes(mode) ? mode : 'timed';
db.prepare(`
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, mode, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(req.session.user, stratagem, category || '', time_ms, score, safeMode, new Date().toISOString());
// Update stratagem_stats (upsert)
db.prepare(`
INSERT INTO stratagem_stats (username, stratagem, attempts, completions, best_time)
VALUES (?, ?, 1, 1, ?)
ON CONFLICT(username, stratagem) DO UPDATE SET
attempts = attempts + 1,
completions = completions + 1,
best_time = CASE WHEN best_time IS NULL OR ? < best_time THEN ? ELSE best_time END
`).run(req.session.user, stratagem, time_ms, time_ms, time_ms);
invalidateLB();
res.json({ ok: true });
});
app.get('/api/scores/leaderboard', requireAuth, (req, res) => {
res.json(getLeaderboard());
});
app.get('/api/scores/leaderboard/elo', requireAuth, (req, res) => {
const rows = db.prepare(`
SELECT username, elo, role FROM users ORDER BY elo DESC LIMIT 20
`).all();
res.json(rows.map(r => ({ ...r, rank: eloRank(r.elo) })));
});
app.get('/api/scores/leaderboard/speedrun', requireAuth, (req, res) => {
// Speedrun: sum of all time_ms per user where mode = 'speedrun', order by total time ASC
const rows = db.prepare(`
SELECT username, SUM(time_ms) AS totalTime, COUNT(*) AS stratagems
FROM practice_sessions WHERE mode = 'speedrun'
GROUP BY username ORDER BY totalTime ASC LIMIT 20
`).all();
res.json(rows);
});
app.get('/api/scores/me', requireAuth, (req, res) => {
const u = req.session.user;
const practice = db.prepare(`
SELECT stratagem, category, score, time_ms, mode, created_at
FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 50
`).all(u);
const matches = db.prepare(`
SELECT * FROM matches WHERE winner = ? OR loser = ?
ORDER BY created_at DESC LIMIT 20
`).all(u, u);
res.json({ practice, matches });
});
// ── History (paginated) ────────────────────────────────────────────────────────
app.get('/api/history', requireAuth, (req, res) => {
const u = req.session.user;
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 10));
const mode = req.query.mode || '';
const cat = req.query.cat || '';
const offset = (page - 1) * limit;
let where = 'WHERE username = ?';
const params = [u];
if (mode) { where += ' AND mode = ?'; params.push(mode); }
if (cat) { where += ' AND category = ?'; params.push(cat); }
const total = db.prepare(`SELECT COUNT(*) AS n FROM practice_sessions ${where}`).get(...params).n;
const rows = db.prepare(`
SELECT stratagem, category, score, time_ms, mode, created_at
FROM practice_sessions ${where}
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({ rows, total, page, pages: Math.ceil(total / limit) });
});
// ── Stratagem stats ────────────────────────────────────────────────────────────
app.get('/api/stats/stratagems', requireAuth, (req, res) => {
const u = req.session.user;
const rows = db.prepare(`
SELECT stratagem, attempts, completions, best_time
FROM stratagem_stats WHERE username = ?
ORDER BY best_time ASC NULLS LAST
`).all(u);
res.json(rows);
});
// ── Stratagems API (authenticated) ────────────────────────────────────────────
// Stratagem sequences are served via API not as a public static file.
app.get('/api/stratagems', requireAuth, (req, res) => {
res.json(STRATAGEMS);
});
// ── Public static files (index.html, styles.css, app.js) ─────────────────────
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'),
}));
app.use((req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
// ── Boot ──────────────────────────────────────────────────────────────────────
async function main() {
initDB();
await initUsers();
const server = app.listen(PORT, () => {
console.log(`[helldivers] listening on http://localhost:${PORT}`);
});
// ── WebSocket server ────────────────────────────────────────────────────────
const wss = new WebSocket.Server({ server });
function send(ws, type, payload) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type, payload }));
}
function broadcastLobbyUpdate() {
const online = [...userSockets.keys()].map(name => {
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
});
wss.clients.forEach(ws => {
if (ws.readyState !== WebSocket.OPEN || !ws.userId) return;
const incoming = [...pendingChallenges.entries()]
.filter(([, target]) => target === ws.userId)
.map(([from]) => from);
send(ws, 'lobby-update', { online, incoming });
});
}
function getRoomForUser(userId) {
for (const room of rooms.values()) {
if (room.players.some(p => p.userId === userId)) return room;
}
return null;
}
function startRound(room) {
room.state = 'active';
room.current = STRATAGEMS[Math.floor(Math.random() * STRATAGEMS.length)];
room.players.forEach(p => { p.progress = 0; });
broadcastToRoom(room, 'round-start', { stratagem: room.current });
}
function resolveRound(room, winnerId) {
const loser = room.players.find(p => p.userId !== winnerId);
room.matchScores[winnerId]++;
const matchScores = { ...room.matchScores };
// Track round history for post-match screen
if (!room.roundHistory) room.roundHistory = [];
room.roundHistory.push({ round: room.roundHistory.length + 1, winner: winnerId });
broadcastToRoom(room, 'round-complete', { winner: winnerId, matchScores });
if (room.matchScores[winnerId] >= 5) {
// Calculate ELO delta
const wRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(winnerId);
const lRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(loser.userId);
const eloResult = calcElo(wRow?.elo ?? 1000, lRow?.elo ?? 1000);
db.prepare('UPDATE users SET elo = ? WHERE username = ?').run(eloResult.winner, winnerId);
db.prepare('UPDATE users SET elo = ? WHERE username = ?').run(eloResult.loser, loser.userId);
db.prepare(`
INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(winnerId, loser.userId, room.matchScores[winnerId], room.matchScores[loser.userId], new Date().toISOString());
invalidateLB();
broadcastToRoom(room, 'match-end', {
winner: winnerId,
matchScores,
roundHistory: room.roundHistory,
eloChanges: {
[winnerId]: { old: wRow?.elo ?? 1000, new: eloResult.winner, delta: +eloResult.delta },
[loser.userId]: { old: lRow?.elo ?? 1000, new: eloResult.loser, delta: -eloResult.delta },
},
});
rooms.delete(room.roomId);
} else {
room.state = 'waiting';
room.readyPlayers.clear();
}
}
function broadcastToRoom(room, type, payload) {
room.players.forEach(({ userId }) => {
const ws = userSockets.get(userId);
if (ws) send(ws, type, payload);
});
}
function handleMessage(ws, type, payload) {
const userId = ws.userId;
switch (type) {
case 'challenge-user': {
const { targetUser } = payload;
if (!userSockets.has(targetUser) || targetUser === userId) return;
pendingChallenges.set(userId, targetUser);
const challengerElo = db.prepare('SELECT elo FROM users WHERE username = ?').get(userId)?.elo ?? 1000;
send(userSockets.get(targetUser), 'challenge-received', { from: userId, elo: challengerElo });
broadcastLobbyUpdate();
break;
}
case 'accept-challenge': {
const { challengerId } = payload;
if (!userSockets.has(challengerId) || pendingChallenges.get(challengerId) !== userId) return;
pendingChallenges.delete(challengerId);
const roomId = crypto.randomUUID();
const room = {
roomId,
players: [{ userId: challengerId, progress: 0 }, { userId, progress: 0 }],
state: 'waiting',
current: null,
matchScores: { [challengerId]: 0, [userId]: 0 },
readyPlayers: new Set(),
};
rooms.set(roomId, room);
send(userSockets.get(challengerId), 'room-joined', { roomId, opponent: userId, matchScores: room.matchScores });
send(ws, 'room-joined', { roomId, opponent: challengerId, matchScores: room.matchScores });
broadcastLobbyUpdate();
break;
}
case 'decline-challenge': {
const { challengerId } = payload;
pendingChallenges.delete(challengerId);
const cWs = userSockets.get(challengerId);
if (cWs) send(cWs, 'challenge-declined', { by: userId });
broadcastLobbyUpdate();
break;
}
case 'player-ready': {
const room = getRoomForUser(userId);
if (!room || room.state !== 'waiting') return;
room.readyPlayers.add(userId);
if (room.readyPlayers.size >= 2) startRound(room);
break;
}
case 'input-arrow': {
const room = getRoomForUser(userId);
if (!room || room.state !== 'active') return;
const { direction } = payload;
if (!['up','down','left','right'].includes(direction)) return;
const player = room.players.find(p => p.userId === userId);
const expected = room.current.sequence[player.progress];
if (direction === expected) {
player.progress++;
broadcastToRoom(room, 'input-result', { userId, correct: true, progress: player.progress });
if (player.progress === room.current.sequence.length) {
room.state = 'round-resolving'; // lock before any async work
resolveRound(room, userId);
}
} else {
player.progress = 0;
broadcastToRoom(room, 'input-result', { userId, correct: false, progress: 0 });
}
break;
}
case 'leave-room': {
const room = getRoomForUser(userId);
if (!room) return;
const opponent = room.players.find(p => p.userId !== userId);
if (opponent) {
const oWs = userSockets.get(opponent.userId);
if (oWs) send(oWs, 'opponent-left', {});
}
rooms.delete(room.roomId);
break;
}
}
}
wss.on('connection', (ws, req) => {
// Re-use session middleware to authenticate the WS upgrade request
sessionMiddleware(req, {}, () => {
if (!req.session?.user) { ws.close(1008, 'Unauthorized'); return; }
const userId = req.session.user;
// Close any stale socket for this user
const stale = userSockets.get(userId);
if (stale && stale !== ws) stale.terminate();
userSockets.set(userId, ws);
ws.userId = userId;
ws.isAlive = true;
broadcastLobbyUpdate();
ws.on('message', (raw) => {
try {
const { type, payload } = JSON.parse(raw.toString());
handleMessage(ws, type, payload || {});
} catch { /* ignore malformed */ }
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => {
userSockets.delete(userId);
pendingChallenges.delete(userId);
// Notify opponent if in a room
const room = getRoomForUser(userId);
if (room) {
const opponent = room.players.find(p => p.userId !== userId);
if (opponent) {
const oWs = userSockets.get(opponent.userId);
if (oWs) send(oWs, 'opponent-left', {});
}
rooms.delete(room.roomId);
}
broadcastLobbyUpdate();
});
});
});
// Heartbeat terminates stale connections every 30s
const heartbeat = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping();
});
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
}
main().catch(err => { console.error(err); process.exit(1); });
process.on('SIGTERM', () => { db.close(); process.exit(0); });
process.on('SIGINT', () => { db.close(); process.exit(0); });