feat: initial elysium idle game prototype

Sets up the full monorepo with pnpm workspaces. Includes shared types
package, Hono API with Discord OAuth/JWT auth, Prisma v6 + MongoDB
Atlas, and React + Vite frontend with game loop, five tabs, and
Discord-linked save/load.
This commit is contained in:
2026-03-06 11:26:19 -08:00
committed by Naomi Carrigan
parent c69e155de3
commit a3daed1683
64 changed files with 9011 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import { NaomisConfig } from "@nhcarrigan/eslint-config";
export default [...NaomisConfig];
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@elysium/api",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./prod/src/index.js",
"scripts": {
"build": "prisma generate && tsc -p tsconfig.json",
"db:push": "prisma db push",
"dev": "op run --env-file=./prod.env -- tsx watch src/index.ts",
"lint": "eslint --max-warnings 0 src",
"start": "op run --env-file=./prod.env -- node prod/src/index.js",
"test": "vitest run --coverage"
},
"dependencies": {
"@elysium/types": "workspace:*",
"@hono/node-server": "1.13.7",
"@prisma/client": "6.5.0",
"hono": "4.7.4",
"prisma": "6.5.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@vitest/coverage-v8": "3.0.8",
"eslint": "9.22.0",
"tsx": "4.19.3",
"typescript": "5.8.2",
"vitest": "3.0.8"
}
}
+38
View File
@@ -0,0 +1,38 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
model Player {
id String @id @default(auto()) @map("_id") @db.ObjectId
discordId String @unique
username String
discriminator String
avatar String?
characterName String @default("")
createdAt Float
lastSavedAt Float
totalGoldEarned Float @default(0)
totalClicks Float @default(0)
}
model GameState {
id String @id @default(auto()) @map("_id") @db.ObjectId
discordId String @unique
state Json
updatedAt Float
}
model BossDamageLog {
id String @id @default(auto()) @map("_id") @db.ObjectId
discordId String
bossId String
damage Float
dealtAt Float
@@index([discordId, bossId, dealtAt])
}
+5
View File
@@ -0,0 +1,5 @@
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
+104
View File
@@ -0,0 +1,104 @@
import type { Adventurer } from "@elysium/types";
export const DEFAULT_ADVENTURERS: Adventurer[] = [
{
id: "peasant",
name: "Peasant",
class: "warrior",
level: 1,
goldPerSecond: 0.1,
essencePerSecond: 0,
count: 0,
unlocked: true,
},
{
id: "militia",
name: "Militia",
class: "warrior",
level: 2,
goldPerSecond: 0.5,
essencePerSecond: 0,
count: 0,
unlocked: false,
},
{
id: "apprentice",
name: "Apprentice Mage",
class: "mage",
level: 3,
goldPerSecond: 1.5,
essencePerSecond: 0.01,
count: 0,
unlocked: false,
},
{
id: "scout",
name: "Scout",
class: "rogue",
level: 4,
goldPerSecond: 4,
essencePerSecond: 0.02,
count: 0,
unlocked: false,
},
{
id: "acolyte",
name: "Acolyte",
class: "cleric",
level: 5,
goldPerSecond: 10,
essencePerSecond: 0.05,
count: 0,
unlocked: false,
},
{
id: "ranger",
name: "Ranger",
class: "ranger",
level: 6,
goldPerSecond: 25,
essencePerSecond: 0.1,
count: 0,
unlocked: false,
},
{
id: "knight",
name: "Knight",
class: "warrior",
level: 7,
goldPerSecond: 75,
essencePerSecond: 0.2,
count: 0,
unlocked: false,
},
{
id: "archmage",
name: "Archmage",
class: "mage",
level: 8,
goldPerSecond: 200,
essencePerSecond: 0.5,
count: 0,
unlocked: false,
},
{
id: "paladin",
name: "Paladin",
class: "paladin",
level: 9,
goldPerSecond: 600,
essencePerSecond: 1,
count: 0,
unlocked: false,
},
{
id: "dragon_rider",
name: "Dragon Rider",
class: "ranger",
level: 10,
goldPerSecond: 2000,
essencePerSecond: 3,
count: 0,
unlocked: false,
},
];
+64
View File
@@ -0,0 +1,64 @@
import type { Boss } from "@elysium/types";
export const DEFAULT_BOSSES: Boss[] = [
{
id: "troll_king",
name: "The Troll King",
description:
"Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.",
status: "available",
maxHp: 1_000,
currentHp: 1_000,
damagePerSecond: 5,
goldReward: 10_000,
essenceReward: 25,
crystalReward: 0,
upgradeRewards: ["click_2"],
prestigeRequirement: 0,
},
{
id: "lich_queen",
name: "The Lich Queen",
description:
"Seraphina the Undying commands legions of undead from her bone throne. Her defeat will echo through history.",
status: "locked",
maxHp: 10_000,
currentHp: 10_000,
damagePerSecond: 20,
goldReward: 100_000,
essenceReward: 200,
crystalReward: 10,
upgradeRewards: ["global_2"],
prestigeRequirement: 0,
},
{
id: "elder_dragon",
name: "Elder Dragon Vaeltharox",
description:
"The eldest dragon in existence, older than the kingdom itself. Even his breath can level mountains.",
status: "locked",
maxHp: 100_000,
currentHp: 100_000,
damagePerSecond: 75,
goldReward: 1_000_000,
essenceReward: 1_000,
crystalReward: 50,
upgradeRewards: ["click_3"],
prestigeRequirement: 1,
},
{
id: "void_titan",
name: "The Void Titan",
description:
"A creature from beyond the veil of reality, drawn by the power your guild has accumulated. It must not be allowed to exist.",
status: "locked",
maxHp: 1_000_000,
currentHp: 1_000_000,
damagePerSecond: 250,
goldReward: 10_000_000,
essenceReward: 5_000,
crystalReward: 200,
upgradeRewards: [],
prestigeRequirement: 3,
},
];
+34
View File
@@ -0,0 +1,34 @@
import type { GameState, Player, PrestigeData } from "@elysium/types";
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
import { DEFAULT_BOSSES } from "./bosses.js";
import { DEFAULT_QUESTS } from "./quests.js";
import { DEFAULT_UPGRADES } from "./upgrades.js";
export const INITIAL_PRESTIGE: PrestigeData = {
count: 0,
runestones: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
};
export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameState => ({
player: {
...player,
characterName,
totalGoldEarned: 0,
totalClicks: 0,
},
resources: {
gold: 0,
essence: 0,
crystals: 0,
runestones: 0,
},
adventurers: structuredClone(DEFAULT_ADVENTURERS),
upgrades: structuredClone(DEFAULT_UPGRADES),
quests: structuredClone(DEFAULT_QUESTS),
bosses: structuredClone(DEFAULT_BOSSES),
prestige: INITIAL_PRESTIGE,
baseClickPower: 1,
lastTickAt: Date.now(),
});
+63
View File
@@ -0,0 +1,63 @@
import type { Quest } from "@elysium/types";
export const DEFAULT_QUESTS: Quest[] = [
{
id: "first_steps",
name: "First Steps",
description: "Every legend begins somewhere. Send your first adventurer into the field.",
status: "available",
durationSeconds: 60,
rewards: [{ type: "gold", amount: 500 }],
prerequisiteIds: [],
},
{
id: "goblin_camp",
name: "Goblin Camp",
description: "Clear out a troublesome goblin camp to the east.",
status: "locked",
durationSeconds: 5 * 60,
rewards: [
{ type: "gold", amount: 2_000 },
{ type: "essence", amount: 5 },
],
prerequisiteIds: ["first_steps"],
},
{
id: "haunted_mine",
name: "The Haunted Mine",
description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.",
status: "locked",
durationSeconds: 15 * 60,
rewards: [
{ type: "crystals", amount: 10 },
{ type: "upgrade", targetId: "global_1" },
],
prerequisiteIds: ["goblin_camp"],
},
{
id: "ancient_ruins",
name: "Ancient Ruins",
description: "Scholars believe the ruins hold secrets of a forgotten civilisation.",
status: "locked",
durationSeconds: 30 * 60,
rewards: [
{ type: "essence", amount: 50 },
{ type: "upgrade", targetId: "click_2" },
],
prerequisiteIds: ["haunted_mine"],
},
{
id: "dragon_lair",
name: "Dragon's Lair",
description:
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
status: "locked",
durationSeconds: 60 * 60,
rewards: [
{ type: "gold", amount: 500_000 },
{ type: "crystals", amount: 50 },
{ type: "adventurer", targetId: "dragon_rider" },
],
prerequisiteIds: ["ancient_ruins"],
},
];
+98
View File
@@ -0,0 +1,98 @@
import type { Upgrade } from "@elysium/types";
export const DEFAULT_UPGRADES: Upgrade[] = [
// Click upgrades
{
id: "click_1",
name: "Keen Eye",
description: "Your strikes find weak points. Doubles click power.",
target: "click",
multiplier: 2,
costGold: 100,
costEssence: 0,
purchased: false,
unlocked: true,
},
{
id: "click_2",
name: "Battle Hardened",
description: "Years of combat sharpen your instincts. Doubles click power again.",
target: "click",
multiplier: 2,
costGold: 1000,
costEssence: 0,
purchased: false,
unlocked: false,
},
{
id: "click_3",
name: "Legendary Weapon",
description: "A weapon of ancient power. Triples click power.",
target: "click",
multiplier: 3,
costGold: 50_000,
costEssence: 10,
purchased: false,
unlocked: false,
},
// Global upgrades
{
id: "global_1",
name: "Guild Charter",
description: "Formalising the guild structure increases all income by 25%.",
target: "global",
multiplier: 1.25,
costGold: 500,
costEssence: 0,
purchased: false,
unlocked: false,
},
{
id: "global_2",
name: "Merchant Alliance",
description: "Trade routes boost all income by 50%.",
target: "global",
multiplier: 1.5,
costGold: 10_000,
costEssence: 5,
purchased: false,
unlocked: false,
},
// Adventurer-specific upgrades
{
id: "peasant_1",
name: "Better Tools",
description: "Peasants work twice as hard with proper equipment.",
target: "adventurer",
adventurerId: "peasant",
multiplier: 2,
costGold: 200,
costEssence: 0,
purchased: false,
unlocked: false,
},
{
id: "militia_1",
name: "Militia Training",
description: "Formal training doubles militia effectiveness.",
target: "adventurer",
adventurerId: "militia",
multiplier: 2,
costGold: 1_000,
costEssence: 0,
purchased: false,
unlocked: false,
},
{
id: "mage_1",
name: "Arcane Tomes",
description: "Ancient books of magic double mage output.",
target: "adventurer",
adventurerId: "apprentice",
multiplier: 2,
costGold: 5_000,
costEssence: 2,
purchased: false,
unlocked: false,
},
];
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
+35
View File
@@ -0,0 +1,35 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { gameRouter } from "./routes/game.js";
import { prestigeRouter } from "./routes/prestige.js";
import { profileRouter } from "./routes/profile.js";
const app = new Hono();
app.use("*", logger());
app.use(
"*",
cors({
origin: process.env["CORS_ORIGIN"] ?? "http://localhost:5173",
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}),
);
app.route("/auth", authRouter);
app.route("/game", gameRouter);
app.route("/boss", bossRouter);
app.route("/prestige", prestigeRouter);
app.route("/profile", profileRouter);
app.get("/health", (context) => context.json({ status: "ok" }));
const port = Number(process.env["PORT"] ?? 3001);
serve({ fetch: app.fetch, port }, () => {
console.log(`Elysium API running on port ${port}`);
});
+23
View File
@@ -0,0 +1,23 @@
import type { Context, Next } from "hono";
import { verifyToken } from "../services/jwt.js";
export const authMiddleware = async (context: Context, next: Next): Promise<void> => {
const authorization = context.req.header("Authorization");
if (!authorization?.startsWith("Bearer ")) {
context.status(401);
context.json({ error: "Missing or invalid Authorization header" });
return;
}
const token = authorization.slice(7);
try {
const payload = verifyToken(token);
context.set("discordId", payload.discordId);
await next();
} catch {
context.status(401);
context.json({ error: "Invalid or expired token" });
}
};
+97
View File
@@ -0,0 +1,97 @@
import type { Player } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
import {
buildOAuthUrl,
exchangeCode,
fetchDiscordUser,
} from "../services/discord.js";
import { signToken } from "../services/jwt.js";
export const authRouter = new Hono();
authRouter.get("/url", (context) => {
try {
const url = buildOAuthUrl();
return context.json({ url });
} catch {
return context.json({ error: "Failed to build OAuth URL" }, 500);
}
});
authRouter.get("/callback", async (context) => {
const code = context.req.query("code");
if (!code) {
return context.json({ error: "Missing code parameter" }, 400);
}
try {
const tokenData = await exchangeCode(code);
const discordUser = await fetchDiscordUser(tokenData.access_token);
const existing = await prisma.player.findUnique({
where: { discordId: discordUser.id },
});
const now = Date.now();
if (!existing) {
const player = await prisma.player.create({
data: {
discordId: discordUser.id,
username: discordUser.username,
discriminator: discordUser.discriminator,
avatar: discordUser.avatar,
characterName: discordUser.username,
createdAt: now,
lastSavedAt: now,
totalGoldEarned: 0,
totalClicks: 0,
},
});
const playerShape: Player = {
discordId: player.discordId,
username: player.username,
discriminator: player.discriminator,
avatar: player.avatar ?? null,
characterName: player.characterName,
createdAt: player.createdAt,
lastSavedAt: player.lastSavedAt,
totalGoldEarned: player.totalGoldEarned,
totalClicks: player.totalClicks,
};
const initialState = INITIAL_GAME_STATE(playerShape, playerShape.characterName);
await prisma.gameState.create({
data: {
discordId: player.discordId,
state: initialState,
updatedAt: now,
},
});
const jwtToken = signToken(player.discordId);
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`);
}
const updated = await prisma.player.update({
where: { discordId: discordUser.id },
data: {
username: discordUser.username,
discriminator: discordUser.discriminator,
avatar: discordUser.avatar,
},
});
const jwtToken = signToken(updated.discordId);
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`);
} catch {
const clientUrl = process.env["CORS_ORIGIN"] ?? "http://localhost:5173";
return context.redirect(`${clientUrl}/auth/callback?error=auth_failed`);
}
});
+100
View File
@@ -0,0 +1,100 @@
import type { BossDamageRequest, GameState } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
const RATE_LIMIT_WINDOW_MS = 1_000;
const MAX_DAMAGE_PER_SECOND = 10_000;
export const bossRouter = new Hono();
bossRouter.use("*", authMiddleware);
bossRouter.post("/damage", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<BossDamageRequest>();
if (!body.bossId || body.damage == null || body.damage <= 0) {
return context.json({ error: "Invalid request body" }, 400);
}
// Rate limiting: sum damage dealt to this boss in the last second
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
const aggregate = await prisma.bossDamageLog.aggregate({
where: { discordId, bossId: body.bossId, dealtAt: { gt: windowStart } },
_sum: { damage: true },
});
const recentDamage = aggregate._sum.damage ?? 0;
if (recentDamage + body.damage > MAX_DAMAGE_PER_SECOND) {
return context.json({ error: "Rate limit exceeded" }, 429);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const boss = state.bosses.find((b) => b.id === body.bossId);
if (!boss) {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "in_progress" && boss.status !== "available") {
return context.json({ error: "Boss is not currently available" }, 400);
}
if (boss.prestigeRequirement > state.prestige.count) {
return context.json({ error: "Prestige requirement not met" }, 403);
}
await prisma.bossDamageLog.create({
data: { discordId, bossId: body.bossId, damage: body.damage, dealtAt: Date.now() },
});
boss.status = "in_progress";
boss.currentHp = Math.max(0, boss.currentHp - body.damage);
const defeated = boss.currentHp <= 0;
let rewards: { gold: number; essence: number; crystals: number; upgradeIds: string[] } | undefined;
if (defeated) {
boss.status = "defeated";
state.resources.gold += boss.goldReward;
state.resources.essence += boss.essenceReward;
state.resources.crystals += boss.crystalReward;
state.player.totalGoldEarned += boss.goldReward;
for (const upgradeId of boss.upgradeRewards) {
const upgrade = state.upgrades.find((u) => u.id === upgradeId);
if (upgrade) {
upgrade.unlocked = true;
}
}
const bossIndex = state.bosses.findIndex((b) => b.id === body.bossId);
const nextBoss = state.bosses[bossIndex + 1];
if (nextBoss && nextBoss.prestigeRequirement <= state.prestige.count) {
nextBoss.status = "available";
}
rewards = {
gold: boss.goldReward,
essence: boss.essenceReward,
crystals: boss.crystalReward,
upgradeIds: boss.upgradeRewards,
};
}
const now = Date.now();
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
return context.json({ currentHp: boss.currentHp, defeated, rewards });
});
+62
View File
@@ -0,0 +1,62 @@
import type { GameState, SaveRequest } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { calculateOfflineGold } from "../services/offlineProgress.js";
export const gameRouter = new Hono();
gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async (context) => {
const discordId = context.get("discordId") as string;
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
const now = Date.now();
const { offlineGold, offlineSeconds } = calculateOfflineGold(state, now);
if (offlineGold > 0) {
state.resources.gold += offlineGold;
state.player.totalGoldEarned += offlineGold;
}
state.lastTickAt = now;
return context.json({ state, offlineGold, offlineSeconds });
});
gameRouter.post("/save", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<SaveRequest>();
if (!body.state) {
return context.json({ error: "Missing state in request body" }, 400);
}
const now = Date.now();
await prisma.player.update({
where: { discordId },
data: {
lastSavedAt: now,
totalGoldEarned: body.state.player.totalGoldEarned,
totalClicks: body.state.player.totalClicks,
characterName: body.state.player.characterName,
},
});
await prisma.gameState.upsert({
where: { discordId },
create: { discordId, state: body.state, updatedAt: now },
update: { state: body.state, updatedAt: now },
});
return context.json({ savedAt: now });
});
+63
View File
@@ -0,0 +1,63 @@
import type { GameState, PrestigeRequest } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import {
buildPostPrestigeState,
isEligibleForPrestige,
} from "../services/prestige.js";
export const prestigeRouter = new Hono();
prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<PrestigeRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!isEligibleForPrestige(state)) {
return context.json(
{ error: "Not eligible for prestige — collect 1,000,000 total gold first" },
400,
);
}
const { newState, newPrestigeData, runestonesEarned } = buildPostPrestigeState(
state,
characterName,
);
const now = Date.now();
await prisma.gameState.update({
where: { discordId },
data: { state: newState as object, updatedAt: now },
});
await prisma.player.update({
where: { discordId },
data: {
characterName,
totalGoldEarned: 0,
totalClicks: 0,
lastSavedAt: now,
},
});
return context.json({
runestones: runestonesEarned,
newPrestigeCount: newPrestigeData.count,
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { GameState } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
export const profileRouter = new Hono();
profileRouter.get("/:discordId", async (context) => {
const { discordId } = context.req.param();
const [player, gameStateRecord] = await Promise.all([
prisma.player.findUnique({ where: { discordId } }),
prisma.gameState.findUnique({ where: { discordId } }),
]);
if (!player) {
return context.json({ error: "Player not found" }, 404);
}
const state = gameStateRecord?.state as unknown as GameState | undefined;
const prestigeCount = state?.prestige.count ?? 0;
return context.json({
characterName: player.characterName,
username: player.username,
avatar: player.avatar ?? null,
prestigeCount,
totalGoldEarned: player.totalGoldEarned,
totalClicks: player.totalClicks,
createdAt: player.createdAt,
});
});
+74
View File
@@ -0,0 +1,74 @@
export interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
export interface DiscordUser {
id: string;
username: string;
discriminator: string;
avatar: string | null;
}
export const exchangeCode = async (code: string): Promise<DiscordTokenResponse> => {
const clientId = process.env["DISCORD_CLIENT_ID"];
const clientSecret = process.env["DISCORD_CLIENT_SECRET"];
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
if (!clientId || !clientSecret || !redirectUri) {
throw new Error("Discord OAuth environment variables are required");
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
});
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
if (!response.ok) {
throw new Error(`Discord token exchange failed: ${response.statusText}`);
}
return response.json() as Promise<DiscordTokenResponse>;
};
export const fetchDiscordUser = async (accessToken: string): Promise<DiscordUser> => {
const response = await fetch("https://discord.com/api/v10/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Discord user fetch failed: ${response.statusText}`);
}
return response.json() as Promise<DiscordUser>;
};
export const buildOAuthUrl = (): string => {
const clientId = process.env["DISCORD_CLIENT_ID"];
const redirectUri = process.env["DISCORD_REDIRECT_URI"];
if (!clientId || !redirectUri) {
throw new Error("Discord OAuth environment variables are required");
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "identify",
});
return `https://discord.com/api/oauth2/authorize?${params.toString()}`;
};
+65
View File
@@ -0,0 +1,65 @@
import { createHmac } from "crypto";
interface JwtPayload {
discordId: string;
iat: number;
exp: number;
}
const base64UrlEncode = (data: string): string =>
Buffer.from(data).toString("base64url");
const base64UrlDecode = (data: string): string =>
Buffer.from(data, "base64url").toString("utf8");
export const signToken = (discordId: string): string => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = base64UrlEncode(
JSON.stringify({
discordId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days
}),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
};
export const verifyToken = (token: string): JwtPayload => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid token format");
}
const [header, payload, signature] = parts as [string, string, string];
const expectedSignature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
if (signature !== expectedSignature) {
throw new Error("Invalid token signature");
}
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
if (decoded.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token has expired");
}
return decoded;
};
+45
View File
@@ -0,0 +1,45 @@
import type { GameState } from "@elysium/types";
const MAX_OFFLINE_SECONDS = 8 * 60 * 60; // 8 hours
/**
* Calculates the gold earned whilst the player was offline.
* Capped at 8 hours to prevent exploit via system clock manipulation.
*/
export const calculateOfflineGold = (
state: GameState,
nowMs: number,
): { offlineGold: number; offlineSeconds: number } => {
const elapsedSeconds = Math.min(
(nowMs - state.lastTickAt) / 1000,
MAX_OFFLINE_SECONDS,
);
const goldPerSecond = state.adventurers.reduce((total, adventurer) => {
if (!adventurer.unlocked || adventurer.count === 0) {
return total;
}
const upgradeMultiplier = state.upgrades
.filter(
(u) =>
u.purchased &&
(u.target === "global" ||
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
)
.reduce((mult, u) => mult * u.multiplier, 1);
return (
total +
adventurer.goldPerSecond *
adventurer.count *
upgradeMultiplier *
state.prestige.productionMultiplier
);
}, 0);
return {
offlineGold: goldPerSecond * elapsedSeconds,
offlineSeconds: elapsedSeconds,
};
};
+54
View File
@@ -0,0 +1,54 @@
import type { GameState, PrestigeData } from "@elysium/types";
import { INITIAL_GAME_STATE } from "../data/initialState.js";
const PRESTIGE_GOLD_THRESHOLD = 1_000_000;
const RUNESTONES_PER_PRESTIGE_LEVEL = 10;
export const isEligibleForPrestige = (state: GameState): boolean =>
state.player.totalGoldEarned >= PRESTIGE_GOLD_THRESHOLD;
/**
* Calculates how many runestones the player earns from a prestige.
* Formula: floor(sqrt(totalGoldEarned / PRESTIGE_GOLD_THRESHOLD)) * RUNESTONES_PER_PRESTIGE_LEVEL
*/
export const calculateRunestones = (totalGoldEarned: number): number =>
Math.floor(Math.sqrt(totalGoldEarned / PRESTIGE_GOLD_THRESHOLD)) *
RUNESTONES_PER_PRESTIGE_LEVEL;
/**
* Calculates the new prestige production multiplier.
* Formula: 1 + (prestigeCount * 0.1) — each prestige adds 10% global production.
*/
export const calculateProductionMultiplier = (prestigeCount: number): number =>
1 + prestigeCount * 0.1;
/**
* Generates the reset game state after a prestige.
* Carries over prestige data and runestones; resets everything else.
*/
export const buildPostPrestigeState = (
currentState: GameState,
characterName: string,
): { newState: GameState; newPrestigeData: PrestigeData; runestonesEarned: number } => {
const runestonesEarned = calculateRunestones(
currentState.player.totalGoldEarned,
);
const newPrestigeCount = currentState.prestige.count + 1;
const newPrestigeData: PrestigeData = {
count: newPrestigeCount,
runestones: currentState.prestige.runestones + runestonesEarned,
productionMultiplier: calculateProductionMultiplier(newPrestigeCount),
purchasedUpgradeIds: currentState.prestige.purchasedUpgradeIds,
lastPrestigedAt: Date.now(),
};
const freshState = INITIAL_GAME_STATE(currentState.player, characterName);
const newState: GameState = {
...freshState,
prestige: newPrestigeData,
lastTickAt: Date.now(),
};
return { newState, newPrestigeData, runestonesEarned };
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": "."
},
"exclude": ["test/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/types/**/*.ts"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
include: ["test/**/*.spec.ts"],
},
});