step 8.2: done

This commit is contained in:
bepis
2026-10-21 08:05:25 +11:00
parent fcf2ea2d21
commit b8fef0629b
6 changed files with 308 additions and 36 deletions
+4
View File
@@ -10,6 +10,7 @@ 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 { routes: heroProfileRoutes } = require('./routes/heroProfile');
const { AppError } = require('./utils/errors');
function createApp() {
@@ -76,6 +77,9 @@ function createApp() {
// Leaderboard route (require session)
app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard);
// Hero profile route (public, no session required)
app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile);
// Central error handler
app.use((err, _req, res, _next) => {
// Log stack traces server-side only
+33
View File
@@ -0,0 +1,33 @@
// Hero profile routes: GET /api/heroes/:name (public).
const zod = require('zod');
const { AppError } = require('../utils/errors');
const { getHeroProfile } = require('../services/heroProfileService');
function wrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
/**
* GET /api/heroes/:name — public hero profile.
*/
async function handleHeroProfile(req, res) {
const parsed = zod.strictObject({
name: zod.string().min(1).trim(),
}).safeParse(req.params);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const profile = getHeroProfile(parsed.data.name);
if (!profile) {
throw new AppError('NOT_FOUND', 'Hero not found.', 404);
}
res.json({ ok: true, data: profile });
}
module.exports = {
routes: {
getHeroProfile: wrap(handleHeroProfile),
},
};
+72
View File
@@ -0,0 +1,72 @@
// Hero profile service: public hero lookup (no private data).
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
// Prepared statements cached per DB handle (WeakMap).
const stmtCache = new WeakMap();
function getStmts(db) {
let s = stmtCache.get(db);
if (!s) {
s = {
// Find character by name (case-insensitive).
findByName: db.prepare(
`SELECT c.id, c.name, c.class, c.level, c.xp, c.gold,
c.str, c.agi, c.int, c.vit, c.kills, c.deaths
FROM characters c
WHERE c.name = ? COLLATE NOCASE`
),
// Get equipped items for a character.
equippedItems: db.prepare(
`SELECT it.name, i.equipped_slot
FROM inventory i
JOIN items it ON it.id = i.item_id
WHERE i.character_id = ? AND i.equipped_slot IS NOT NULL`
),
};
stmtCache.set(db, s);
}
return s;
}
/**
* Get a public hero profile by name.
* @param {string} heroName - hero name (case-insensitive)
* @returns {object} { name, class, level, xp, gold, str, agi, int, vit, kills, deaths, equipment: { weapon, armor, trinket } }
*/
function getHeroProfile(heroName) {
const db = getDb();
const s = getStmts(db);
const character = s.findByName.get(heroName);
if (!character) {
return null;
}
const rows = s.equippedItems.all(character.id);
const equipment = { weapon: null, armor: null, trinket: null };
for (const row of rows) {
if (row.equipped_slot in equipment) {
equipment[row.equipped_slot] = row.name;
}
}
return {
name: character.name,
class: character.class,
level: character.level,
xp: character.xp,
gold: character.gold,
str: character.str,
agi: character.agi,
int: character.int,
vit: character.vit,
kills: character.kills,
deaths: character.deaths,
equipment,
};
}
module.exports = { getHeroProfile };