generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -27,12 +27,3 @@ model GameState {
|
||||
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,139 @@
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "first_click",
|
||||
name: "First Strike",
|
||||
description: "Click the Guild Hall for the first time.",
|
||||
icon: "👆",
|
||||
condition: { type: "totalClicks", amount: 1 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_enthusiast",
|
||||
name: "Click Enthusiast",
|
||||
description: "Click the Guild Hall 100 times.",
|
||||
icon: "🖱️",
|
||||
condition: { type: "totalClicks", amount: 100 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "click_master",
|
||||
name: "Click Master",
|
||||
description: "Click the Guild Hall 1,000 times.",
|
||||
icon: "⚡",
|
||||
condition: { type: "totalClicks", amount: 1_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "first_gold",
|
||||
name: "First Gold",
|
||||
description: "Earn your first 100 gold.",
|
||||
icon: "🪙",
|
||||
condition: { type: "totalGoldEarned", amount: 100 },
|
||||
reward: { crystals: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "wealthy",
|
||||
name: "Wealthy",
|
||||
description: "Earn 10,000 gold in total.",
|
||||
icon: "💰",
|
||||
condition: { type: "totalGoldEarned", amount: 10_000 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
name: "Rich",
|
||||
description: "Earn 1,000,000 gold in total.",
|
||||
icon: "👑",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "billionaire",
|
||||
name: "Billionaire",
|
||||
description: "Earn 1,000,000,000 gold in total.",
|
||||
icon: "🏦",
|
||||
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
|
||||
reward: { crystals: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "first_quest",
|
||||
name: "Adventurous Spirit",
|
||||
description: "Complete your first quest.",
|
||||
icon: "📜",
|
||||
condition: { type: "questsCompleted", amount: 1 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "quest_veteran",
|
||||
name: "Quest Veteran",
|
||||
description: "Complete 5 quests.",
|
||||
icon: "📚",
|
||||
condition: { type: "questsCompleted", amount: 5 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "boss_slayer",
|
||||
name: "Boss Slayer",
|
||||
description: "Defeat your first boss.",
|
||||
icon: "⚔️",
|
||||
condition: { type: "bossesDefeated", amount: 1 },
|
||||
reward: { crystals: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "legendary_hunter",
|
||||
name: "Legendary Hunter",
|
||||
description: "Defeat all four bosses.",
|
||||
icon: "🏆",
|
||||
condition: { type: "bossesDefeated", amount: 4 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "guild_master",
|
||||
name: "Guild Master",
|
||||
description: "Recruit a total of 50 adventurers.",
|
||||
icon: "🏰",
|
||||
condition: { type: "adventurerTotal", amount: 50 },
|
||||
reward: { crystals: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "army_commander",
|
||||
name: "Army Commander",
|
||||
description: "Recruit a total of 500 adventurers.",
|
||||
icon: "🛡️",
|
||||
condition: { type: "adventurerTotal", amount: 500 },
|
||||
reward: { crystals: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "first_prestige",
|
||||
name: "Born Again",
|
||||
description: "Prestige for the first time.",
|
||||
icon: "⭐",
|
||||
condition: { type: "prestigeCount", amount: 1 },
|
||||
reward: { crystals: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
id: "collector",
|
||||
name: "Collector",
|
||||
description: "Acquire your first piece of boss-dropped equipment.",
|
||||
icon: "🎒",
|
||||
condition: { type: "equipmentOwned", amount: 4 },
|
||||
reward: { crystals: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
@@ -8,6 +8,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 1,
|
||||
goldPerSecond: 0.1,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
unlocked: true,
|
||||
},
|
||||
@@ -18,6 +19,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 2,
|
||||
goldPerSecond: 0.5,
|
||||
essencePerSecond: 0,
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -28,6 +30,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 3,
|
||||
goldPerSecond: 1.5,
|
||||
essencePerSecond: 0.01,
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -38,6 +41,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 4,
|
||||
goldPerSecond: 4,
|
||||
essencePerSecond: 0.02,
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -48,6 +52,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 5,
|
||||
goldPerSecond: 10,
|
||||
essencePerSecond: 0.05,
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -58,6 +63,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 6,
|
||||
goldPerSecond: 25,
|
||||
essencePerSecond: 0.1,
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -68,6 +74,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 7,
|
||||
goldPerSecond: 75,
|
||||
essencePerSecond: 0.2,
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -78,6 +85,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 8,
|
||||
goldPerSecond: 200,
|
||||
essencePerSecond: 0.5,
|
||||
combatPower: 800,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -88,6 +96,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 9,
|
||||
goldPerSecond: 600,
|
||||
essencePerSecond: 1,
|
||||
combatPower: 2000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
@@ -98,6 +107,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
|
||||
level: 10,
|
||||
goldPerSecond: 2000,
|
||||
essencePerSecond: 3,
|
||||
combatPower: 6000,
|
||||
count: 0,
|
||||
unlocked: false,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_BOSSES: Boss[] = [
|
||||
essenceReward: 25,
|
||||
crystalReward: 0,
|
||||
upgradeRewards: ["click_2"],
|
||||
equipmentRewards: ["iron_sword", "chainmail", "mages_focus"],
|
||||
prestigeRequirement: 0,
|
||||
},
|
||||
{
|
||||
@@ -29,6 +30,7 @@ export const DEFAULT_BOSSES: Boss[] = [
|
||||
essenceReward: 200,
|
||||
crystalReward: 10,
|
||||
upgradeRewards: ["global_2"],
|
||||
equipmentRewards: ["enchanted_blade", "plate_armour", "arcane_orb"],
|
||||
prestigeRequirement: 0,
|
||||
},
|
||||
{
|
||||
@@ -44,6 +46,7 @@ export const DEFAULT_BOSSES: Boss[] = [
|
||||
essenceReward: 1_000,
|
||||
crystalReward: 50,
|
||||
upgradeRewards: ["click_3"],
|
||||
equipmentRewards: ["vorpal_sword", "dragon_scale"],
|
||||
prestigeRequirement: 1,
|
||||
},
|
||||
{
|
||||
@@ -59,6 +62,7 @@ export const DEFAULT_BOSSES: Boss[] = [
|
||||
essenceReward: 5_000,
|
||||
crystalReward: 200,
|
||||
upgradeRewards: [],
|
||||
equipmentRewards: ["philosophers_stone"],
|
||||
prestigeRequirement: 3,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Equipment } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_EQUIPMENT: Equipment[] = [
|
||||
// Weapons — drop from bosses; common starts owned
|
||||
{
|
||||
id: "rusty_sword",
|
||||
name: "Rusty Sword",
|
||||
description: "A battered blade, but still sharp enough to draw blood.",
|
||||
type: "weapon",
|
||||
rarity: "common",
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "iron_sword",
|
||||
name: "Iron Sword",
|
||||
description: "A sturdy weapon issued to veterans of the guild.",
|
||||
type: "weapon",
|
||||
rarity: "rare",
|
||||
bonus: { combatMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "enchanted_blade",
|
||||
name: "Enchanted Blade",
|
||||
description: "A sword imbued with ancient magic that makes every strike count.",
|
||||
type: "weapon",
|
||||
rarity: "epic",
|
||||
bonus: { combatMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "vorpal_sword",
|
||||
name: "Vorpal Sword",
|
||||
description: "A legendary blade that severs even the strongest bonds.",
|
||||
type: "weapon",
|
||||
rarity: "legendary",
|
||||
bonus: { combatMultiplier: 2.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// Armour — drop from bosses; common starts owned
|
||||
{
|
||||
id: "leather_armour",
|
||||
name: "Leather Armour",
|
||||
description: "Simple protection that keeps your adventurers moving efficiently.",
|
||||
type: "armour",
|
||||
rarity: "common",
|
||||
bonus: { goldMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "chainmail",
|
||||
name: "Chainmail",
|
||||
description: "Interlocked rings that guard against most mundane threats.",
|
||||
type: "armour",
|
||||
rarity: "rare",
|
||||
bonus: { goldMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "plate_armour",
|
||||
name: "Plate Armour",
|
||||
description: "Full plate protection that inspires confidence — and gold.",
|
||||
type: "armour",
|
||||
rarity: "epic",
|
||||
bonus: { goldMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "dragon_scale",
|
||||
name: "Dragon Scale Armour",
|
||||
description: "Armour forged from the scales of a defeated elder dragon.",
|
||||
type: "armour",
|
||||
rarity: "legendary",
|
||||
bonus: { goldMultiplier: 2.0 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
// Trinkets — drop from bosses; common starts owned
|
||||
{
|
||||
id: "lucky_coin",
|
||||
name: "Lucky Coin",
|
||||
description: "A coin that always lands on the side you need.",
|
||||
type: "trinket",
|
||||
rarity: "common",
|
||||
bonus: { clickMultiplier: 1.1 },
|
||||
owned: true,
|
||||
equipped: true,
|
||||
},
|
||||
{
|
||||
id: "mages_focus",
|
||||
name: "Mage's Focus",
|
||||
description: "A crystal lens that sharpens magical precision.",
|
||||
type: "trinket",
|
||||
rarity: "rare",
|
||||
bonus: { clickMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "arcane_orb",
|
||||
name: "Arcane Orb",
|
||||
description: "An orb humming with concentrated arcane energy.",
|
||||
type: "trinket",
|
||||
rarity: "epic",
|
||||
bonus: { clickMultiplier: 1.5 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
{
|
||||
id: "philosophers_stone",
|
||||
name: "Philosopher's Stone",
|
||||
description: "The legendary stone that grants mastery over gold and combat alike.",
|
||||
type: "trinket",
|
||||
rarity: "legendary",
|
||||
bonus: { clickMultiplier: 2.0, goldMultiplier: 1.25 },
|
||||
owned: false,
|
||||
equipped: false,
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { GameState, Player, PrestigeData } from "@elysium/types";
|
||||
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
|
||||
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
|
||||
import { DEFAULT_BOSSES } from "./bosses.js";
|
||||
import { DEFAULT_EQUIPMENT } from "./equipment.js";
|
||||
import { DEFAULT_QUESTS } from "./quests.js";
|
||||
import { DEFAULT_UPGRADES } from "./upgrades.js";
|
||||
|
||||
@@ -28,6 +30,8 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS
|
||||
upgrades: structuredClone(DEFAULT_UPGRADES),
|
||||
quests: structuredClone(DEFAULT_QUESTS),
|
||||
bosses: structuredClone(DEFAULT_BOSSES),
|
||||
equipment: structuredClone(DEFAULT_EQUIPMENT),
|
||||
achievements: structuredClone(DEFAULT_ACHIEVEMENTS),
|
||||
prestige: INITIAL_PRESTIGE,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: Date.now(),
|
||||
|
||||
@@ -34,6 +34,20 @@ export const DEFAULT_QUESTS: Quest[] = [
|
||||
],
|
||||
prerequisiteIds: ["goblin_camp"],
|
||||
},
|
||||
{
|
||||
id: "necromancer_tower",
|
||||
name: "Necromancer's Tower",
|
||||
description:
|
||||
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
|
||||
status: "locked",
|
||||
durationSeconds: 25 * 60,
|
||||
rewards: [
|
||||
{ type: "gold", amount: 15_000 },
|
||||
{ type: "essence", amount: 20 },
|
||||
{ type: "upgrade", targetId: "cleric_1" },
|
||||
],
|
||||
prerequisiteIds: ["haunted_mine"],
|
||||
},
|
||||
{
|
||||
id: "ancient_ruins",
|
||||
name: "Ancient Ruins",
|
||||
@@ -46,6 +60,19 @@ export const DEFAULT_QUESTS: Quest[] = [
|
||||
],
|
||||
prerequisiteIds: ["haunted_mine"],
|
||||
},
|
||||
{
|
||||
id: "shadow_mere",
|
||||
name: "The Shadow Mere",
|
||||
description:
|
||||
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
|
||||
status: "locked",
|
||||
durationSeconds: 45 * 60,
|
||||
rewards: [
|
||||
{ type: "essence", amount: 150 },
|
||||
{ type: "upgrade", targetId: "scout_1" },
|
||||
],
|
||||
prerequisiteIds: ["ancient_ruins"],
|
||||
},
|
||||
{
|
||||
id: "dragon_lair",
|
||||
name: "Dragon's Lair",
|
||||
@@ -60,4 +87,32 @@ export const DEFAULT_QUESTS: Quest[] = [
|
||||
],
|
||||
prerequisiteIds: ["ancient_ruins"],
|
||||
},
|
||||
{
|
||||
id: "frozen_wastes",
|
||||
name: "The Frozen Wastes",
|
||||
description:
|
||||
"A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.",
|
||||
status: "locked",
|
||||
durationSeconds: 2 * 60 * 60,
|
||||
rewards: [
|
||||
{ type: "gold", amount: 2_000_000 },
|
||||
{ type: "crystals", amount: 150 },
|
||||
{ type: "upgrade", targetId: "global_3" },
|
||||
],
|
||||
prerequisiteIds: ["dragon_lair"],
|
||||
},
|
||||
{
|
||||
id: "void_rift",
|
||||
name: "Void Rift",
|
||||
description:
|
||||
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
|
||||
status: "locked",
|
||||
durationSeconds: 4 * 60 * 60,
|
||||
rewards: [
|
||||
{ type: "crystals", amount: 500 },
|
||||
{ type: "essence", amount: 5_000 },
|
||||
{ type: "upgrade", targetId: "knight_1" },
|
||||
],
|
||||
prerequisiteIds: ["frozen_wastes"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,6 +58,17 @@ export const DEFAULT_UPGRADES: Upgrade[] = [
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "global_3",
|
||||
name: "Royal Patronage",
|
||||
description: "The king himself backs your guild. All income doubled.",
|
||||
target: "global",
|
||||
multiplier: 2,
|
||||
costGold: 1_000_000,
|
||||
costEssence: 100,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
// Adventurer-specific upgrades
|
||||
{
|
||||
id: "peasant_1",
|
||||
@@ -95,4 +106,40 @@ export const DEFAULT_UPGRADES: Upgrade[] = [
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "cleric_1",
|
||||
name: "Holy Rites",
|
||||
description: "Sacred ceremonies double the output of your clerics.",
|
||||
target: "adventurer",
|
||||
adventurerId: "acolyte",
|
||||
multiplier: 2,
|
||||
costGold: 8_000,
|
||||
costEssence: 3,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "scout_1",
|
||||
name: "Stealth Training",
|
||||
description: "Advanced scouting techniques double ranger effectiveness.",
|
||||
target: "adventurer",
|
||||
adventurerId: "ranger",
|
||||
multiplier: 2,
|
||||
costGold: 15_000,
|
||||
costEssence: 5,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
id: "knight_1",
|
||||
name: "Tempered Steel",
|
||||
description: "Superior forging techniques double the output of your knights.",
|
||||
target: "adventurer",
|
||||
adventurerId: "knight",
|
||||
multiplier: 2,
|
||||
costGold: 50_000,
|
||||
costEssence: 10,
|
||||
purchased: false,
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
+139
-29
@@ -1,34 +1,67 @@
|
||||
import type { BossDamageRequest, GameState } from "@elysium/types";
|
||||
import type { BossChallengeResponse, 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);
|
||||
const calculatePartyStats = (
|
||||
state: GameState,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
|
||||
const recentDamage = aggregate._sum.damage ?? 0;
|
||||
// Apply equipped weapon's combat bonus
|
||||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||||
|
||||
if (recentDamage + body.damage > MAX_DAMAGE_PER_SECOND) {
|
||||
return context.json({ error: "Rate limit exceeded" }, 429);
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) continue;
|
||||
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased &&
|
||||
upgrade.target === "adventurer" &&
|
||||
upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
partyDPS +=
|
||||
adventurer.combatPower *
|
||||
adventurer.count *
|
||||
adventurerMultiplier *
|
||||
globalMultiplier *
|
||||
prestigeMultiplier;
|
||||
|
||||
partyMaxHp += adventurer.level * 50 * adventurer.count;
|
||||
}
|
||||
|
||||
partyDPS *= equipmentCombatMultiplier;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
bossRouter.post("/challenge", async (context) => {
|
||||
const discordId = context.get("discordId") as string;
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
@@ -44,7 +77,7 @@ bossRouter.post("/damage", async (context) => {
|
||||
return context.json({ error: "Boss not found" }, 404);
|
||||
}
|
||||
|
||||
if (boss.status !== "in_progress" && boss.status !== "available") {
|
||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||
return context.json({ error: "Boss is not currently available" }, 400);
|
||||
}
|
||||
|
||||
@@ -52,18 +85,40 @@ bossRouter.post("/damage", async (context) => {
|
||||
return context.json({ error: "Prestige requirement not met" }, 403);
|
||||
}
|
||||
|
||||
await prisma.bossDamageLog.create({
|
||||
data: { discordId, bossId: body.bossId, damage: body.damage, dealtAt: Date.now() },
|
||||
});
|
||||
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
||||
|
||||
boss.status = "in_progress";
|
||||
boss.currentHp = Math.max(0, boss.currentHp - body.damage);
|
||||
const defeated = boss.currentHp <= 0;
|
||||
if (partyDPS === 0 || partyMaxHp === 0 || !isFinite(partyDPS) || !isFinite(partyMaxHp)) {
|
||||
return context.json(
|
||||
{ error: "Your party has no adventurers ready to fight" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let rewards: { gold: number; essence: number; crystals: number; upgradeIds: string[] } | undefined;
|
||||
const bossHpBefore = boss.currentHp;
|
||||
const bossDPS = boss.damagePerSecond;
|
||||
|
||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||
const timeToKillParty = partyMaxHp / bossDPS;
|
||||
|
||||
const won = timeToKillBoss <= timeToKillParty;
|
||||
|
||||
let partyHpRemaining: number;
|
||||
let bossHpAtBattleEnd: number;
|
||||
let bossNewHp: number;
|
||||
let rewards: BossChallengeResponse["rewards"];
|
||||
let casualties: BossChallengeResponse["casualties"];
|
||||
|
||||
if (won) {
|
||||
bossHpAtBattleEnd = 0;
|
||||
bossNewHp = 0;
|
||||
partyHpRemaining = Math.max(
|
||||
0,
|
||||
partyMaxHp - bossDPS * timeToKillBoss,
|
||||
);
|
||||
|
||||
if (defeated) {
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
state.resources.gold += boss.goldReward;
|
||||
state.resources.essence += boss.essenceReward;
|
||||
state.resources.crystals += boss.crystalReward;
|
||||
@@ -76,6 +131,21 @@ bossRouter.post("/damage", async (context) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||
const equipmentRewards = boss.equipmentRewards ?? [];
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
const equipment = (state.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
const slotAlreadyEquipped = (state.equipment ?? []).some(
|
||||
(e) => e.type === equipment.type && e.equipped,
|
||||
);
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bossIndex = state.bosses.findIndex((b) => b.id === body.bossId);
|
||||
const nextBoss = state.bosses[bossIndex + 1];
|
||||
if (nextBoss && nextBoss.prestigeRequirement <= state.prestige.count) {
|
||||
@@ -87,7 +157,33 @@ bossRouter.post("/damage", async (context) => {
|
||||
essence: boss.essenceReward,
|
||||
crystals: boss.crystalReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
equipmentIds: equipmentRewards,
|
||||
};
|
||||
} else {
|
||||
bossHpAtBattleEnd = Math.max(
|
||||
0,
|
||||
bossHpBefore - partyDPS * timeToKillParty,
|
||||
);
|
||||
bossNewHp = boss.maxHp;
|
||||
partyHpRemaining = 0;
|
||||
|
||||
boss.status = "available";
|
||||
boss.currentHp = boss.maxHp;
|
||||
|
||||
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||
|
||||
casualties = [];
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) continue;
|
||||
const killed = Math.floor(adventurer.count * casualtyFraction);
|
||||
if (killed > 0) {
|
||||
adventurer.count = Math.max(1, adventurer.count - killed);
|
||||
casualties.push({ adventurerId: adventurer.id, killed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
@@ -96,5 +192,19 @@ bossRouter.post("/damage", async (context) => {
|
||||
data: { state: state as object, updatedAt: now },
|
||||
});
|
||||
|
||||
return context.json({ currentHp: boss.currentHp, defeated, rewards });
|
||||
const response: BossChallengeResponse = {
|
||||
won,
|
||||
partyDPS,
|
||||
bossDPS,
|
||||
bossHpBefore,
|
||||
bossMaxHp: boss.maxHp,
|
||||
bossHpAtBattleEnd,
|
||||
bossNewHp,
|
||||
partyMaxHp,
|
||||
partyHpRemaining,
|
||||
};
|
||||
if (rewards !== undefined) response.rewards = rewards;
|
||||
if (casualties !== undefined) response.casualties = casualties;
|
||||
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { GameState, SaveRequest } from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js";
|
||||
import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
|
||||
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { calculateOfflineGold } from "../services/offlineProgress.js";
|
||||
|
||||
@@ -18,6 +21,72 @@ gameRouter.get("/load", async (context) => {
|
||||
}
|
||||
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
let needsBackfill = false;
|
||||
|
||||
// Backfill combatPower on saves that predate the field
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.combatPower == null) {
|
||||
const defaults = DEFAULT_ADVENTURERS.find((d) => d.id === adventurer.id);
|
||||
adventurer.combatPower = defaults?.combatPower ?? 1;
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill equipment on saves that predate the feature
|
||||
if (!Array.isArray(state.equipment) || state.equipment.length === 0) {
|
||||
state.equipment = structuredClone(DEFAULT_EQUIPMENT);
|
||||
needsBackfill = true;
|
||||
} else {
|
||||
// Merge in any equipment items missing from existing saves (new items added later)
|
||||
for (const defaultItem of DEFAULT_EQUIPMENT) {
|
||||
if (!state.equipment.some((e) => e.id === defaultItem.id)) {
|
||||
state.equipment.push(structuredClone(defaultItem));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill achievements on saves that predate the feature
|
||||
if (!Array.isArray(state.achievements) || state.achievements.length === 0) {
|
||||
state.achievements = structuredClone(DEFAULT_ACHIEVEMENTS);
|
||||
needsBackfill = true;
|
||||
} else {
|
||||
// Merge in any achievements missing from existing saves
|
||||
for (const defaultAchievement of DEFAULT_ACHIEVEMENTS) {
|
||||
if (!state.achievements.some((a) => a.id === defaultAchievement.id)) {
|
||||
state.achievements.push(structuredClone(defaultAchievement));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill equipmentRewards on bosses that predate the field
|
||||
for (const boss of state.bosses) {
|
||||
if (!Array.isArray(boss.equipmentRewards)) {
|
||||
boss.equipmentRewards = [];
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill new quests and upgrades from defaults (add missing ones)
|
||||
const { DEFAULT_QUESTS } = await import("../data/quests.js");
|
||||
const { DEFAULT_UPGRADES } = await import("../data/upgrades.js");
|
||||
|
||||
for (const defaultQuest of DEFAULT_QUESTS) {
|
||||
if (!state.quests.some((q) => q.id === defaultQuest.id)) {
|
||||
state.quests.push(structuredClone(defaultQuest));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const defaultUpgrade of DEFAULT_UPGRADES) {
|
||||
if (!state.upgrades.some((u) => u.id === defaultUpgrade.id)) {
|
||||
state.upgrades.push(structuredClone(defaultUpgrade));
|
||||
needsBackfill = true;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const { offlineGold, offlineSeconds } = calculateOfflineGold(state, now);
|
||||
@@ -29,6 +98,13 @@ gameRouter.get("/load", async (context) => {
|
||||
|
||||
state.lastTickAt = now;
|
||||
|
||||
if (needsBackfill || offlineGold > 0) {
|
||||
await prisma.gameState.update({
|
||||
where: { discordId },
|
||||
data: { state: state as object, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
return context.json({ state, offlineGold, offlineSeconds });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
BossDamageRequest,
|
||||
BossDamageResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
@@ -61,10 +61,10 @@ export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const dealBossDamage = async (
|
||||
body: BossDamageRequest,
|
||||
): Promise<BossDamageResponse> =>
|
||||
request<BossDamageResponse>("/boss/damage", {
|
||||
export const challengeBoss = async (
|
||||
body: BossChallengeRequest,
|
||||
): Promise<BossChallengeResponse> =>
|
||||
request<BossChallengeResponse>("/boss/challenge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
|
||||
const conditionDescription = (achievement: Achievement): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProps {
|
||||
achievement: Achievement;
|
||||
}
|
||||
|
||||
const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked ? "unlocked" : "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">{conditionDescription(achievement)}</p>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked ? (
|
||||
<span className="achievement-unlocked-badge">✓ Unlocked</span>
|
||||
) : (
|
||||
<span className="achievement-locked-badge">🔒</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const achievements = state.achievements ?? [];
|
||||
const unlocked = achievements.filter((a) => a.unlockedAt !== null).length;
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<h2>Achievements</h2>
|
||||
<p className="achievement-progress">
|
||||
{unlocked} / {achievements.length} unlocked
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{achievements.map((achievement) => (
|
||||
<AchievementCard key={achievement.id} achievement={achievement} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface ToastItemProps {
|
||||
achievement: Achievement;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastItem = ({ achievement, onDismiss }: ToastItemProps): React.JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [achievement.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={() => { onDismiss(achievement.id); }}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">Achievement Unlocked!</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{achievement.reward?.crystals != null && (
|
||||
<span className="toast-reward">💎 +{achievement.reward.crystals}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AchievementToast = (): React.JSX.Element | null => {
|
||||
const { newAchievements, dismissAchievement } = useGame();
|
||||
|
||||
if (newAchievements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{newAchievements.map((achievement) => (
|
||||
<ToastItem
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { BattleResult } from "../../context/GameContext.js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface BattleModalProps {
|
||||
battle: BattleResult;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const formatNumber = (n: number): string => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return Math.floor(n).toLocaleString();
|
||||
};
|
||||
|
||||
export const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProps): React.JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
|
||||
const [phase, setPhase] = useState<"animating" | "result">("animating");
|
||||
|
||||
// Starting HP percentages
|
||||
const bossStartPercent = (result.bossHpBefore / result.bossMaxHp) * 100;
|
||||
const partyStartPercent = 100;
|
||||
|
||||
// Target HP percentages (after battle)
|
||||
const bossEndPercent = (result.bossHpAtBattleEnd / result.bossMaxHp) * 100;
|
||||
const partyEndPercent = result.partyMaxHp > 0
|
||||
? (result.partyHpRemaining / result.partyMaxHp) * 100
|
||||
: 0;
|
||||
|
||||
const [bossHpPercent, setBossHpPercent] = useState(bossStartPercent);
|
||||
const [partyHpPercent, setPartyHpPercent] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
// Brief delay so CSS transition has a starting point to animate from
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
// Reveal result after animation completes
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5_200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [bossEndPercent, partyEndPercent]);
|
||||
|
||||
const bossHpBarColour = bossHpPercent > 50
|
||||
? "#e74c3c"
|
||||
: bossHpPercent > 25
|
||||
? "#e67e22"
|
||||
: "#c0392b";
|
||||
|
||||
const partyHpBarColour = partyHpPercent > 50
|
||||
? "#27ae60"
|
||||
: partyHpPercent > 25
|
||||
? "#f39c12"
|
||||
: "#e74c3c";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>⚔️ Battle: {bossName}</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Your Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">vs</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">Boss DPS</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">👹 {bossName}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">⚔️ VS ⚔️</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">🛡️ Your Party</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating" && (
|
||||
<p className="battle-in-progress">Battling…</p>
|
||||
)}
|
||||
|
||||
{phase === "result" && (
|
||||
<div className={`battle-outcome ${result.won ? "victory" : "defeat"}`}>
|
||||
{result.won ? (
|
||||
<>
|
||||
<h3>🏆 Victory!</h3>
|
||||
{result.rewards && (
|
||||
<div className="battle-rewards">
|
||||
<p>Rewards:</p>
|
||||
<span>🪙 {formatNumber(result.rewards.gold)} gold</span>
|
||||
{result.rewards.essence > 0 && (
|
||||
<span>✨ {formatNumber(result.rewards.essence)} essence</span>
|
||||
)}
|
||||
{result.rewards.crystals > 0 && (
|
||||
<span>💎 {formatNumber(result.rewards.crystals)} crystals</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>💀 Defeat</h3>
|
||||
<p>Your party was defeated. The boss has reset.</p>
|
||||
{result.casualties && result.casualties.length > 0 && (
|
||||
<div className="battle-casualties">
|
||||
<p>Casualties:</p>
|
||||
{result.casualties.map((c) => (
|
||||
<span key={c.adventurerId}>
|
||||
☠️ {c.killed} {c.adventurerId} lost
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,25 @@
|
||||
import type { Boss } from "@elysium/types";
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
|
||||
interface BossCardProps {
|
||||
boss: Boss;
|
||||
prestigeCount: number;
|
||||
onChallenge: (bossId: string) => void;
|
||||
isChallenging: boolean;
|
||||
}
|
||||
|
||||
const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element => {
|
||||
const { attackBoss } = useGame();
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
}: BossCardProps): React.JSX.Element => {
|
||||
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
|
||||
const isLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge =
|
||||
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
@@ -17,7 +27,9 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isLocked && boss.status === "locked" && (
|
||||
<p className="prestige-lock">🔒 Requires Prestige {boss.prestigeRequirement}</p>
|
||||
<p className="prestige-lock">
|
||||
🔒 Requires Prestige {boss.prestigeRequirement}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,26 +42,40 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{boss.currentHp.toLocaleString()} / {boss.maxHp.toLocaleString()} HP
|
||||
{formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} 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 className="boss-meta">
|
||||
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
|
||||
</div>
|
||||
|
||||
{boss.status === "available" || boss.status === "in_progress" ? (
|
||||
<div className="boss-rewards">
|
||||
<span>🪙 {formatNumber(boss.goldReward)}</span>
|
||||
{boss.essenceReward > 0 && (
|
||||
<span>✨ {formatNumber(boss.essenceReward)}</span>
|
||||
)}
|
||||
{boss.crystalReward > 0 && (
|
||||
<span>💎 {formatNumber(boss.crystalReward)}</span>
|
||||
)}
|
||||
{(boss.equipmentRewards ?? []).length > 0 && (
|
||||
<span>🗡️ {boss.equipmentRewards.length} Equipment</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress") && (
|
||||
<button
|
||||
className="attack-button"
|
||||
onClick={() => { void attackBoss(boss.id); }}
|
||||
disabled={!canChallenge}
|
||||
onClick={() => {
|
||||
onChallenge(boss.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
⚔️ Attack
|
||||
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
|
||||
</button>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{boss.status === "defeated" && (
|
||||
<span className="boss-badge defeated">☠️ Defeated</span>
|
||||
@@ -59,19 +85,81 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
|
||||
};
|
||||
|
||||
export const BossPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const { state, challengeBoss } = useGame();
|
||||
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
// Calculate party combat stats including equipment multiplier
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyHP = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) continue;
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased &&
|
||||
upgrade.target === "adventurer" &&
|
||||
upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier *= upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
partyDPS +=
|
||||
adventurer.combatPower *
|
||||
adventurer.count *
|
||||
adventurerMultiplier *
|
||||
globalMultiplier *
|
||||
prestigeMultiplier;
|
||||
partyHP += adventurer.level * 50 * adventurer.count;
|
||||
}
|
||||
partyDPS *= equipmentCombatMultiplier;
|
||||
|
||||
const handleChallenge = async (bossId: string): Promise<void> => {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<h2>Boss Encounters</h2>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">⚔️ Party DPS</span>
|
||||
<span className="stat-value">{formatNumber(partyDPS)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">❤️ Party HP</span>
|
||||
<span className="stat-value">{formatNumber(partyHP)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{state.bosses.map((boss) => (
|
||||
<BossCard
|
||||
key={boss.id}
|
||||
boss={boss}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
prestigeCount={state.prestige.count}
|
||||
onChallenge={(id) => {
|
||||
void handleChallenge(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ClickArea = (): React.JSX.Element => {
|
||||
const { state, handleClick } = useGame();
|
||||
const [floats, setFloats] = useState<FloatText[]>([]);
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!state) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = nextIdRef.current++;
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
setFloats((prev) => [...prev, { id, x, y, text: `+${formatNumber(clickPower)}` }]);
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
setFloats((prev) => prev.filter((f) => f.id !== id));
|
||||
}, 900);
|
||||
},
|
||||
[state, handleClick],
|
||||
);
|
||||
|
||||
if (!state) return <div className="click-area-placeholder" />;
|
||||
|
||||
@@ -11,15 +41,26 @@ export const ClickArea = (): React.JSX.Element => {
|
||||
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>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
>
|
||||
⚔️
|
||||
</button>
|
||||
{floats.map((float) => (
|
||||
<span
|
||||
key={float.id}
|
||||
className="click-float"
|
||||
style={{ left: float.x, top: float.y }}
|
||||
>
|
||||
{float.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="click-power">+{formatNumber(clickPower)} gold/click</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
common: "Common",
|
||||
rare: "Rare",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
const TYPE_ICON: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️",
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
};
|
||||
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: string[] = [];
|
||||
if (item.bonus.combatMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier != null) {
|
||||
parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProps {
|
||||
item: Equipment;
|
||||
}
|
||||
|
||||
const EquipmentCard = ({ item }: EquipmentCardProps): React.JSX.Element => {
|
||||
const { equipItem } = useGame();
|
||||
|
||||
return (
|
||||
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
|
||||
<div className="equipment-icon">{TYPE_ICON[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && <span className="equipment-locked">🔒 Not yet obtained</span>}
|
||||
{item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>}
|
||||
{item.owned && !item.equipped && (
|
||||
<button
|
||||
className="equip-button"
|
||||
onClick={() => { equipItem(item.id); }}
|
||||
type="button"
|
||||
>
|
||||
Equip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"];
|
||||
const SLOT_LABEL: Record<EquipmentType, string> = {
|
||||
weapon: "⚔️ Weapons",
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
};
|
||||
|
||||
export const EquipmentPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const equipment = state.equipment ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<h2>Equipment</h2>
|
||||
<p className="equipment-intro">
|
||||
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time.
|
||||
</p>
|
||||
|
||||
{SLOT_ORDER.map((slotType) => {
|
||||
const items = equipment.filter((e) => e.type === slotType);
|
||||
return (
|
||||
<div key={slotType} className="equipment-slot-section">
|
||||
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => (
|
||||
<EquipmentCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
import { ResourceBar } from "../ui/ResourceBar.js";
|
||||
import { AchievementPanel } from "./AchievementPanel.js";
|
||||
import { AchievementToast } from "./AchievementToast.js";
|
||||
import { AdventurerPanel } from "./AdventurerPanel.js";
|
||||
import { BattleModal } from "./BattleModal.js";
|
||||
import { BossPanel } from "./BossPanel.js";
|
||||
import { ClickArea } from "./ClickArea.js";
|
||||
import { EquipmentPanel } from "./EquipmentPanel.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";
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
];
|
||||
|
||||
export const GameLayout = (): React.JSX.Element => {
|
||||
const { state, isLoading, error } = useGame();
|
||||
const { state, isLoading, error, battleResult, dismissBattle } = useGame();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||
|
||||
if (isLoading) {
|
||||
@@ -48,6 +54,10 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
prestigeCount={state.prestige.count}
|
||||
/>
|
||||
<OfflineModal />
|
||||
<AchievementToast />
|
||||
{battleResult && (
|
||||
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
)}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
@@ -73,6 +83,8 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,6 +12,23 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps)
|
||||
const canAfford =
|
||||
currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence;
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<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>
|
||||
<span className="upgrade-locked-label">Locked</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
@@ -49,16 +66,35 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
|
||||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||||
|
||||
const availableUpgrades = state.upgrades.filter((u) => u.unlocked);
|
||||
const purchased = state.upgrades.filter((u) => u.purchased);
|
||||
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
|
||||
const locked = state.upgrades.filter((u) => !u.unlocked);
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<h2>Upgrades</h2>
|
||||
{availableUpgrades.length === 0 ? (
|
||||
<p className="upgrade-progress">{purchased.length} / {state.upgrades.length} purchased</p>
|
||||
{state.upgrades.length === 0 ? (
|
||||
<p className="empty-state">No upgrades available yet — keep adventuring!</p>
|
||||
) : (
|
||||
<div className="upgrade-list">
|
||||
{availableUpgrades.map((upgrade) => (
|
||||
{available.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
/>
|
||||
))}
|
||||
{purchased.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
/>
|
||||
))}
|
||||
{locked.map((upgrade) => (
|
||||
<UpgradeCard
|
||||
key={upgrade.id}
|
||||
upgrade={upgrade}
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import type { Resource } from "@elysium/types";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState } from "@elysium/types";
|
||||
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
|
||||
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
|
||||
import { applyTick, calculateClickPower } from "../engine/tick.js";
|
||||
|
||||
export interface BattleResult {
|
||||
bossName: string;
|
||||
result: BossChallengeResponse;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
state: GameState | null;
|
||||
isLoading: boolean;
|
||||
@@ -22,14 +27,24 @@ interface GameContextValue {
|
||||
buyUpgrade: (upgradeId: string) => void;
|
||||
/** Start a quest */
|
||||
startQuest: (questId: string) => void;
|
||||
/** Attack the active boss */
|
||||
attackBoss: (bossId: string) => void;
|
||||
/** Challenge a boss — runs full server-side simulation */
|
||||
challengeBoss: (bossId: string) => Promise<void>;
|
||||
/** Equip an owned equipment item (auto-unequips the same slot) */
|
||||
equipItem: (equipmentId: string) => void;
|
||||
/** Reload state from the server */
|
||||
reload: () => Promise<void>;
|
||||
/** Offline gold earned on login */
|
||||
offlineGold: number;
|
||||
/** Dismiss the offline gold notification */
|
||||
dismissOfflineGold: () => void;
|
||||
/** Battle result to display in the modal (null when no battle pending) */
|
||||
battleResult: BattleResult | null;
|
||||
/** Dismiss the battle result modal */
|
||||
dismissBattle: () => void;
|
||||
/** Queue of newly unlocked achievements (for toasts) */
|
||||
newAchievements: Achievement[];
|
||||
/** Remove an achievement from the toast queue */
|
||||
dismissAchievement: (id: string) => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -41,9 +56,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const stateRef = useRef<GameState | null>(null);
|
||||
const lastSaveRef = useRef<number>(Date.now());
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
@@ -79,9 +97,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return applyTick(prev, deltaSeconds);
|
||||
const next = applyTick(prev, deltaSeconds);
|
||||
|
||||
// Detect newly unlocked achievements
|
||||
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
|
||||
const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null;
|
||||
return wasLocked && a.unlockedAt !== null;
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (newlyUnlockedRef.current.length > 0) {
|
||||
setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]);
|
||||
newlyUnlockedRef.current = [];
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||
lastSaveRef.current = Date.now();
|
||||
@@ -176,50 +207,107 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attackBoss = useCallback(async (bossId: string) => {
|
||||
const equipItem = useCallback((equipmentId: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
|
||||
if (!item || !item.owned) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
equipment: (prev.equipment ?? []).map((e) => {
|
||||
if (e.id === equipmentId) return { ...e, equipped: true };
|
||||
// Unequip the previously-equipped item in the same slot
|
||||
if (e.type === item.type && e.equipped) return { ...e, equipped: false };
|
||||
return e;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const challengeBoss = useCallback(async (bossId: string) => {
|
||||
if (!stateRef.current) return;
|
||||
const clickPower = calculateClickPower(stateRef.current);
|
||||
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
|
||||
if (!boss) return;
|
||||
|
||||
try {
|
||||
const result = await dealBossDamage({ bossId, damage: clickPower });
|
||||
const result = await challengeBossApi({ bossId });
|
||||
|
||||
// Update local state to match server result
|
||||
setState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
|
||||
if (result.won) {
|
||||
const bossIndex = prev.bosses.findIndex((b) => b.id === bossId);
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b, idx) => {
|
||||
if (b.id === bossId) {
|
||||
return { ...b, status: "defeated" as const, currentHp: 0 };
|
||||
}
|
||||
if (
|
||||
idx === bossIndex + 1 &&
|
||||
b.prestigeRequirement <= prev.prestige.count
|
||||
) {
|
||||
return { ...b, status: "available" as const };
|
||||
}
|
||||
return b;
|
||||
}),
|
||||
resources: result.rewards
|
||||
? {
|
||||
...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.resources,
|
||||
player: result.rewards
|
||||
? {
|
||||
...prev.player,
|
||||
totalGoldEarned:
|
||||
prev.player.totalGoldEarned + result.rewards.gold,
|
||||
},
|
||||
upgrades: prev.upgrades.map((u) =>
|
||||
}
|
||||
: prev.player,
|
||||
upgrades: result.rewards
|
||||
? prev.upgrades.map((u) =>
|
||||
result.rewards!.upgradeIds.includes(u.id)
|
||||
? { ...u, unlocked: true }
|
||||
: u,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
)
|
||||
: prev.upgrades,
|
||||
equipment: result.rewards
|
||||
? (prev.equipment ?? []).map((e) => {
|
||||
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
|
||||
const slotEmpty = !(prev.equipment ?? []).some(
|
||||
(other) => other.type === e.type && other.equipped,
|
||||
);
|
||||
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
|
||||
})
|
||||
: prev.equipment ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Loss: reset boss HP and apply casualties
|
||||
return {
|
||||
...prev,
|
||||
bosses: prev.bosses.map((b) =>
|
||||
b.id === bossId
|
||||
? { ...b, status: "available" as const, currentHp: b.maxHp }
|
||||
: b,
|
||||
),
|
||||
adventurers: prev.adventurers.map((a) => {
|
||||
const casualty = result.casualties?.find(
|
||||
(c) => c.adventurerId === a.id,
|
||||
);
|
||||
if (!casualty) return a;
|
||||
return { ...a, count: Math.max(0, a.count - casualty.killed) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
setBattleResult({ bossName: boss.name, result });
|
||||
} catch {
|
||||
// Rate limited or other error — silently ignore
|
||||
// Silently ignore — server errors shouldn't crash the UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -227,6 +315,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
setOfflineGold(0);
|
||||
}, []);
|
||||
|
||||
const dismissBattle = useCallback(() => {
|
||||
setBattleResult(null);
|
||||
}, []);
|
||||
|
||||
const dismissAchievement = useCallback((id: string) => {
|
||||
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GameContext.Provider
|
||||
value={{
|
||||
@@ -237,10 +333,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
buyAdventurer,
|
||||
buyUpgrade,
|
||||
startQuest,
|
||||
attackBoss,
|
||||
challengeBoss,
|
||||
equipItem,
|
||||
reload,
|
||||
offlineGold,
|
||||
dismissOfflineGold,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
newAchievements,
|
||||
dismissAchievement,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+122
-10
@@ -1,4 +1,44 @@
|
||||
import type { GameState } from "@elysium/types";
|
||||
import type { Achievement, Equipment, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Checks all achievements against the current game state and returns an updated
|
||||
* achievements array, marking newly-met conditions with the current timestamp.
|
||||
*/
|
||||
const checkAchievements = (state: GameState): Achievement[] => {
|
||||
const now = Date.now();
|
||||
return (state.achievements ?? []).map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) return achievement;
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
break;
|
||||
case "bossesDefeated":
|
||||
met = state.bosses.filter((b) => b.status === "defeated").length >= condition.amount;
|
||||
break;
|
||||
case "questsCompleted":
|
||||
met = state.quests.filter((q) => q.status === "completed").length >= condition.amount;
|
||||
break;
|
||||
case "adventurerTotal":
|
||||
met = state.adventurers.reduce((sum, a) => sum + a.count, 0) >= condition.amount;
|
||||
break;
|
||||
case "prestigeCount":
|
||||
met = state.prestige.count >= condition.amount;
|
||||
break;
|
||||
case "equipmentOwned":
|
||||
met = (state.equipment ?? []).filter((e) => e.owned).length >= condition.amount;
|
||||
break;
|
||||
}
|
||||
|
||||
return met ? { ...achievement, unlockedAt: now } : achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
@@ -6,6 +46,12 @@ import type { GameState } from "@elysium/types";
|
||||
* Returns a new GameState (does not mutate the original).
|
||||
*/
|
||||
export const applyTick = (state: GameState, deltaSeconds: number): GameState => {
|
||||
const equippedItems: Equipment[] = (state.equipment ?? []).filter((e) => e.equipped);
|
||||
const equipmentGoldMultiplier = equippedItems.reduce(
|
||||
(mult, e) => mult * (e.bonus.goldMultiplier ?? 1),
|
||||
1,
|
||||
);
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
@@ -26,7 +72,12 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
goldGained +=
|
||||
adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * deltaSeconds;
|
||||
adventurer.goldPerSecond *
|
||||
adventurer.count *
|
||||
upgradeMultiplier *
|
||||
prestige *
|
||||
equipmentGoldMultiplier *
|
||||
deltaSeconds;
|
||||
|
||||
essenceGained +=
|
||||
adventurer.essencePerSecond *
|
||||
@@ -36,12 +87,16 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
deltaSeconds;
|
||||
}
|
||||
|
||||
// Complete active quests
|
||||
// Complete active quests and apply their rewards
|
||||
const now = Date.now();
|
||||
let questGold = 0;
|
||||
let questEssence = 0;
|
||||
let questCrystals = 0;
|
||||
|
||||
let updatedUpgrades = state.upgrades;
|
||||
let updatedAdventurers = state.adventurers;
|
||||
let updatedEquipment = state.equipment ?? [];
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
if (
|
||||
quest.status !== "active" ||
|
||||
@@ -51,7 +106,6 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
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;
|
||||
@@ -59,15 +113,46 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
questEssence += reward.amount;
|
||||
} else if (reward.type === "crystals" && reward.amount != null) {
|
||||
questCrystals += reward.amount;
|
||||
} else if (reward.type === "upgrade" && reward.targetId != null) {
|
||||
updatedUpgrades = updatedUpgrades.map((u) =>
|
||||
u.id === reward.targetId ? { ...u, unlocked: true } : u,
|
||||
);
|
||||
} else if (reward.type === "adventurer" && reward.targetId != null) {
|
||||
updatedAdventurers = updatedAdventurers.map((a) =>
|
||||
a.id === reward.targetId ? { ...a, unlocked: true } : a,
|
||||
);
|
||||
} else if (reward.type === "equipment" && reward.targetId != null) {
|
||||
const targetId = reward.targetId;
|
||||
updatedEquipment = updatedEquipment.map((e) => {
|
||||
if (e.id !== targetId) return e;
|
||||
const slotEmpty = !updatedEquipment.some(
|
||||
(other) => other.type === e.type && other.equipped,
|
||||
);
|
||||
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
|
||||
});
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock quests whose prerequisites are now all completed
|
||||
const completedIds = new Set(
|
||||
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
|
||||
);
|
||||
const fullyUpdatedQuests = updatedQuests.map((quest) => {
|
||||
if (quest.status !== "locked") return quest;
|
||||
if (quest.prerequisiteIds.every((id) => completedIds.has(id))) {
|
||||
return { ...quest, status: "available" as const };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
|
||||
const newGold = state.resources.gold + goldGained + questGold;
|
||||
const newEssence = state.resources.essence + essenceGained + questEssence;
|
||||
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
|
||||
|
||||
return {
|
||||
const partialState: GameState = {
|
||||
...state,
|
||||
resources: {
|
||||
...state.resources,
|
||||
@@ -77,20 +162,47 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
|
||||
},
|
||||
player: {
|
||||
...state.player,
|
||||
totalGoldEarned: state.player.totalGoldEarned + goldGained + questGold,
|
||||
totalGoldEarned: newTotalGoldEarned,
|
||||
},
|
||||
quests: updatedQuests,
|
||||
quests: fullyUpdatedQuests,
|
||||
upgrades: updatedUpgrades,
|
||||
adventurers: updatedAdventurers,
|
||||
equipment: updatedEquipment,
|
||||
lastTickAt: now,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce((sum, a, i) => {
|
||||
const wasLocked = (state.achievements ?? [])[i]?.unlockedAt === null;
|
||||
const isNowUnlocked = a.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (a.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: partialState.resources.crystals + crystalsFromAchievements,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the effective click power, including upgrades.
|
||||
* Calculates the effective click power, including upgrades and equipped trinkets.
|
||||
*/
|
||||
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;
|
||||
const equipmentClickMultiplier = (state.equipment ?? [])
|
||||
.filter((e) => e.equipped && e.bonus.clickMultiplier != null)
|
||||
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
|
||||
|
||||
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * equipmentClickMultiplier;
|
||||
};
|
||||
|
||||
@@ -291,6 +291,22 @@ body {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upgrade-card.locked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.upgrade-locked-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-progress {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.upgrade-info {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -611,6 +627,475 @@ body {
|
||||
background: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
/* ===================== BATTLE MODAL ===================== */
|
||||
.battle-modal {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.battle-stats {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.battle-stat {
|
||||
background: var(--colour-bg);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 120px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-stat .stat-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.battle-stat .stat-value {
|
||||
color: var(--colour-accent-light);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.battle-stat-divider {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.battle-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.battle-bar-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.battle-bar-row .bar-label {
|
||||
font-size: 0.85rem;
|
||||
min-width: 100px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hp-bar-container {
|
||||
background: var(--colour-bg);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hp-bar-fill {
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.battle-bar-row .bar-hp {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-in-progress {
|
||||
color: var(--colour-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.battle-outcome {
|
||||
border-radius: var(--radius);
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.battle-outcome.victory {
|
||||
background: rgba(39, 174, 96, 0.1);
|
||||
border: 1px solid #27ae60;
|
||||
}
|
||||
|
||||
.battle-outcome.defeat {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border: 1px solid #e74c3c;
|
||||
}
|
||||
|
||||
.battle-outcome h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.battle-rewards,
|
||||
.battle-casualties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.2rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
background: var(--colour-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 2rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
/* Party combat stat bar in BossPanel */
|
||||
.party-combat-stats {
|
||||
background: var(--colour-bg);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.combat-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.combat-stat .stat-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.combat-stat .stat-value {
|
||||
color: var(--colour-accent-light);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.boss-meta {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ===================== CLICK FLOAT ===================== */
|
||||
@keyframes float-up {
|
||||
0% { opacity: 1; transform: translate(-50%, 0); }
|
||||
100% { opacity: 0; transform: translate(-50%, -70px); }
|
||||
}
|
||||
|
||||
.click-button-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.click-float {
|
||||
animation: float-up 0.9s ease-out forwards;
|
||||
color: var(--colour-gold);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ===================== EQUIPMENT ===================== */
|
||||
.equipment-intro {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.equipment-slot-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.slot-heading {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.equipment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.equipment-card {
|
||||
align-items: center;
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-left: 3px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.equipment-card.equipped {
|
||||
border-color: var(--colour-success);
|
||||
border-left-color: var(--colour-success);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.equipment-card.not-owned {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Rarity border-left colours */
|
||||
.equipment-card.rarity-common { border-left-color: #9ca3af; }
|
||||
.equipment-card.rarity-rare { border-left-color: #3b82f6; }
|
||||
.equipment-card.rarity-epic { border-left-color: #a855f7; }
|
||||
.equipment-card.rarity-legendary { border-left-color: #f59e0b; }
|
||||
|
||||
.equipment-icon {
|
||||
font-size: 1.5rem;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.equipment-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.equipment-name-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.equipment-name-row h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rarity-badge {
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.rarity-badge.rarity-common { background: rgba(156, 163, 175, 0.2); color: #9ca3af; }
|
||||
.rarity-badge.rarity-rare { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||
.rarity-badge.rarity-epic { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
|
||||
.rarity-badge.rarity-legendary { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
|
||||
|
||||
.equipment-description {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.equipment-bonus {
|
||||
color: var(--colour-gold);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.equipment-action {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.equipment-locked {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.equipment-equipped-badge {
|
||||
color: var(--colour-success);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.equip-button {
|
||||
background: var(--colour-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.equip-button:hover {
|
||||
background: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
/* ===================== ACHIEVEMENTS ===================== */
|
||||
.achievement-progress {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.achievement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-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;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.achievement-card.unlocked {
|
||||
border-color: var(--colour-gold);
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.achievement-card.locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 1.5rem;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-info h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.achievement-info p {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.achievement-condition {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.achievement-reward {
|
||||
color: var(--colour-crystal) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.achievement-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-unlocked-badge {
|
||||
color: var(--colour-gold);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.achievement-locked-badge {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===================== ACHIEVEMENT TOAST ===================== */
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(120%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.achievement-toast-container {
|
||||
bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.achievement-toast {
|
||||
align-items: center;
|
||||
animation: slide-in-right 0.35s ease-out;
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-gold);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
max-width: 280px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.toast-label {
|
||||
color: var(--colour-gold);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toast-name {
|
||||
color: var(--colour-text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toast-reward {
|
||||
color: var(--colour-crystal);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ===================== UTILITY ===================== */
|
||||
.error {
|
||||
color: var(--colour-error);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Formats a number with K/M/B/T suffixes for display.
|
||||
* Numbers below 1000 show one decimal place.
|
||||
*/
|
||||
export const formatNumber = (value: number): string => {
|
||||
if (!isFinite(value) || isNaN(value)) return "0";
|
||||
if (value >= 1_000_000_000_000) {
|
||||
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
|
||||
}
|
||||
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);
|
||||
};
|
||||
@@ -1,9 +1,15 @@
|
||||
export type {
|
||||
Achievement,
|
||||
AchievementCondition,
|
||||
AchievementConditionType,
|
||||
AchievementReward,
|
||||
} from "./interfaces/Achievement.js";
|
||||
export type { Adventurer, AdventurerClass } from "./interfaces/Adventurer.js";
|
||||
export type {
|
||||
ApiError,
|
||||
AuthResponse,
|
||||
BossDamageRequest,
|
||||
BossDamageResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
@@ -12,6 +18,12 @@ export type {
|
||||
SaveResponse,
|
||||
} from "./interfaces/Api.js";
|
||||
export type { Boss, BossStatus } from "./interfaces/Boss.js";
|
||||
export type {
|
||||
Equipment,
|
||||
EquipmentBonus,
|
||||
EquipmentRarity,
|
||||
EquipmentType,
|
||||
} from "./interfaces/Equipment.js";
|
||||
export type { GameState } from "./interfaces/GameState.js";
|
||||
export type { Player } from "./interfaces/Player.js";
|
||||
export type { PrestigeData } from "./interfaces/Prestige.js";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type AchievementConditionType =
|
||||
| "totalGoldEarned"
|
||||
| "totalClicks"
|
||||
| "bossesDefeated"
|
||||
| "questsCompleted"
|
||||
| "adventurerTotal"
|
||||
| "prestigeCount"
|
||||
| "equipmentOwned";
|
||||
|
||||
export interface AchievementCondition {
|
||||
type: AchievementConditionType;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AchievementReward {
|
||||
crystals?: number;
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
condition: AchievementCondition;
|
||||
reward?: AchievementReward;
|
||||
/** Unix timestamp when unlocked, null if not yet unlocked */
|
||||
unlockedAt: number | null;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export interface Adventurer {
|
||||
goldPerSecond: number;
|
||||
/** Base essence generated per second */
|
||||
essencePerSecond: number;
|
||||
/** Combat power per unit — used in boss battle simulation */
|
||||
combatPower: number;
|
||||
count: number;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
@@ -23,20 +23,37 @@ export interface LoadResponse {
|
||||
offlineSeconds: number;
|
||||
}
|
||||
|
||||
export interface BossDamageRequest {
|
||||
export interface BossChallengeRequest {
|
||||
bossId: string;
|
||||
damage: number;
|
||||
}
|
||||
|
||||
export interface BossDamageResponse {
|
||||
currentHp: number;
|
||||
defeated: boolean;
|
||||
export interface BossChallengeResponse {
|
||||
won: boolean;
|
||||
partyDPS: number;
|
||||
bossDPS: number;
|
||||
/** Boss HP immediately before the battle */
|
||||
bossHpBefore: number;
|
||||
/** Boss maximum HP */
|
||||
bossMaxHp: number;
|
||||
/** Boss HP at end of battle before any state reset (0 on win) */
|
||||
bossHpAtBattleEnd: number;
|
||||
/** Boss HP stored in game after the result (0 on win, maxHp on loss) */
|
||||
bossNewHp: number;
|
||||
/** Total party HP at start of battle */
|
||||
partyMaxHp: number;
|
||||
/** Party HP remaining after battle (0 on loss) */
|
||||
partyHpRemaining: number;
|
||||
rewards?: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
upgradeIds: string[];
|
||||
equipmentIds: string[];
|
||||
};
|
||||
casualties?: Array<{
|
||||
adventurerId: string;
|
||||
killed: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PrestigeRequest {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Boss {
|
||||
crystalReward: number;
|
||||
/** IDs of upgrades unlocked on defeat */
|
||||
upgradeRewards: string[];
|
||||
/** IDs of equipment items granted on defeat */
|
||||
equipmentRewards: string[];
|
||||
/** Minimum prestige level required to access this boss */
|
||||
prestigeRequirement: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export type EquipmentType = "weapon" | "armour" | "trinket";
|
||||
|
||||
export type EquipmentRarity = "common" | "rare" | "epic" | "legendary";
|
||||
|
||||
export interface EquipmentBonus {
|
||||
/** Multiplier applied to all gold/s income (e.g. 1.1 = +10%) */
|
||||
goldMultiplier?: number;
|
||||
/** Multiplier applied to all combat power (e.g. 1.25 = +25%) */
|
||||
combatMultiplier?: number;
|
||||
/** Multiplier applied to click power (e.g. 1.5 = +50%) */
|
||||
clickMultiplier?: number;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
/** Whether the player has acquired this item */
|
||||
owned: boolean;
|
||||
/** Whether this item is currently equipped (only one per type can be equipped) */
|
||||
equipped: boolean;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Achievement } from "./Achievement.js";
|
||||
import type { Adventurer } from "./Adventurer.js";
|
||||
import type { Boss } from "./Boss.js";
|
||||
import type { Equipment } from "./Equipment.js";
|
||||
import type { Player } from "./Player.js";
|
||||
import type { PrestigeData } from "./Prestige.js";
|
||||
import type { Quest } from "./Quest.js";
|
||||
@@ -13,6 +15,8 @@ export interface GameState {
|
||||
upgrades: Upgrade[];
|
||||
quests: Quest[];
|
||||
bosses: Boss[];
|
||||
equipment: Equipment[];
|
||||
achievements: Achievement[];
|
||||
prestige: PrestigeData;
|
||||
/** Click power (gold per click, before upgrades) */
|
||||
baseClickPower: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type QuestStatus = "locked" | "available" | "active" | "completed";
|
||||
|
||||
export type QuestRewardType = "gold" | "essence" | "crystals" | "upgrade" | "adventurer";
|
||||
export type QuestRewardType = "gold" | "essence" | "crystals" | "upgrade" | "adventurer" | "equipment";
|
||||
|
||||
export interface QuestReward {
|
||||
type: QuestRewardType;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "."
|
||||
"rootDir": ".",
|
||||
"declaration": true
|
||||
},
|
||||
"exclude": ["test/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user