generated from nhcarrigan/template
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:
@@ -0,0 +1,3 @@
|
||||
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [...NaomisConfig];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "."
|
||||
},
|
||||
"exclude": ["test/**/*.ts"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user