diff --git a/STATUS.md b/STATUS.md index f08eedf..4b3e28a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -99,7 +99,7 @@ *Verified:* expedition tab in game.html; game.js has renderExpeditionDefs, renderActiveExpedition, handleStartExpedition, handleClaimExpedition, startExpeditionSync; CSS styles for expedition UI; GET /api/expeditions/defs endpoint added. ## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards) -- [ ] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param. +- [x] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param. - [ ] **8.2** Public profile `GET /api/heroes/:name` (no private data). - [ ] **8.3** UI page. diff --git a/src/app.js b/src/app.js index 5048bf5..3e4c0fe 100644 --- a/src/app.js +++ b/src/app.js @@ -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 diff --git a/src/routes/leaderboard.js b/src/routes/leaderboard.js new file mode 100644 index 0000000..6d7a7ac --- /dev/null +++ b/src/routes/leaderboard.js @@ -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), + }, +}; diff --git a/src/services/leaderboardService.js b/src/services/leaderboardService.js new file mode 100644 index 0000000..d208198 --- /dev/null +++ b/src/services/leaderboardService.js @@ -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 }; diff --git a/tests/integration/leaderboard.test.js b/tests/integration/leaderboard.test.js new file mode 100644 index 0000000..6741720 --- /dev/null +++ b/tests/integration/leaderboard.test.js @@ -0,0 +1,247 @@ +// Leaderboard integration tests: GET /api/leaderboard?type=level|gold|kills&page=1 +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +/** + * Register a user and log them in, returning the session cookie string. + */ +async function registerAndLogin(baseUrl, username, password) { + await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + const res = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + + const setCookie = res.headers.get('set-cookie'); + assert.ok(setCookie, 'Login should set a session cookie'); + const cookie = setCookie.split(';')[0]; + return cookie; +} + +function authHeaders(cookie) { + return { Cookie: cookie }; +} + +describe('leaderboard', () => { + let baseUrl, userSeq = 0; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + await teardown(); cleanup(); + }); + + /** + * Register, login, create a character, optionally tweak DB fields, return cookie + characterId. + */ + async function setupHero(overrides = {}) { + userSeq += 1; + const username = `lbu${userSeq}`; + const cookie = await registerAndLogin(baseUrl, username, 'password123'); + + const charRes = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ name: `LbHero${userSeq}`, class: 'warrior' }), + }); + assert.equal(charRes.status, 201); + const charJson = await charRes.json(); + const characterId = charJson.data.character.id; + + const db = require('../../src/db').getDb(); + const cols = Object.keys(overrides); + if (cols.length) { + db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`) + .run(...Object.values(overrides), characterId); + } + + return { cookie, characterId }; + } + + // ─── GET /api/leaderboard?type=level ────────────────────────────────── + + it('returns heroes sorted by level descending, then xp descending', async () => { + // Create 3 heroes with different levels. + const heroes = []; + for (let i = 0; i < 3; i++) { + heroes.push(await setupHero({ level: i + 1 })); + } + + // Create a 4th hero for the auth cookie. + const { cookie } = await setupHero(); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=level`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.ok(Array.isArray(body.data.entries)); + assert.ok(body.data.entries.length >= 3); + // First entry should be the highest level (3). + assert.equal(body.data.entries[0].level, 3); + assert.ok(body.data.pagination, 'should have pagination'); + assert.ok(body.data.pagination.page === 1); + }); + + it('returns heroes sorted by gold descending', async () => { + const heroes = []; + heroes.push(await setupHero({ level: 1, gold: 500 })); + heroes.push(await setupHero({ level: 1, gold: 1000 })); + heroes.push(await setupHero({ level: 1, gold: 250 })); + + const { cookie } = await setupHero({ level: 1, gold: 50 }); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=gold`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + const entries = body.data.entries; + assert.equal(entries[0].gold, 1000); + assert.equal(entries[1].gold, 500); + assert.equal(entries[2].gold, 250); + }); + + it('returns heroes sorted by kills descending', async () => { + await setupHero({ level: 1, kills: 50 }); + await setupHero({ level: 1, kills: 100 }); + await setupHero({ level: 1, kills: 10 }); + + const { cookie } = await setupHero({ level: 1, kills: 5 }); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=kills`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + const entries = body.data.entries; + assert.equal(entries[0].kills, 100); + assert.equal(entries[1].kills, 50); + assert.equal(entries[2].kills, 10); + }); + + // ─── Pagination ─────────────────────────────────────────────────────── + + it('paginates correctly with page and limit', async () => { + // Create 4 heroes at different levels. + await setupHero({ level: 1 }); + await setupHero({ level: 2 }); + await setupHero({ level: 3 }); + await setupHero({ level: 4 }); + + const { cookie } = await setupHero({ level: 1 }); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=1&limit=2`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.data.entries.length, 2); + assert.equal(body.data.pagination.page, 1); + assert.equal(body.data.pagination.limit, 2); + assert.ok(body.data.pagination.total >= 5, 'should have at least 5 total'); + assert.ok(body.data.pagination.totalPages >= 3, 'should have at least 3 pages'); + assert.equal(body.data.entries[0].rank, 1); + assert.equal(body.data.entries[1].rank, 2); + }); + + it('page 2 returns subsequent entries with correct ranks', async () => { + await setupHero({ level: 1 }); + await setupHero({ level: 2 }); + await setupHero({ level: 3 }); + + const { cookie } = await setupHero({ level: 1 }); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=2&limit=2`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.data.entries.length, 2); + assert.equal(body.data.pagination.page, 2); + // Ranks should be 3, 4. + assert.equal(body.data.entries[0].rank, 3); + assert.equal(body.data.entries[1].rank, 4); + }); + + // ─── Validation ─────────────────────────────────────────────────────── + + it('rejects invalid type', async () => { + const { cookie } = await setupHero(); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=invalid`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }); + + it('rejects negative page number', async () => { + const { cookie } = await setupHero(); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=-1`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }); + + it('rejects request without authentication', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=level`); + const body = await res.json(); + + assert.equal(res.status, 401); + assert.equal(body.ok, false); + }); + + // ─── Entry shape ────────────────────────────────────────────────────── + + it('returns correct entry shape with all fields', async () => { + const { cookie } = await setupHero({ + level: 7, xp: 1000, gold: 500, kills: 25, + }); + + // Create one more hero so there are multiple entries. + await setupHero({ level: 1 }); + + const res = await fetch(`${baseUrl}/api/leaderboard?type=level`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + const entry = body.data.entries.find(e => e.name === `LbHero${userSeq - 1}`); + assert.ok(entry, 'should find the hero'); + assert.equal(entry.name, `LbHero${userSeq - 1}`); + assert.equal(entry.class, 'warrior'); + assert.equal(entry.level, 7); + assert.equal(entry.xp, 1000); + assert.equal(entry.gold, 500); + assert.equal(entry.kills, 25); + assert.ok(typeof entry.rank === 'number', 'rank should be a number'); + }); +});