step 8.1: done
This commit is contained in:
@@ -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.
|
*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)
|
## 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.2** Public profile `GET /api/heroes/:name` (no private data).
|
||||||
- [ ] **8.3** UI page.
|
- [ ] **8.3** UI page.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { routes: combatLogRoutes } = require('./routes/combatLog');
|
|||||||
const { routes: inventoryRoutes } = require('./routes/inventory');
|
const { routes: inventoryRoutes } = require('./routes/inventory');
|
||||||
const { routes: shopRoutes } = require('./routes/shop');
|
const { routes: shopRoutes } = require('./routes/shop');
|
||||||
const { routes: expeditionRoutes } = require('./routes/expeditions');
|
const { routes: expeditionRoutes } = require('./routes/expeditions');
|
||||||
|
const { routes: leaderboardRoutes } = require('./routes/leaderboard');
|
||||||
const { AppError } = require('./utils/errors');
|
const { AppError } = require('./utils/errors');
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
@@ -72,6 +73,9 @@ function createApp() {
|
|||||||
app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive);
|
app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive);
|
||||||
app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim);
|
app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim);
|
||||||
|
|
||||||
|
// Leaderboard route (require session)
|
||||||
|
app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard);
|
||||||
|
|
||||||
// Central error handler
|
// Central error handler
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
// Log stack traces server-side only
|
// 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 };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user