640 lines
29 KiB
JavaScript
640 lines
29 KiB
JavaScript
'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 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);
|
||
`);
|
||
}
|
||
|
||
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) VALUES (?, ?, ?, 1)')
|
||
.run(username, hash, role);
|
||
console.log(`[INIT] Created user '${username}' – temp password: ${tempPw}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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:'],
|
||
},
|
||
},
|
||
}));
|
||
|
||
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);
|
||
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(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, 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);
|
||
|
||
res.json({
|
||
stats: { ...stats, ...matchStats },
|
||
rank,
|
||
online: [...userSockets.keys()],
|
||
recent,
|
||
daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null },
|
||
});
|
||
});
|
||
|
||
// ── Scores ────────────────────────────────────────────────────────────────────
|
||
app.post('/api/scores/practice', requireAuth, (req, res) => {
|
||
const { stratagem, category, time_ms, score } = 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 > 35_000) return res.status(400).json({ error: 'Invalid time' });
|
||
if (typeof score !== 'number' || score < 0 || score > 15_000) return res.status(400).json({ error: 'Invalid score' });
|
||
|
||
db.prepare(`
|
||
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`).run(req.session.user, stratagem, category || '', time_ms, score, new Date().toISOString());
|
||
|
||
invalidateLB();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/scores/leaderboard', requireAuth, (req, res) => {
|
||
res.json(getLeaderboard());
|
||
});
|
||
|
||
app.get('/api/scores/me', requireAuth, (req, res) => {
|
||
const u = req.session.user;
|
||
const practice = db.prepare(`
|
||
SELECT stratagem, category, score, time_ms, 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 });
|
||
});
|
||
|
||
// ── Static files ──────────────────────────────────────────────────────────────
|
||
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()];
|
||
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 };
|
||
|
||
broadcastToRoom(room, 'round-complete', { winner: winnerId, matchScores });
|
||
|
||
if (room.matchScores[winnerId] >= 5) {
|
||
broadcastToRoom(room, 'match-end', { winner: winnerId, matchScores });
|
||
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();
|
||
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);
|
||
send(userSockets.get(targetUser), 'challenge-received', { from: userId });
|
||
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); });
|