feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
12 changed files with 525 additions and 0 deletions
Showing only changes of commit 3515de62ee - Show all commits
+2
View File
@@ -9,6 +9,7 @@ import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js";
import { exploreRouter } from "./routes/explore.js";
import { gameRouter } from "./routes/game.js";
import { leaderboardRouter } from "./routes/leaderboards.js";
import { prestigeRouter } from "./routes/prestige.js";
import { transcendenceRouter } from "./routes/transcendence.js";
import { profileRouter } from "./routes/profile.js";
@@ -34,6 +35,7 @@ app.route("/craft", craftRouter);
app.route("/prestige", prestigeRouter);
app.route("/transcendence", transcendenceRouter);
app.route("/apotheosis", apotheosisRouter);
app.route("/leaderboards", leaderboardRouter);
app.route("/profile", profileRouter);
app.get("/health", (context) => context.json({ status: "ok" }));
+86
View File
@@ -0,0 +1,86 @@
import type { GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { TITLES } from "../data/titles.js";
export const leaderboardRouter = new Hono<HonoEnv>();
const VALID_CATEGORIES = new Set([
"totalGold",
"bossesDefeated",
"questsCompleted",
"achievementsUnlocked",
"prestigeCount",
"transcendenceCount",
"apotheosisCount",
]);
const GAMESTATE_CATEGORIES = new Set(["prestigeCount", "transcendenceCount", "apotheosisCount"]);
const parseShowOnLeaderboards = (raw: unknown): boolean => {
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
return (raw as Record<string, unknown>).showOnLeaderboards !== false;
}
return true;
};
leaderboardRouter.get("/", async (context) => {
const category = context.req.query("category") ?? "totalGold";
const limitRaw = Number(context.req.query("limit") ?? "100");
const limit = Math.min(Math.max(1, limitRaw), 100);
if (!VALID_CATEGORIES.has(category)) {
return context.json({ error: "Invalid category" }, 400);
}
const [players, gameStates] = await Promise.all([
prisma.player.findMany(),
GAMESTATE_CATEGORIES.has(category) ? prisma.gameState.findMany() : Promise.resolve([]),
]);
const stateMap = new Map(
gameStates.map((gs) => [gs.discordId, gs.state as unknown as GameState]),
);
const entries = players
.filter((p) => parseShowOnLeaderboards(p.profileSettings))
.map((p) => {
let value = 0;
if (category === "totalGold") {
value = p.lifetimeGoldEarned;
} else if (category === "bossesDefeated") {
value = p.lifetimeBossesDefeated;
} else if (category === "questsCompleted") {
value = p.lifetimeQuestsCompleted;
} else if (category === "achievementsUnlocked") {
value = p.lifetimeAchievementsUnlocked;
} else {
const state = stateMap.get(p.discordId);
if (category === "prestigeCount") {
value = state?.prestige?.count ?? 0;
} else if (category === "transcendenceCount") {
value = state?.transcendence?.count ?? 0;
} else if (category === "apotheosisCount") {
value = state?.apotheosis?.count ?? 0;
}
}
const titleId = p.activeTitle ?? "";
const titleName = titleId
? (TITLES.find((t) => t.id === titleId)?.name ?? titleId)
: "";
return {
discordId: p.discordId,
characterName: p.characterName,
username: p.username,
avatar: p.avatar ?? null,
activeTitle: titleName,
value,
};
})
.sort((a, b) => b.value - a.value)
.slice(0, limit)
.map((entry, index) => ({ ...entry, rank: index + 1 }));
return context.json({ category, entries });
});
+2
View File
@@ -39,6 +39,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
showAdventurersRecruited: obj.showAdventurersRecruited !== false,
showAchievementsUnlocked: obj.showAchievementsUnlocked !== false,
numberFormat,
showOnLeaderboards: obj.showOnLeaderboards !== false,
};
}
return { ...DEFAULT_PROFILE_SETTINGS };
@@ -146,6 +147,7 @@ profileRouter.put("/", authMiddleware, async (context) => {
showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false,
showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false,
numberFormat,
showOnLeaderboards: body.profileSettings?.showOnLeaderboards !== false,
};
if (!characterName) {
+5
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { GameProvider } from "./context/GameContext.js";
import { CharacterPage } from "./components/game/CharacterPage.js";
import { GameLayout } from "./components/game/GameLayout.js";
import { LeaderboardPage } from "./components/game/LeaderboardPage.js";
import { LoginPage } from "./components/game/LoginPage.js";
import { ProfilePage } from "./components/game/ProfilePage.js";
@@ -49,6 +50,10 @@ export const App = (): React.JSX.Element => {
return <CharacterPage discordId={characterDiscordId} />;
}
if (window.location.pathname === "/leaderboards") {
return <LeaderboardPage />;
}
if (!loggedIn) {
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
}
@@ -75,6 +75,10 @@ const HOW_TO_PLAY = [
title: "🗡️ Equipment",
body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time — visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.",
},
{
title: "🏆 Leaderboards",
body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -310,6 +310,9 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
>
{copied ? "✓ Copied!" : "🔗 Share"}
</button>
<a className="character-sheet-edit-btn" href="/leaderboards">
🏆 Boards
</a>
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
Edit
</button>
@@ -179,6 +179,21 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
</div>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">Privacy</p>
<p className="edit-profile-sublabel">Control your visibility on public leaderboards.</p>
<button
className={`stat-toggle-btn ${settings.showOnLeaderboards ? "stat-toggle-on" : "stat-toggle-off"}`}
onClick={() => { toggleSetting("showOnLeaderboards"); }}
type="button"
>
<span>🏆 Appear on Leaderboards</span>
<span className="stat-toggle-indicator">
{settings.showOnLeaderboards ? "✓ Shown" : "Hidden"}
</span>
</button>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">Number Format</p>
<p className="edit-profile-sublabel">How large numbers appear across the game.</p>
@@ -0,0 +1,177 @@
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
import { useEffect, useState } from "react";
interface CategoryConfig {
id: LeaderboardCategory;
label: string;
icon: string;
formatValue: (value: number) => string;
}
const CATEGORIES: CategoryConfig[] = [
{
id: "totalGold",
label: "Lifetime Gold",
icon: "🪙",
formatValue: (v) => formatGold(v),
},
{
id: "bossesDefeated",
label: "Bosses Defeated",
icon: "💀",
formatValue: (v) => v.toLocaleString(),
},
{
id: "questsCompleted",
label: "Quests Completed",
icon: "📜",
formatValue: (v) => v.toLocaleString(),
},
{
id: "achievementsUnlocked",
label: "Achievements",
icon: "🏆",
formatValue: (v) => v.toLocaleString(),
},
{
id: "prestigeCount",
label: "Prestige",
icon: "⭐",
formatValue: (v) => v.toLocaleString(),
},
{
id: "transcendenceCount",
label: "Transcendence",
icon: "🌌",
formatValue: (v) => v.toLocaleString(),
},
{
id: "apotheosisCount",
label: "Apotheosis",
icon: "✨",
formatValue: (v) => v.toLocaleString(),
},
];
const SUFFIXES = ["", "K", "M", "B", "T", "Qa", "Qt", "S", "Sp", "O", "N", "D"];
const formatGold = (value: number): string => {
if (value === 0) return "0";
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
const clamped = Math.min(tier, SUFFIXES.length - 1);
const scaled = value / Math.pow(1000, clamped);
return `${parseFloat(scaled.toFixed(2))}${SUFFIXES[clamped] ?? ""}`;
};
const RANK_BADGES: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
export const LeaderboardPage = (): React.JSX.Element => {
const [category, setCategory] = useState<LeaderboardCategory>("totalGold");
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/leaderboards?category=${category}&limit=100`)
.then(async (res) => {
if (!res.ok) throw new Error("Failed to load leaderboard");
const data = (await res.json()) as { entries: LeaderboardEntry[] };
setEntries(data.entries);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load leaderboard");
})
.finally(() => { setLoading(false); });
}, [category]);
const currentConfig = CATEGORIES.find((c) => c.id === category) ?? CATEGORIES[0];
return (
<div className="leaderboard-page">
<div className="leaderboard-card">
<div className="leaderboard-header">
<h1 className="leaderboard-title">🏆 Leaderboards</h1>
<p className="leaderboard-subtitle">The mightiest adventurers in Elysium</p>
</div>
<div className="leaderboard-tabs">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
className={`leaderboard-tab ${category === cat.id ? "leaderboard-tab--active" : ""}`}
onClick={() => { setCategory(cat.id); }}
type="button"
>
{cat.icon} {cat.label}
</button>
))}
</div>
{loading && (
<div className="leaderboard-loading">Loading</div>
)}
{error && (
<div className="leaderboard-error"> {error}</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="leaderboard-empty">No entries yet be the first on the board!</div>
)}
{!loading && !error && entries.length > 0 && (
<div className="leaderboard-table">
<div className="leaderboard-table-header">
<span className="leaderboard-col-rank">Rank</span>
<span className="leaderboard-col-player">Player</span>
<span className="leaderboard-col-value">{currentConfig?.icon} {currentConfig?.label}</span>
</div>
{entries.map((entry) => {
const avatarUrl = entry.avatar
? `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`
: `https://cdn.discordapp.com/embed/avatars/${parseInt(entry.discordId, 10) % 5}.png`;
const displayName = entry.characterName || entry.username;
return (
<a
className={`leaderboard-row ${entry.rank <= 3 ? `leaderboard-row--top${entry.rank}` : ""}`}
href={`/character/${entry.discordId}`}
key={entry.discordId}
>
<span className="leaderboard-col-rank">
{RANK_BADGES[entry.rank] ?? `#${entry.rank}`}
</span>
<span className="leaderboard-col-player">
<img
alt={displayName}
className="leaderboard-avatar"
src={avatarUrl}
/>
<span className="leaderboard-player-info">
<span className="leaderboard-player-name">{displayName}</span>
{entry.activeTitle && (
<span className="leaderboard-player-title">{entry.activeTitle}</span>
)}
</span>
</span>
<span className="leaderboard-col-value">
{currentConfig?.formatValue(entry.value)}
</span>
</a>
);
})}
</div>
)}
<div className="leaderboard-footer">
<a className="leaderboard-play-link" href="/"> Play Elysium</a>
<p className="leaderboard-privacy-note">
Players can opt out via their profile settings.
</p>
</div>
</div>
</div>
);
};
+201
View File
@@ -3564,3 +3564,204 @@ body {
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
/* ── Leaderboard Page ─────────────────────────────────────────────────────── */
.leaderboard-page {
align-items: flex-start;
background: var(--colour-bg);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 2rem 1rem;
}
.leaderboard-card {
background: var(--colour-bg-panel);
border: 1px solid var(--colour-border);
border-radius: var(--radius-lg);
max-width: 800px;
padding: 2rem;
width: 100%;
}
.leaderboard-header {
margin-bottom: 1.5rem;
text-align: center;
}
.leaderboard-title {
font-size: 2rem;
font-weight: 800;
margin: 0 0 0.25rem;
}
.leaderboard-subtitle {
color: var(--colour-text-muted);
font-size: 0.9rem;
margin: 0;
}
.leaderboard-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.5rem;
}
.leaderboard-tab {
background: var(--colour-bg-alt);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text-muted);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
transition: all 0.15s ease;
}
.leaderboard-tab:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.leaderboard-tab--active {
background: var(--colour-accent);
border-color: var(--colour-accent);
color: #fff;
}
.leaderboard-loading,
.leaderboard-error,
.leaderboard-empty {
padding: 2rem;
text-align: center;
}
.leaderboard-error {
color: var(--colour-danger, #ef4444);
}
.leaderboard-empty {
color: var(--colour-text-muted);
}
.leaderboard-table {
border: 1px solid var(--colour-border);
border-radius: var(--radius);
overflow: hidden;
}
.leaderboard-table-header {
background: var(--colour-bg-alt);
color: var(--colour-text-muted);
display: grid;
font-size: 0.75rem;
font-weight: 700;
grid-template-columns: 3rem 1fr 8rem;
padding: 0.6rem 1rem;
text-transform: uppercase;
}
.leaderboard-row {
align-items: center;
border-top: 1px solid var(--colour-border);
color: var(--colour-text);
display: grid;
grid-template-columns: 3rem 1fr 8rem;
padding: 0.75rem 1rem;
text-decoration: none;
transition: background 0.15s ease;
}
.leaderboard-row:hover {
background: var(--colour-bg-alt);
}
.leaderboard-row--top1 {
background: rgba(251, 191, 36, 0.08);
}
.leaderboard-row--top2 {
background: rgba(148, 163, 184, 0.08);
}
.leaderboard-row--top3 {
background: rgba(180, 120, 70, 0.08);
}
.leaderboard-col-rank {
font-size: 1.1rem;
font-weight: 700;
text-align: center;
}
.leaderboard-col-player {
align-items: center;
display: flex;
gap: 0.75rem;
min-width: 0;
}
.leaderboard-avatar {
border-radius: 50%;
flex-shrink: 0;
height: 32px;
width: 32px;
}
.leaderboard-player-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.leaderboard-player-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-player-title {
color: var(--colour-accent);
font-size: 0.75rem;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-col-value {
font-weight: 700;
text-align: right;
}
.leaderboard-footer {
align-items: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1.5rem;
}
.leaderboard-play-link {
background: var(--colour-accent);
border-radius: var(--radius);
color: #fff;
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem 1.5rem;
text-decoration: none;
}
.leaderboard-play-link:hover {
opacity: 0.85;
}
.leaderboard-privacy-note {
color: var(--colour-text-muted);
font-size: 0.75rem;
margin: 0;
}
+3
View File
@@ -38,6 +38,9 @@ export type {
ExploreStartRequest,
ExploreStartResponse,
GiteaRelease,
LeaderboardCategory,
LeaderboardEntry,
LeaderboardResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
+24
View File
@@ -183,6 +183,30 @@ export interface ApiError {
error: string;
}
export type LeaderboardCategory =
| "totalGold"
| "bossesDefeated"
| "questsCompleted"
| "achievementsUnlocked"
| "prestigeCount"
| "transcendenceCount"
| "apotheosisCount";
export interface LeaderboardEntry {
rank: number;
discordId: string;
characterName: string;
username: string;
avatar: string | null;
activeTitle: string;
value: number;
}
export interface LeaderboardResponse {
category: LeaderboardCategory;
entries: LeaderboardEntry[];
}
export interface GiteaRelease {
tag_name: string;
name: string;
@@ -20,6 +20,8 @@ export interface ProfileSettings {
showAdventurersRecruited: boolean;
showAchievementsUnlocked: boolean;
numberFormat: NumberFormat;
/** Whether this player appears on the public leaderboards */
showOnLeaderboards: boolean;
}
export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
@@ -40,4 +42,5 @@ export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
showAdventurersRecruited: true,
showAchievementsUnlocked: true,
numberFormat: "suffix",
showOnLeaderboards: true,
};