Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions webapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions webapp/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down
53 changes: 53 additions & 0 deletions webapp/src/routes/hub.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});

135 changes: 135 additions & 0 deletions webapp/src/services/hub-content.ts
Original file line number Diff line number Diff line change
@@ -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<HubEmbed | null> => {
const [rows] = await pool.query<HubEmbedRow[]>(
`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<HubEmbed[]> => {
const [rows] = await pool.query<HubEmbedRow[]>(
`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<HighScoreEntry[]> => {
const [rows] = await pool.query<HighScoreRow[]>(
`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,
}));
};

Loading