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,57 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: Lint, Build & Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check dependency pins
|
||||||
|
uses: naomi-lgbt/dependency-pin-check@main
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: "10"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint (types package)
|
||||||
|
run: pnpm --filter @elysium/types lint
|
||||||
|
|
||||||
|
- name: Lint (API)
|
||||||
|
run: pnpm --filter @elysium/api lint
|
||||||
|
|
||||||
|
- name: Lint (web)
|
||||||
|
run: pnpm --filter @elysium/web lint
|
||||||
|
|
||||||
|
- name: Build (types package)
|
||||||
|
run: pnpm --filter @elysium/types build
|
||||||
|
|
||||||
|
- name: Build (API)
|
||||||
|
run: pnpm --filter @elysium/api build
|
||||||
|
|
||||||
|
- name: Build (web)
|
||||||
|
run: pnpm --filter @elysium/web build
|
||||||
|
|
||||||
|
- name: Test (API)
|
||||||
|
run: pnpm --filter @elysium/api test
|
||||||
|
|
||||||
|
- name: Test (web)
|
||||||
|
run: pnpm --filter @elysium/web test
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
prod/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
coverage/
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { NaomisConfig } from "@nhcarrigan/eslint-config";
|
||||||
|
|
||||||
|
export default [...NaomisConfig];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Elysium — Idle RPG</title>
|
||||||
|
<meta name="description" content="An idle fantasy RPG — hire adventurers, defeat bosses, and ascend to glory." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@elysium/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"lint": "eslint --max-warnings 0 src",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@elysium/types": "workspace:*",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"@types/react": "19.0.10",
|
||||||
|
"@types/react-dom": "19.0.4",
|
||||||
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
|
"@vitest/coverage-v8": "3.0.8",
|
||||||
|
"eslint": "9.22.0",
|
||||||
|
"jsdom": "26.0.0",
|
||||||
|
"typescript": "5.8.2",
|
||||||
|
"vite": "6.2.1",
|
||||||
|
"vitest": "3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { GameProvider } from "./context/GameContext.js";
|
||||||
|
import { GameLayout } from "./components/game/GameLayout.js";
|
||||||
|
import { LoginPage } from "./components/game/LoginPage.js";
|
||||||
|
|
||||||
|
const handleAuthCallback = (): boolean => {
|
||||||
|
if (window.location.pathname !== "/auth/callback") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get("token");
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("elysium_token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
return Boolean(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthenticated = (): boolean => {
|
||||||
|
const fromCallback = handleAuthCallback();
|
||||||
|
return fromCallback || Boolean(localStorage.getItem("elysium_token"));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App = (): React.JSX.Element => {
|
||||||
|
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
|
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameProvider>
|
||||||
|
<GameLayout />
|
||||||
|
</GameProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
BossDamageRequest,
|
||||||
|
BossDamageResponse,
|
||||||
|
LoadResponse,
|
||||||
|
PrestigeRequest,
|
||||||
|
PrestigeResponse,
|
||||||
|
PublicProfileResponse,
|
||||||
|
SaveRequest,
|
||||||
|
SaveResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
|
const BASE_URL = "/api";
|
||||||
|
|
||||||
|
const getToken = (): string | null => localStorage.getItem("elysium_token");
|
||||||
|
|
||||||
|
const headers = (): Record<string, string> => {
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = async <T>(
|
||||||
|
path: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...headers(), ...options?.headers },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (await response.json().catch(() => ({ error: "Unknown error" }))) as {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
throw new Error(error.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuthUrl = async (): Promise<string> => {
|
||||||
|
const data = await request<{ url: string }>("/auth/url");
|
||||||
|
return data.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAuthCallback = async (code: string): Promise<AuthResponse> => {
|
||||||
|
const data = await request<AuthResponse>(`/auth/callback?code=${code}`);
|
||||||
|
localStorage.setItem("elysium_token", data.token);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadGame = async (): Promise<LoadResponse> =>
|
||||||
|
request<LoadResponse>("/game/load");
|
||||||
|
|
||||||
|
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||||
|
request<SaveResponse>("/game/save", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dealBossDamage = async (
|
||||||
|
body: BossDamageRequest,
|
||||||
|
): Promise<BossDamageResponse> =>
|
||||||
|
request<BossDamageResponse>("/boss/damage", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prestige = async (body: PrestigeRequest): Promise<PrestigeResponse> =>
|
||||||
|
request<PrestigeResponse>("/prestige", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPublicProfile = async (
|
||||||
|
discordId: string,
|
||||||
|
): Promise<PublicProfileResponse> =>
|
||||||
|
request<PublicProfileResponse>(`/profile/${discordId}`);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Adventurer } from "@elysium/types";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
const CLASS_ICONS: Record<string, string> = {
|
||||||
|
warrior: "🗡️",
|
||||||
|
mage: "🔮",
|
||||||
|
rogue: "🗝️",
|
||||||
|
cleric: "✝️",
|
||||||
|
ranger: "🏹",
|
||||||
|
paladin: "🛡️",
|
||||||
|
};
|
||||||
|
|
||||||
|
const adventurerCost = (adventurer: Adventurer): number =>
|
||||||
|
Math.ceil(10 * Math.pow(1.15, adventurer.count));
|
||||||
|
|
||||||
|
interface AdventurerCardProps {
|
||||||
|
adventurer: Adventurer;
|
||||||
|
currentGold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdventurerCard = ({ adventurer, currentGold }: AdventurerCardProps): React.JSX.Element => {
|
||||||
|
const { buyAdventurer } = useGame();
|
||||||
|
const cost = adventurerCost(adventurer);
|
||||||
|
const canAfford = currentGold >= cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
|
||||||
|
<div className="adventurer-icon">{CLASS_ICONS[adventurer.class] ?? "⚔️"}</div>
|
||||||
|
<div className="adventurer-info">
|
||||||
|
<h3>{adventurer.name}</h3>
|
||||||
|
<p>{adventurer.goldPerSecond.toFixed(2)} gold/s each</p>
|
||||||
|
{adventurer.essencePerSecond > 0 && (
|
||||||
|
<p>{adventurer.essencePerSecond.toFixed(3)} essence/s each</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="adventurer-count">×{adventurer.count}</div>
|
||||||
|
<button
|
||||||
|
className="buy-button"
|
||||||
|
disabled={!canAfford || !adventurer.unlocked}
|
||||||
|
onClick={() => { buyAdventurer(adventurer.id); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{adventurer.unlocked ? `🪙 ${cost}` : "🔒 Locked"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdventurerPanel = (): React.JSX.Element => {
|
||||||
|
const { state } = useGame();
|
||||||
|
|
||||||
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel adventurer-panel">
|
||||||
|
<h2>Adventurers</h2>
|
||||||
|
<div className="adventurer-list">
|
||||||
|
{state.adventurers
|
||||||
|
.filter((a) => a.unlocked)
|
||||||
|
.map((adventurer) => (
|
||||||
|
<AdventurerCard
|
||||||
|
key={adventurer.id}
|
||||||
|
adventurer={adventurer}
|
||||||
|
currentGold={state.resources.gold}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Boss } from "@elysium/types";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
interface BossCardProps {
|
||||||
|
boss: Boss;
|
||||||
|
prestigeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element => {
|
||||||
|
const { attackBoss } = useGame();
|
||||||
|
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
|
||||||
|
const isLocked = boss.prestigeRequirement > prestigeCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`boss-card boss-${boss.status}`}>
|
||||||
|
<div className="boss-info">
|
||||||
|
<h3>{boss.name}</h3>
|
||||||
|
<p>{boss.description}</p>
|
||||||
|
{isLocked && boss.status === "locked" && (
|
||||||
|
<p className="prestige-lock">🔒 Requires Prestige {boss.prestigeRequirement}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boss.status !== "locked" && boss.status !== "defeated" && (
|
||||||
|
<div className="boss-hp">
|
||||||
|
<div className="hp-bar">
|
||||||
|
<div
|
||||||
|
className="hp-fill"
|
||||||
|
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="hp-text">
|
||||||
|
{boss.currentHp.toLocaleString()} / {boss.maxHp.toLocaleString()} HP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="boss-rewards">
|
||||||
|
<span>🪙 {boss.goldReward.toLocaleString()}</span>
|
||||||
|
{boss.essenceReward > 0 && <span>✨ {boss.essenceReward.toLocaleString()}</span>}
|
||||||
|
{boss.crystalReward > 0 && <span>💎 {boss.crystalReward.toLocaleString()}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boss.status === "available" || boss.status === "in_progress" ? (
|
||||||
|
<button
|
||||||
|
className="attack-button"
|
||||||
|
onClick={() => { void attackBoss(boss.id); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
⚔️ Attack
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{boss.status === "defeated" && (
|
||||||
|
<span className="boss-badge defeated">☠️ Defeated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BossPanel = (): React.JSX.Element => {
|
||||||
|
const { state } = useGame();
|
||||||
|
|
||||||
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel boss-panel">
|
||||||
|
<h2>Boss Encounters</h2>
|
||||||
|
<div className="boss-list">
|
||||||
|
{state.bosses.map((boss) => (
|
||||||
|
<BossCard
|
||||||
|
key={boss.id}
|
||||||
|
boss={boss}
|
||||||
|
prestigeCount={state.prestige.count}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
import { calculateClickPower } from "../../engine/tick.js";
|
||||||
|
|
||||||
|
export const ClickArea = (): React.JSX.Element => {
|
||||||
|
const { state, handleClick } = useGame();
|
||||||
|
|
||||||
|
if (!state) return <div className="click-area-placeholder" />;
|
||||||
|
|
||||||
|
const clickPower = calculateClickPower(state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="click-area">
|
||||||
|
<h2>Guild Hall</h2>
|
||||||
|
<button
|
||||||
|
className="click-button"
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Click to earn ${clickPower.toFixed(1)} gold`}
|
||||||
|
>
|
||||||
|
⚔️
|
||||||
|
</button>
|
||||||
|
<p className="click-power">+{clickPower.toFixed(1)} gold per click</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
import { ResourceBar } from "../ui/ResourceBar.js";
|
||||||
|
import { AdventurerPanel } from "./AdventurerPanel.js";
|
||||||
|
import { BossPanel } from "./BossPanel.js";
|
||||||
|
import { ClickArea } from "./ClickArea.js";
|
||||||
|
import { OfflineModal } from "./OfflineModal.js";
|
||||||
|
import { PrestigePanel } from "./PrestigePanel.js";
|
||||||
|
import { QuestPanel } from "./QuestPanel.js";
|
||||||
|
import { UpgradePanel } from "./UpgradePanel.js";
|
||||||
|
|
||||||
|
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "prestige";
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||||
|
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||||
|
{ id: "quests", label: "📜 Quests" },
|
||||||
|
{ id: "bosses", label: "👹 Bosses" },
|
||||||
|
{ id: "prestige", label: "⭐ Prestige" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GameLayout = (): React.JSX.Element => {
|
||||||
|
const { state, isLoading, error } = useGame();
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="loading-screen">
|
||||||
|
<p>Loading your adventure...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="error-screen">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) return <div className="loading-screen"><p>Loading...</p></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-layout">
|
||||||
|
<ResourceBar
|
||||||
|
resources={state.resources}
|
||||||
|
prestigeCount={state.prestige.count}
|
||||||
|
/>
|
||||||
|
<OfflineModal />
|
||||||
|
|
||||||
|
<div className="game-main">
|
||||||
|
<aside className="game-sidebar">
|
||||||
|
<ClickArea />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="game-content">
|
||||||
|
<nav className="tab-bar">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
|
||||||
|
onClick={() => { setActiveTab(tab.id); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="tab-content">
|
||||||
|
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||||
|
{activeTab === "upgrades" && <UpgradePanel />}
|
||||||
|
{activeTab === "quests" && <QuestPanel />}
|
||||||
|
{activeTab === "bosses" && <BossPanel />}
|
||||||
|
{activeTab === "prestige" && <PrestigePanel />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginPage = ({ onLogin }: LoginPageProps): React.JSX.Element => {
|
||||||
|
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle OAuth callback
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
setIsLoading(true);
|
||||||
|
handleAuthCallback(code)
|
||||||
|
.then(() => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
onLogin();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the Discord OAuth URL
|
||||||
|
getAuthUrl()
|
||||||
|
.then((url) => {
|
||||||
|
setAuthUrl(url);
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("Failed to load authentication URL");
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [onLogin]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { window.location.reload(); }}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>⚔️ Elysium</h1>
|
||||||
|
<p>An idle fantasy RPG. Hire adventurers, defeat bosses, and ascend to glory.</p>
|
||||||
|
<a
|
||||||
|
className="discord-login-button"
|
||||||
|
href={authUrl ?? "#"}
|
||||||
|
>
|
||||||
|
Login with Discord
|
||||||
|
</a>
|
||||||
|
<p className="login-note">
|
||||||
|
Your progress is saved to your Discord account and shareable with others!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
export const OfflineModal = (): React.JSX.Element | null => {
|
||||||
|
const { offlineGold, dismissOfflineGold } = useGame();
|
||||||
|
|
||||||
|
if (offlineGold <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>Welcome back!</h2>
|
||||||
|
<p>
|
||||||
|
Your adventurers kept working whilst you were away and earned{" "}
|
||||||
|
<strong>🪙 {offlineGold.toFixed(0)} gold</strong>!
|
||||||
|
</p>
|
||||||
|
<p className="modal-note">Offline progress is calculated up to 8 hours.</p>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
onClick={dismissOfflineGold}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Collect!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { prestige } from "../../api/client.js";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
const PRESTIGE_THRESHOLD = 1_000_000;
|
||||||
|
|
||||||
|
export const PrestigePanel = (): React.JSX.Element => {
|
||||||
|
const { state, reload } = useGame();
|
||||||
|
const [characterName, setCharacterName] = useState("");
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ runestones: number; count: number } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||||
|
|
||||||
|
const isEligible = state.player.totalGoldEarned >= PRESTIGE_THRESHOLD;
|
||||||
|
|
||||||
|
const handlePrestige = async (): Promise<void> => {
|
||||||
|
if (!characterName.trim()) return;
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await prestige({ characterName: characterName.trim() });
|
||||||
|
setResult({ runestones: data.runestones, count: data.newPrestigeCount });
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Prestige failed");
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel prestige-panel">
|
||||||
|
<h2>⭐ Prestige</h2>
|
||||||
|
<p>
|
||||||
|
Prestige resets your progress but grants <strong>Runestones</strong> — permanent
|
||||||
|
currency used for powerful upgrades. Each prestige also increases your global
|
||||||
|
production multiplier by 10%.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="prestige-status">
|
||||||
|
<p>
|
||||||
|
Total gold earned:{" "}
|
||||||
|
<strong>{state.player.totalGoldEarned.toLocaleString()}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Required: <strong>{PRESTIGE_THRESHOLD.toLocaleString()}</strong>
|
||||||
|
</p>
|
||||||
|
<p>Current prestige count: <strong>{state.prestige.count}</strong></p>
|
||||||
|
<p>
|
||||||
|
Production multiplier:{" "}
|
||||||
|
<strong>×{state.prestige.productionMultiplier.toFixed(1)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEligible ? (
|
||||||
|
<div className="prestige-form">
|
||||||
|
<p>You are ready to prestige! Choose your new character name:</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={characterName}
|
||||||
|
onChange={(e) => { setCharacterName(e.target.value); }}
|
||||||
|
placeholder="Character name..."
|
||||||
|
maxLength={32}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="prestige-button"
|
||||||
|
onClick={() => { void handlePrestige(); }}
|
||||||
|
disabled={isPending || !characterName.trim()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? "Ascending..." : "✨ Ascend"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{result && (
|
||||||
|
<p className="success">
|
||||||
|
Ascended to Prestige {result.count}! Earned {result.runestones} Runestones.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="prestige-locked">
|
||||||
|
Earn {(PRESTIGE_THRESHOLD - state.player.totalGoldEarned).toLocaleString()} more
|
||||||
|
gold to unlock prestige.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Quest } from "@elysium/types";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
|
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const questTimeRemaining = (quest: Quest): number => {
|
||||||
|
if (quest.status !== "active" || quest.startedAt == null) return 0;
|
||||||
|
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||||
|
return Math.max(0, quest.durationSeconds - elapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuestCardProps {
|
||||||
|
quest: Quest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => {
|
||||||
|
const { startQuest } = useGame();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<div className="quest-info">
|
||||||
|
<h3>{quest.name}</h3>
|
||||||
|
<p>{quest.description}</p>
|
||||||
|
<div className="quest-rewards">
|
||||||
|
{quest.rewards.map((reward, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key -- rewards have no unique id
|
||||||
|
<span key={index} className="reward-tag">
|
||||||
|
{reward.type === "gold" && `🪙 ${reward.amount?.toLocaleString()}`}
|
||||||
|
{reward.type === "essence" && `✨ ${reward.amount?.toLocaleString()}`}
|
||||||
|
{reward.type === "crystals" && `💎 ${reward.amount?.toLocaleString()}`}
|
||||||
|
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||||
|
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="quest-action">
|
||||||
|
{quest.status === "locked" && <span className="quest-badge locked">🔒 Locked</span>}
|
||||||
|
{quest.status === "available" && (
|
||||||
|
<button
|
||||||
|
className="start-quest-button"
|
||||||
|
onClick={() => { startQuest(quest.id); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Send Party ({formatDuration(quest.durationSeconds)})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{quest.status === "active" && (
|
||||||
|
<span className="quest-badge active">
|
||||||
|
⏳ {formatDuration(Math.ceil(questTimeRemaining(quest)))} remaining
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{quest.status === "completed" && <span className="quest-badge completed">✅ Complete</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestPanel = (): React.JSX.Element => {
|
||||||
|
const { state } = useGame();
|
||||||
|
|
||||||
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel quest-panel">
|
||||||
|
<h2>Quests</h2>
|
||||||
|
<div className="quest-list">
|
||||||
|
{state.quests.map((quest) => (
|
||||||
|
<QuestCard key={quest.id} quest={quest} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Upgrade } from "@elysium/types";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
interface UpgradeCardProps {
|
||||||
|
upgrade: Upgrade;
|
||||||
|
currentGold: number;
|
||||||
|
currentEssence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps): React.JSX.Element => {
|
||||||
|
const { buyUpgrade } = useGame();
|
||||||
|
const canAfford =
|
||||||
|
currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence;
|
||||||
|
|
||||||
|
if (upgrade.purchased) {
|
||||||
|
return (
|
||||||
|
<div className="upgrade-card purchased">
|
||||||
|
<span className="upgrade-name">✅ {upgrade.name}</span>
|
||||||
|
<span className="upgrade-desc">{upgrade.description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upgrade-card">
|
||||||
|
<div className="upgrade-info">
|
||||||
|
<h3>{upgrade.name}</h3>
|
||||||
|
<p>{upgrade.description}</p>
|
||||||
|
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
|
||||||
|
</div>
|
||||||
|
<div className="upgrade-cost">
|
||||||
|
{upgrade.costGold > 0 && <span>🪙 {upgrade.costGold.toLocaleString()}</span>}
|
||||||
|
{upgrade.costEssence > 0 && <span>✨ {upgrade.costEssence.toLocaleString()}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="buy-button"
|
||||||
|
disabled={!canAfford}
|
||||||
|
onClick={() => { buyUpgrade(upgrade.id); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpgradePanel = (): React.JSX.Element => {
|
||||||
|
const { state } = useGame();
|
||||||
|
|
||||||
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||||
|
|
||||||
|
const availableUpgrades = state.upgrades.filter((u) => u.unlocked);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel upgrade-panel">
|
||||||
|
<h2>Upgrades</h2>
|
||||||
|
{availableUpgrades.length === 0 ? (
|
||||||
|
<p className="empty-state">No upgrades available yet — keep adventuring!</p>
|
||||||
|
) : (
|
||||||
|
<div className="upgrade-list">
|
||||||
|
{availableUpgrades.map((upgrade) => (
|
||||||
|
<UpgradeCard
|
||||||
|
key={upgrade.id}
|
||||||
|
upgrade={upgrade}
|
||||||
|
currentGold={state.resources.gold}
|
||||||
|
currentEssence={state.resources.essence}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Resource } from "@elysium/types";
|
||||||
|
|
||||||
|
interface ResourceBarProps {
|
||||||
|
resources: Resource;
|
||||||
|
prestigeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value: number): string => {
|
||||||
|
if (value >= 1_000_000_000) {
|
||||||
|
return `${(value / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${(value / 1_000_000).toFixed(2)}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${(value / 1_000).toFixed(2)}K`;
|
||||||
|
}
|
||||||
|
return value.toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): React.JSX.Element => (
|
||||||
|
<header className="resource-bar">
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">🪙</span>
|
||||||
|
<span className="resource-value">{formatNumber(resources.gold)}</span>
|
||||||
|
<span className="resource-label">Gold</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">✨</span>
|
||||||
|
<span className="resource-value">{formatNumber(resources.essence)}</span>
|
||||||
|
<span className="resource-label">Essence</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">💎</span>
|
||||||
|
<span className="resource-value">{formatNumber(resources.crystals)}</span>
|
||||||
|
<span className="resource-label">Crystals</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource">
|
||||||
|
<span className="resource-icon">🔮</span>
|
||||||
|
<span className="resource-value">{formatNumber(resources.runestones)}</span>
|
||||||
|
<span className="resource-label">Runestones</span>
|
||||||
|
</div>
|
||||||
|
{prestigeCount > 0 && (
|
||||||
|
<div className="prestige-badge">
|
||||||
|
⭐ Prestige {prestigeCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
|
||||||
|
import { applyTick, calculateClickPower } from "../engine/tick.js";
|
||||||
|
|
||||||
|
interface GameContextValue {
|
||||||
|
state: GameState | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Click the crystal to earn gold */
|
||||||
|
handleClick: () => void;
|
||||||
|
/** Buy an adventurer */
|
||||||
|
buyAdventurer: (adventurerId: string) => void;
|
||||||
|
/** Buy an upgrade */
|
||||||
|
buyUpgrade: (upgradeId: string) => void;
|
||||||
|
/** Start a quest */
|
||||||
|
startQuest: (questId: string) => void;
|
||||||
|
/** Attack the active boss */
|
||||||
|
attackBoss: (bossId: string) => void;
|
||||||
|
/** Reload state from the server */
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
/** Offline gold earned on login */
|
||||||
|
offlineGold: number;
|
||||||
|
/** Dismiss the offline gold notification */
|
||||||
|
dismissOfflineGold: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameContext = createContext<GameContextValue | null>(null);
|
||||||
|
|
||||||
|
const AUTO_SAVE_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
export const GameProvider = ({ children }: { children: React.ReactNode }): React.JSX.Element => {
|
||||||
|
const [state, setState] = useState<GameState | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [offlineGold, setOfflineGold] = useState(0);
|
||||||
|
const stateRef = useRef<GameState | null>(null);
|
||||||
|
const lastSaveRef = useRef<number>(Date.now());
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await loadGame();
|
||||||
|
setState(data.state);
|
||||||
|
if (data.offlineGold > 0) {
|
||||||
|
setOfflineGold(data.offlineGold);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load game");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
// Game loop via requestAnimationFrame
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
const tick = (now: number): void => {
|
||||||
|
const deltaSeconds = (now - lastTime) / 1000;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return applyTick(prev, deltaSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save every 30 seconds
|
||||||
|
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||||
|
lastSaveRef.current = Date.now();
|
||||||
|
if (stateRef.current) {
|
||||||
|
void saveGame({ state: stateRef.current });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when state becomes available
|
||||||
|
}, [state !== null]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const clickPower = calculateClickPower(prev);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
resources: { ...prev.resources, gold: prev.resources.gold + clickPower },
|
||||||
|
player: {
|
||||||
|
...prev.player,
|
||||||
|
totalGoldEarned: prev.player.totalGoldEarned + clickPower,
|
||||||
|
totalClicks: prev.player.totalClicks + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buyAdventurer = useCallback((adventurerId: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
|
||||||
|
if (!adventurer || !adventurer.unlocked) return prev;
|
||||||
|
|
||||||
|
const cost = 10 * Math.pow(1.15, adventurer.count);
|
||||||
|
if (prev.resources.gold < cost) return prev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
resources: { ...prev.resources, gold: prev.resources.gold - cost },
|
||||||
|
adventurers: prev.adventurers.map((a) =>
|
||||||
|
a.id === adventurerId ? { ...a, count: a.count + 1 } : a,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buyUpgrade = useCallback((upgradeId: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const upgrade = prev.upgrades.find((u) => u.id === upgradeId);
|
||||||
|
if (!upgrade || !upgrade.unlocked || upgrade.purchased) return prev;
|
||||||
|
if (prev.resources.gold < upgrade.costGold) return prev;
|
||||||
|
if (prev.resources.essence < upgrade.costEssence) return prev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
resources: {
|
||||||
|
...prev.resources,
|
||||||
|
gold: prev.resources.gold - upgrade.costGold,
|
||||||
|
essence: prev.resources.essence - upgrade.costEssence,
|
||||||
|
},
|
||||||
|
upgrades: prev.upgrades.map((u) =>
|
||||||
|
u.id === upgradeId ? { ...u, purchased: true } : u,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startQuest = useCallback((questId: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const quest = prev.quests.find((q) => q.id === questId);
|
||||||
|
if (!quest || quest.status !== "available") return prev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
quests: prev.quests.map((q) =>
|
||||||
|
q.id === questId
|
||||||
|
? { ...q, status: "active" as const, startedAt: Date.now() }
|
||||||
|
: q,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const attackBoss = useCallback(async (bossId: string) => {
|
||||||
|
if (!stateRef.current) return;
|
||||||
|
const clickPower = calculateClickPower(stateRef.current);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dealBossDamage({ bossId, damage: clickPower });
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
bosses: prev.bosses.map((b) =>
|
||||||
|
b.id === bossId
|
||||||
|
? {
|
||||||
|
...b,
|
||||||
|
status: result.defeated ? ("defeated" as const) : ("in_progress" as const),
|
||||||
|
currentHp: result.currentHp,
|
||||||
|
}
|
||||||
|
: b,
|
||||||
|
),
|
||||||
|
...(result.defeated && result.rewards
|
||||||
|
? {
|
||||||
|
resources: {
|
||||||
|
...prev.resources,
|
||||||
|
gold: prev.resources.gold + result.rewards.gold,
|
||||||
|
essence: prev.resources.essence + result.rewards.essence,
|
||||||
|
crystals: prev.resources.crystals + result.rewards.crystals,
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
...prev.player,
|
||||||
|
totalGoldEarned:
|
||||||
|
prev.player.totalGoldEarned + result.rewards.gold,
|
||||||
|
},
|
||||||
|
upgrades: prev.upgrades.map((u) =>
|
||||||
|
result.rewards!.upgradeIds.includes(u.id)
|
||||||
|
? { ...u, unlocked: true }
|
||||||
|
: u,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Rate limited or other error — silently ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissOfflineGold = useCallback(() => {
|
||||||
|
setOfflineGold(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameContext.Provider
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
handleClick,
|
||||||
|
buyAdventurer,
|
||||||
|
buyUpgrade,
|
||||||
|
startQuest,
|
||||||
|
attackBoss,
|
||||||
|
reload,
|
||||||
|
offlineGold,
|
||||||
|
dismissOfflineGold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GameContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGame = (): GameContextValue => {
|
||||||
|
const context = useContext(GameContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useGame must be used within a GameProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — applies one game tick to the state.
|
||||||
|
* deltaSeconds: time elapsed since last tick.
|
||||||
|
* Returns a new GameState (does not mutate the original).
|
||||||
|
*/
|
||||||
|
export const applyTick = (state: GameState, deltaSeconds: number): GameState => {
|
||||||
|
let goldGained = 0;
|
||||||
|
let essenceGained = 0;
|
||||||
|
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradeMultiplier = state.upgrades
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
u.purchased &&
|
||||||
|
(u.target === "global" ||
|
||||||
|
(u.target === "adventurer" && u.adventurerId === adventurer.id)),
|
||||||
|
)
|
||||||
|
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
|
||||||
|
|
||||||
|
const prestige = state.prestige.productionMultiplier;
|
||||||
|
|
||||||
|
goldGained +=
|
||||||
|
adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * deltaSeconds;
|
||||||
|
|
||||||
|
essenceGained +=
|
||||||
|
adventurer.essencePerSecond *
|
||||||
|
adventurer.count *
|
||||||
|
upgradeMultiplier *
|
||||||
|
prestige *
|
||||||
|
deltaSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete active quests
|
||||||
|
const now = Date.now();
|
||||||
|
let questGold = 0;
|
||||||
|
let questEssence = 0;
|
||||||
|
let questCrystals = 0;
|
||||||
|
|
||||||
|
const updatedQuests = state.quests.map((quest) => {
|
||||||
|
if (
|
||||||
|
quest.status !== "active" ||
|
||||||
|
quest.startedAt == null ||
|
||||||
|
now < quest.startedAt + quest.durationSeconds * 1000
|
||||||
|
) {
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = { ...quest, status: "completed" as const };
|
||||||
|
for (const reward of quest.rewards) {
|
||||||
|
if (reward.type === "gold" && reward.amount != null) {
|
||||||
|
questGold += reward.amount;
|
||||||
|
} else if (reward.type === "essence" && reward.amount != null) {
|
||||||
|
questEssence += reward.amount;
|
||||||
|
} else if (reward.type === "crystals" && reward.amount != null) {
|
||||||
|
questCrystals += reward.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newGold = state.resources.gold + goldGained + questGold;
|
||||||
|
const newEssence = state.resources.essence + essenceGained + questEssence;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
resources: {
|
||||||
|
...state.resources,
|
||||||
|
gold: newGold,
|
||||||
|
essence: newEssence,
|
||||||
|
crystals: state.resources.crystals + questCrystals,
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
...state.player,
|
||||||
|
totalGoldEarned: state.player.totalGoldEarned + goldGained + questGold,
|
||||||
|
},
|
||||||
|
quests: updatedQuests,
|
||||||
|
lastTickAt: now,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the effective click power, including upgrades.
|
||||||
|
*/
|
||||||
|
export const calculateClickPower = (state: GameState): number => {
|
||||||
|
const clickMultiplier = state.upgrades
|
||||||
|
.filter((u) => u.purchased && u.target === "click")
|
||||||
|
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
|
||||||
|
|
||||||
|
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App.js";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
/* ===================== RESET & BASE ===================== */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--colour-bg: #0d0d1a;
|
||||||
|
--colour-surface: #1a1a2e;
|
||||||
|
--colour-surface-2: #16213e;
|
||||||
|
--colour-border: #2a2a4a;
|
||||||
|
--colour-accent: #7c3aed;
|
||||||
|
--colour-accent-light: #a855f7;
|
||||||
|
--colour-gold: #f59e0b;
|
||||||
|
--colour-essence: #8b5cf6;
|
||||||
|
--colour-crystal: #06b6d4;
|
||||||
|
--colour-rune: #ec4899;
|
||||||
|
--colour-text: #e2e8f0;
|
||||||
|
--colour-text-muted: #94a3b8;
|
||||||
|
--colour-success: #10b981;
|
||||||
|
--colour-error: #ef4444;
|
||||||
|
--colour-warning: #f59e0b;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--font: "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--colour-bg);
|
||||||
|
color: var(--colour-text);
|
||||||
|
font-family: var(--font);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== RESOURCE BAR ===================== */
|
||||||
|
.resource-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border-bottom: 1px solid var(--colour-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-label {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light));
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== GAME LAYOUT ===================== */
|
||||||
|
.game-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
border-right: 1px solid var(--colour-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== TABS ===================== */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border-bottom: 1px solid var(--colour-border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
color: var(--colour-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background: var(--colour-accent);
|
||||||
|
border-color: var(--colour-accent-light);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== CLICK AREA ===================== */
|
||||||
|
.click-area {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-area h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-button {
|
||||||
|
background: linear-gradient(135deg, var(--colour-accent), var(--colour-accent-light));
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 3rem;
|
||||||
|
height: 120px;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
width: 120px;
|
||||||
|
box-shadow: 0 0 20px rgba(124, 58, 237, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-button:active {
|
||||||
|
transform: scale(0.93);
|
||||||
|
box-shadow: 0 0 10px rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-power {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== PANEL ===================== */
|
||||||
|
.panel {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== ADVENTURERS ===================== */
|
||||||
|
.adventurer-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-card {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-card.locked {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-info p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventurer-count {
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== BUTTONS ===================== */
|
||||||
|
.buy-button,
|
||||||
|
.start-quest-button,
|
||||||
|
.attack-button,
|
||||||
|
.prestige-button {
|
||||||
|
background: var(--colour-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button:hover:not(:disabled),
|
||||||
|
.start-quest-button:hover,
|
||||||
|
.attack-button:hover,
|
||||||
|
.prestige-button:hover:not(:disabled) {
|
||||||
|
background: var(--colour-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button:disabled,
|
||||||
|
.prestige-button:disabled {
|
||||||
|
background: var(--colour-border);
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== UPGRADES ===================== */
|
||||||
|
.upgrade-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-card {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-card.purchased {
|
||||||
|
border-color: var(--colour-success);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-multiplier {
|
||||||
|
color: var(--colour-gold) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-cost {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== QUESTS ===================== */
|
||||||
|
.quest-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card.quest-completed {
|
||||||
|
border-color: var(--colour-success);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card.quest-active {
|
||||||
|
border-color: var(--colour-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-info p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-rewards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-tag {
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-badge.locked { color: var(--colour-text-muted); }
|
||||||
|
.quest-badge.active { color: var(--colour-warning); }
|
||||||
|
.quest-badge.completed { color: var(--colour-success); }
|
||||||
|
|
||||||
|
/* ===================== BOSSES ===================== */
|
||||||
|
.boss-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-card {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-card.boss-defeated {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-color: var(--colour-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-card.boss-in_progress {
|
||||||
|
border-color: var(--colour-error);
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-info h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-info p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-lock {
|
||||||
|
color: var(--colour-warning) !important;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-bar {
|
||||||
|
background: var(--colour-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-fill {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f97316);
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-text {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-rewards {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boss-badge.defeated {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attack-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attack-button:hover {
|
||||||
|
background: linear-gradient(135deg, #f87171, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== PRESTIGE ===================== */
|
||||||
|
.prestige-panel p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-status {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-form input {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--colour-text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-button {
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-locked {
|
||||||
|
color: var(--colour-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== LOGIN PAGE ===================== */
|
||||||
|
.login-page {
|
||||||
|
align-items: center;
|
||||||
|
background: radial-gradient(ellipse at center, #1a1a3e 0%, #0d0d1a 100%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-login-button {
|
||||||
|
background: #5865f2;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-login-button:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-note {
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== MODAL ===================== */
|
||||||
|
.modal-overlay {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
background: var(--colour-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.6rem 2rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button:hover {
|
||||||
|
background: var(--colour-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== UTILITY ===================== */
|
||||||
|
.error {
|
||||||
|
color: var(--colour-error);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--colour-success);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen,
|
||||||
|
.error-screen {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./prod",
|
||||||
|
"rootDir": ".",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"target": "ES2022"
|
||||||
|
},
|
||||||
|
"exclude": ["test/**/*.ts", "test/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
exclude: ["src/types/**/*.ts", "src/main.tsx"],
|
||||||
|
thresholds: {
|
||||||
|
statements: 100,
|
||||||
|
branches: 100,
|
||||||
|
functions: 100,
|
||||||
|
lines: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: ["test/**/*.spec.ts", "test/**/*.spec.tsx"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "elysium",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"dev": "pnpm -r --parallel dev"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"typescript": "5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { nhcarrigan } from "@nhcarrigan/eslint-config";
|
||||||
|
|
||||||
|
export default [...(await nhcarrigan())];
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@elysium/types",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./prod/src/index.js",
|
||||||
|
"types": "./prod/src/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"lint": "eslint --max-warnings 0 src",
|
||||||
|
"test": "echo \"No tests for types package\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"eslint": "9.22.0",
|
||||||
|
"typescript": "5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type { Adventurer, AdventurerClass } from "./interfaces/Adventurer.js";
|
||||||
|
export type {
|
||||||
|
ApiError,
|
||||||
|
AuthResponse,
|
||||||
|
BossDamageRequest,
|
||||||
|
BossDamageResponse,
|
||||||
|
LoadResponse,
|
||||||
|
PrestigeRequest,
|
||||||
|
PrestigeResponse,
|
||||||
|
PublicProfileResponse,
|
||||||
|
SaveRequest,
|
||||||
|
SaveResponse,
|
||||||
|
} from "./interfaces/Api.js";
|
||||||
|
export type { Boss, BossStatus } from "./interfaces/Boss.js";
|
||||||
|
export type { GameState } from "./interfaces/GameState.js";
|
||||||
|
export type { Player } from "./interfaces/Player.js";
|
||||||
|
export type { PrestigeData } from "./interfaces/Prestige.js";
|
||||||
|
export type {
|
||||||
|
Quest,
|
||||||
|
QuestReward,
|
||||||
|
QuestRewardType,
|
||||||
|
QuestStatus,
|
||||||
|
} from "./interfaces/Quest.js";
|
||||||
|
export type { Resource } from "./interfaces/Resource.js";
|
||||||
|
export type {
|
||||||
|
Upgrade,
|
||||||
|
UpgradeTarget,
|
||||||
|
} from "./interfaces/Upgrade.js";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type AdventurerClass =
|
||||||
|
| "warrior"
|
||||||
|
| "mage"
|
||||||
|
| "rogue"
|
||||||
|
| "cleric"
|
||||||
|
| "ranger"
|
||||||
|
| "paladin";
|
||||||
|
|
||||||
|
export interface Adventurer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
class: AdventurerClass;
|
||||||
|
level: number;
|
||||||
|
/** Base gold generated per second */
|
||||||
|
goldPerSecond: number;
|
||||||
|
/** Base essence generated per second */
|
||||||
|
essencePerSecond: number;
|
||||||
|
count: number;
|
||||||
|
unlocked: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { GameState } from "./GameState.js";
|
||||||
|
import type { Player } from "./Player.js";
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
player: Player;
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRequest {
|
||||||
|
state: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveResponse {
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadResponse {
|
||||||
|
state: GameState;
|
||||||
|
/** Offline gold earned since last save (server-calculated) */
|
||||||
|
offlineGold: number;
|
||||||
|
/** Seconds the player was offline (capped at 8 hours) */
|
||||||
|
offlineSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossDamageRequest {
|
||||||
|
bossId: string;
|
||||||
|
damage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossDamageResponse {
|
||||||
|
currentHp: number;
|
||||||
|
defeated: boolean;
|
||||||
|
rewards?: {
|
||||||
|
gold: number;
|
||||||
|
essence: number;
|
||||||
|
crystals: number;
|
||||||
|
upgradeIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrestigeRequest {
|
||||||
|
characterName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrestigeResponse {
|
||||||
|
runestones: number;
|
||||||
|
newPrestigeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProfileResponse {
|
||||||
|
characterName: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
prestigeCount: number;
|
||||||
|
totalGoldEarned: number;
|
||||||
|
totalClicks: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type BossStatus = "locked" | "available" | "in_progress" | "defeated";
|
||||||
|
|
||||||
|
export interface Boss {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: BossStatus;
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
/** Damage dealt to adventurers per second whilst the fight is active */
|
||||||
|
damagePerSecond: number;
|
||||||
|
/** Gold reward on defeat */
|
||||||
|
goldReward: number;
|
||||||
|
/** Essence reward on defeat */
|
||||||
|
essenceReward: number;
|
||||||
|
/** Crystal reward on defeat */
|
||||||
|
crystalReward: number;
|
||||||
|
/** IDs of upgrades unlocked on defeat */
|
||||||
|
upgradeRewards: string[];
|
||||||
|
/** Minimum prestige level required to access this boss */
|
||||||
|
prestigeRequirement: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Adventurer } from "./Adventurer.js";
|
||||||
|
import type { Boss } from "./Boss.js";
|
||||||
|
import type { Player } from "./Player.js";
|
||||||
|
import type { PrestigeData } from "./Prestige.js";
|
||||||
|
import type { Quest } from "./Quest.js";
|
||||||
|
import type { Resource } from "./Resource.js";
|
||||||
|
import type { Upgrade } from "./Upgrade.js";
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
player: Player;
|
||||||
|
resources: Resource;
|
||||||
|
adventurers: Adventurer[];
|
||||||
|
upgrades: Upgrade[];
|
||||||
|
quests: Quest[];
|
||||||
|
bosses: Boss[];
|
||||||
|
prestige: PrestigeData;
|
||||||
|
/** Click power (gold per click, before upgrades) */
|
||||||
|
baseClickPower: number;
|
||||||
|
/** Unix timestamp of the last client-side tick */
|
||||||
|
lastTickAt: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface Player {
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
discriminator: string;
|
||||||
|
avatar: string | null;
|
||||||
|
/** Player's chosen in-game character name */
|
||||||
|
characterName: string;
|
||||||
|
/** Unix timestamp when the account was created */
|
||||||
|
createdAt: number;
|
||||||
|
/** Unix timestamp of the last server-side save */
|
||||||
|
lastSavedAt: number;
|
||||||
|
/** Total lifetime gold ever collected (for prestige eligibility) */
|
||||||
|
totalGoldEarned: number;
|
||||||
|
/** Total lifetime clicks */
|
||||||
|
totalClicks: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface PrestigeData {
|
||||||
|
/** Number of times the player has prestiged */
|
||||||
|
count: number;
|
||||||
|
/** Runestones carried over between prestiges */
|
||||||
|
runestones: number;
|
||||||
|
/** Multiplier applied to all production (based on prestige count) */
|
||||||
|
productionMultiplier: number;
|
||||||
|
/** IDs of prestige upgrades purchased with runestones */
|
||||||
|
purchasedUpgradeIds: string[];
|
||||||
|
/** Unix timestamp of last prestige */
|
||||||
|
lastPrestigedAt?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type QuestStatus = "locked" | "available" | "active" | "completed";
|
||||||
|
|
||||||
|
export type QuestRewardType = "gold" | "essence" | "crystals" | "upgrade" | "adventurer";
|
||||||
|
|
||||||
|
export interface QuestReward {
|
||||||
|
type: QuestRewardType;
|
||||||
|
amount?: number;
|
||||||
|
/** ID of the upgrade or adventurer to unlock (if applicable) */
|
||||||
|
targetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: QuestStatus;
|
||||||
|
/** Unix timestamp when quest was started (if active) */
|
||||||
|
startedAt?: number;
|
||||||
|
/** Duration in seconds */
|
||||||
|
durationSeconds: number;
|
||||||
|
rewards: QuestReward[];
|
||||||
|
/** IDs of quests that must be completed before this one unlocks */
|
||||||
|
prerequisiteIds: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Resource {
|
||||||
|
gold: number;
|
||||||
|
essence: number;
|
||||||
|
crystals: number;
|
||||||
|
runestones: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type UpgradeTarget =
|
||||||
|
| "click"
|
||||||
|
| "adventurer"
|
||||||
|
| "global"
|
||||||
|
| "prestige"
|
||||||
|
| "boss";
|
||||||
|
|
||||||
|
export interface Upgrade {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
target: UpgradeTarget;
|
||||||
|
/** ID of the adventurer this applies to (if target is "adventurer") */
|
||||||
|
adventurerId?: string;
|
||||||
|
/** Multiplier applied to the target's output */
|
||||||
|
multiplier: number;
|
||||||
|
costGold: number;
|
||||||
|
costEssence: number;
|
||||||
|
purchased: boolean;
|
||||||
|
unlocked: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./prod",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"exclude": ["test/**/*.ts"]
|
||||||
|
}
|
||||||
Generated
+5665
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./prod",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"exclude": ["test/**/*.ts", "apps/**", "packages/**"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user