From 08dd55d04af7292fdd445664025df48500171bd0 Mon Sep 17 00:00:00 2001 From: Demi Dev Unit <131922119+jackandcarter@users.noreply.github.com> Date: Sun, 23 Nov 2025 09:39:18 -0600 Subject: [PATCH] Add hub content endpoints --- webapp/README.md | 7 ++ webapp/src/app.ts | 2 + webapp/src/routes/hub.ts | 53 +++++++++++ webapp/src/services/hub-content.ts | 135 +++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 webapp/src/routes/hub.ts create mode 100644 webapp/src/services/hub-content.ts diff --git a/webapp/README.md b/webapp/README.md index a3a8206..4e5ed6e 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -64,6 +64,13 @@ Early web game APIs are exposed under `/api` and use in-memory state to start ex Sessions now support lobby-style controls: optional passwords, configurable maximum party size, and a toggle that allows or blocks mid-dungeon joins once a run begins. Live sessions report their status (`waiting` or `in_progress`), whether a password is required, and how many seats remain. +## Hub content APIs +Hub/tutorial surfaces now pull directly from the `hub_embeds` and `high_scores` tables so the web UI can mirror the Discord experience: + +- `GET /api/hub/main` — returns the primary hub embed row (title, description, hero image, and news text field) or `null` when not configured. +- `GET /api/hub/tutorial?page=1` — fetches a tutorial page by 1-based index, reporting the total page count so clients can paginate. +- `GET /api/hub/high-scores?sort=score_value&limit=20` — retrieve leaderboard entries sorted by score, enemies defeated, bosses defeated, gil, level, or rooms visited (defaults to score). + ## Lobby and cyber chat prototype APIs The lobby surfaces an in-memory chat feed alongside the list of joinable rooms to help players coordinate: diff --git a/webapp/src/app.ts b/webapp/src/app.ts index 964ec60..6b17af6 100644 --- a/webapp/src/app.ts +++ b/webapp/src/app.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'url'; import pinoHttp from 'pino-http'; import { logger } from './logger.js'; import { healthRouter } from './routes/health.js'; +import { hubRouter } from './routes/hub.js'; import { lobbyRouter } from './routes/lobby.js'; import { sessionsRouter } from './routes/sessions.js'; import { HttpError } from './errors/http-error.js'; @@ -20,6 +21,7 @@ export const createApp = () => { app.use(express.static(publicDir)); app.use(healthRouter); + app.use('/api', hubRouter); app.use('/api', lobbyRouter); app.use('/api', sessionsRouter); diff --git a/webapp/src/routes/hub.ts b/webapp/src/routes/hub.ts new file mode 100644 index 0000000..32e5265 --- /dev/null +++ b/webapp/src/routes/hub.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { + fetchHighScores, + fetchMainHubEmbed, + fetchTutorialPage, + HubEmbed, + HighScoreSort, +} from '../services/hub-content.js'; + +export const hubRouter = Router(); + +hubRouter.get('/hub/main', async (_req, res, next) => { + try { + const embed = await fetchMainHubEmbed(); + const payload: { embed: HubEmbed | null } = { embed }; + res.json(payload); + } catch (error) { + next(error); + } +}); + +const tutorialQuery = z.object({ + page: z.coerce.number().int().min(1).default(1), +}); + +hubRouter.get('/hub/tutorial', async (req, res, next) => { + try { + const { page } = tutorialQuery.parse(req.query); + const result = await fetchTutorialPage(page); + res.json(result); + } catch (error) { + next(error); + } +}); + +const highScoreQuery = z.object({ + sort: z + .enum(['score_value', 'enemies_defeated', 'bosses_defeated', 'gil', 'player_level', 'rooms_visited']) + .default('score_value'), + limit: z.coerce.number().int().min(1).max(50).optional().default(20), +}); + +hubRouter.get('/hub/high-scores', async (req, res, next) => { + try { + const { sort, limit } = highScoreQuery.parse(req.query); + const results = await fetchHighScores(sort as HighScoreSort, limit); + res.json({ sortBy: sort, results }); + } catch (error) { + next(error); + } +}); + diff --git a/webapp/src/services/hub-content.ts b/webapp/src/services/hub-content.ts new file mode 100644 index 0000000..8a95f18 --- /dev/null +++ b/webapp/src/services/hub-content.ts @@ -0,0 +1,135 @@ +import { RowDataPacket } from 'mysql2/promise'; +import { HttpError } from '../errors/http-error.js'; +import { pool } from '../db/pool.js'; + +export type HubEmbedType = 'main' | 'tutorial' | 'news'; + +interface HubEmbedRow extends RowDataPacket { + embed_type: HubEmbedType; + title: string | null; + description: string | null; + image_url: string | null; + text_field: string | null; + step_order: number | null; + created_at: string; +} + +export interface HubEmbed { + type: HubEmbedType; + title: string | null; + description: string | null; + imageUrl: string | null; + text: string | null; + stepOrder: number | null; + createdAt: string; +} + +const mapEmbedRow = (row: HubEmbedRow): HubEmbed => ({ + type: row.embed_type, + title: row.title, + description: row.description, + imageUrl: row.image_url, + text: row.text_field, + stepOrder: row.step_order, + createdAt: row.created_at, +}); + +export const fetchMainHubEmbed = async (): Promise => { + const [rows] = await pool.query( + `SELECT embed_type, title, description, image_url, text_field, step_order, created_at + FROM hub_embeds + WHERE embed_type = 'main' + ORDER BY step_order ASC + LIMIT 1`, + ); + + return rows[0] ? mapEmbedRow(rows[0]) : null; +}; + +export const fetchTutorialEmbeds = async (): Promise => { + const [rows] = await pool.query( + `SELECT embed_type, title, description, image_url, text_field, step_order, created_at + FROM hub_embeds + WHERE embed_type = 'tutorial' + ORDER BY step_order ASC, created_at ASC`, + ); + + return rows.map(mapEmbedRow); +}; + +export const fetchTutorialPage = async ( + page: number, +): Promise<{ page: number; totalPages: number; embed: HubEmbed }> => { + const steps = await fetchTutorialEmbeds(); + + if (!steps.length) { + throw new HttpError(404, 'No tutorial pages are available yet'); + } + + const totalPages = steps.length; + const index = (page - 1) % totalPages; + const embed = steps[index]; + + return { page: index + 1, totalPages, embed }; +}; + +export type HighScoreSort = + | 'score_value' + | 'enemies_defeated' + | 'bosses_defeated' + | 'gil' + | 'player_level' + | 'rooms_visited'; + +interface HighScoreRow extends RowDataPacket { + player_name: string; + player_class: string | null; + score_value: number; + enemies_defeated: number; + bosses_defeated: number; + rooms_visited: number; + gil: number; + player_level: number; + difficulty: string | null; + completed_at: string; +} + +export interface HighScoreEntry { + playerName: string; + playerClass: string | null; + scoreValue: number; + enemiesDefeated: number; + bossesDefeated: number; + roomsVisited: number; + gil: number; + playerLevel: number; + difficulty: string | null; + completedAt: string; +} + +export const fetchHighScores = async ( + sortBy: HighScoreSort, + limit = 20, +): Promise => { + const [rows] = await pool.query( + `SELECT player_name, player_class, score_value, enemies_defeated, bosses_defeated, rooms_visited, gil, player_level, difficulty, completed_at + FROM high_scores + ORDER BY ${sortBy} DESC, completed_at DESC + LIMIT ?`, + [limit], + ); + + return rows.map((row) => ({ + playerName: row.player_name, + playerClass: row.player_class, + scoreValue: row.score_value, + enemiesDefeated: row.enemies_defeated, + bossesDefeated: row.bosses_defeated, + roomsVisited: row.rooms_visited, + gil: row.gil, + playerLevel: row.player_level, + difficulty: row.difficulty, + completedAt: row.completed_at, + })); +}; +