step 8.1: done
This commit is contained in:
@@ -9,6 +9,7 @@ const { routes: combatLogRoutes } = require('./routes/combatLog');
|
||||
const { routes: inventoryRoutes } = require('./routes/inventory');
|
||||
const { routes: shopRoutes } = require('./routes/shop');
|
||||
const { routes: expeditionRoutes } = require('./routes/expeditions');
|
||||
const { routes: leaderboardRoutes } = require('./routes/leaderboard');
|
||||
const { AppError } = require('./utils/errors');
|
||||
|
||||
function createApp() {
|
||||
@@ -72,6 +73,9 @@ function createApp() {
|
||||
app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive);
|
||||
app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim);
|
||||
|
||||
// Leaderboard route (require session)
|
||||
app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard);
|
||||
|
||||
// Central error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Leaderboard routes: GET /api/leaderboard?type=level|gold|kills&page=1
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { getLeaderboard } = require('../services/leaderboardService');
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
const VALID_TYPES = ['level', 'gold', 'kills'];
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard — return ranked leaderboard entries.
|
||||
*/
|
||||
async function handleLeaderboard(req, res) {
|
||||
const parsed = zod.strictObject({
|
||||
type: zod.enum(VALID_TYPES),
|
||||
page: zod.coerce.number().int().min(1).default(1),
|
||||
limit: zod.coerce.number().int().min(1).max(50).default(50),
|
||||
}).safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { type, page, limit } = parsed.data;
|
||||
const result = getLeaderboard(type, page, limit);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routes: {
|
||||
getLeaderboard: wrap(handleLeaderboard),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
// Leaderboard service: top 50 heroes by level, gold, or kills.
|
||||
const { getDb } = require('../db');
|
||||
|
||||
// Prepared statements cached per DB handle (WeakMap).
|
||||
const stmtCache = new WeakMap();
|
||||
|
||||
function getStmts(db) {
|
||||
let s = stmtCache.get(db);
|
||||
if (!s) {
|
||||
s = {
|
||||
// Top 50 by level (tie-break xp DESC).
|
||||
byLevel: db.prepare(
|
||||
`SELECT c.name, c.class, c.level, c.xp, c.kills, c.gold
|
||||
FROM characters c
|
||||
ORDER BY c.level DESC, c.xp DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
),
|
||||
|
||||
// Top 50 by gold.
|
||||
byGold: db.prepare(
|
||||
`SELECT c.name, c.class, c.level, c.xp, c.kills, c.gold
|
||||
FROM characters c
|
||||
ORDER BY c.gold DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
),
|
||||
|
||||
// Top 50 by kills.
|
||||
byKills: db.prepare(
|
||||
`SELECT c.name, c.class, c.level, c.xp, c.kills, c.gold
|
||||
FROM characters c
|
||||
ORDER BY c.kills DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
),
|
||||
|
||||
// Count total characters (for pagination metadata).
|
||||
countByLevel: db.prepare(
|
||||
'SELECT COUNT(*) AS total FROM characters'
|
||||
),
|
||||
countByGold: db.prepare(
|
||||
'SELECT COUNT(*) AS total FROM characters'
|
||||
),
|
||||
countByKills: db.prepare(
|
||||
'SELECT COUNT(*) AS total FROM characters'
|
||||
),
|
||||
};
|
||||
stmtCache.set(db, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaderboard entries.
|
||||
* @param {string} type - 'level' | 'gold' | 'kills'
|
||||
* @param {number} page - page number (1-based)
|
||||
* @param {number} limit - items per page (max 50)
|
||||
* @returns {object} { entries: [{name, class, level, xp, kills, gold}], pagination: { page, limit, total, totalPages } }
|
||||
*/
|
||||
function getLeaderboard(type, page, limit) {
|
||||
const db = getDb();
|
||||
const s = getStmts(db);
|
||||
|
||||
// Default limit to 50, cap at 50.
|
||||
limit = Math.min(limit || 50, 50);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query;
|
||||
switch (type) {
|
||||
case 'gold':
|
||||
query = s.byGold;
|
||||
break;
|
||||
case 'kills':
|
||||
query = s.byKills;
|
||||
break;
|
||||
case 'level':
|
||||
default:
|
||||
query = s.byLevel;
|
||||
break;
|
||||
}
|
||||
|
||||
const rows = query.all(limit, offset);
|
||||
|
||||
// Count total characters (same for all types since it's the full roster).
|
||||
const totalRow = s.countByLevel.get();
|
||||
const total = totalRow.total;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const entries = rows.map((row, idx) => ({
|
||||
rank: offset + idx + 1,
|
||||
name: row.name,
|
||||
class: row.class,
|
||||
level: row.level,
|
||||
xp: row.xp,
|
||||
kills: row.kills,
|
||||
gold: row.gold,
|
||||
}));
|
||||
|
||||
return {
|
||||
entries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getLeaderboard };
|
||||
Reference in New Issue
Block a user