From e9e0df31fd9756b82418fd19503e300f99cef501 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 13:27:48 -0800 Subject: [PATCH] feat: add equipment, achievements, and visual polish - Equipment system: 12 items across weapon/armour/trinket slots with common/rare/epic/legendary rarities; starter commons auto-equipped, higher tiers drop from boss victories - Achievement system: 15 milestones with typed conditions; checked each tick and crystal rewards applied automatically - Achievement toast: slide-in notification, auto-dismisses after 4s - Floating click text: +X gold floats on each manual click - Expanded quests (9 total) and upgrades (12 total) - Upgrade panel now shows locked upgrades so players can see their progression path - formatNumber utility (K/M/B/T) used consistently across all panels - Backfill logic for existing saves to add new content gracefully - types package now emits .d.ts declarations --- apps/api/prisma/schema.prisma | 9 - apps/api/src/data/achievements.ts | 139 +++++ apps/api/src/data/adventurers.ts | 10 + apps/api/src/data/bosses.ts | 4 + apps/api/src/data/equipment.ts | 127 +++++ apps/api/src/data/initialState.ts | 4 + apps/api/src/data/quests.ts | 55 ++ apps/api/src/data/upgrades.ts | 47 ++ apps/api/src/routes/boss.ts | 168 ++++-- apps/api/src/routes/game.ts | 76 +++ apps/web/src/api/client.ts | 12 +- .../src/components/game/AchievementPanel.tsx | 75 +++ .../src/components/game/AchievementToast.tsx | 48 ++ apps/web/src/components/game/BattleModal.tsx | 171 ++++++ apps/web/src/components/game/BossPanel.tsx | 114 +++- apps/web/src/components/game/ClickArea.tsx | 59 ++- .../src/components/game/EquipmentPanel.tsx | 102 ++++ apps/web/src/components/game/GameLayout.tsx | 16 +- apps/web/src/components/game/UpgradePanel.tsx | 42 +- apps/web/src/components/ui/ResourceBar.tsx | 14 +- apps/web/src/context/GameContext.tsx | 161 ++++-- apps/web/src/engine/tick.ts | 132 ++++- apps/web/src/styles.css | 485 ++++++++++++++++++ apps/web/src/utils/format.ts | 20 + packages/types/src/index.ts | 16 +- packages/types/src/interfaces/Achievement.ts | 28 + packages/types/src/interfaces/Adventurer.ts | 2 + packages/types/src/interfaces/Api.ts | 27 +- packages/types/src/interfaces/Boss.ts | 2 + packages/types/src/interfaces/Equipment.ts | 25 + packages/types/src/interfaces/GameState.ts | 4 + packages/types/src/interfaces/Quest.ts | 2 +- packages/types/tsconfig.json | 3 +- 33 files changed, 2066 insertions(+), 133 deletions(-) create mode 100644 apps/api/src/data/achievements.ts create mode 100644 apps/api/src/data/equipment.ts create mode 100644 apps/web/src/components/game/AchievementPanel.tsx create mode 100644 apps/web/src/components/game/AchievementToast.tsx create mode 100644 apps/web/src/components/game/BattleModal.tsx create mode 100644 apps/web/src/components/game/EquipmentPanel.tsx create mode 100644 apps/web/src/utils/format.ts create mode 100644 packages/types/src/interfaces/Achievement.ts create mode 100644 packages/types/src/interfaces/Equipment.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bf7ba98..c68de71 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) -} diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts new file mode 100644 index 0000000..241cd0b --- /dev/null +++ b/apps/api/src/data/achievements.ts @@ -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, + }, +]; diff --git a/apps/api/src/data/adventurers.ts b/apps/api/src/data/adventurers.ts index 526d412..f90373d 100644 --- a/apps/api/src/data/adventurers.ts +++ b/apps/api/src/data/adventurers.ts @@ -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, }, diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 135f321..21144eb 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -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, }, ]; diff --git a/apps/api/src/data/equipment.ts b/apps/api/src/data/equipment.ts new file mode 100644 index 0000000..cc99ae3 --- /dev/null +++ b/apps/api/src/data/equipment.ts @@ -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, + }, +]; diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index 334f567..bc7a35a 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -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(), diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index b4ebbb8..d3a31c3 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -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"], + }, ]; diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index 2841809..b977d50 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -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, + }, ]; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index d4e51aa..683f4e8 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -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(); - - 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); }); diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index c6896a5..d23e4e9 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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 }); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3b4fd6a..9676d26 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 => body: JSON.stringify(body), }); -export const dealBossDamage = async ( - body: BossDamageRequest, -): Promise => - request("/boss/damage", { +export const challengeBoss = async ( + body: BossChallengeRequest, +): Promise => + request("/boss/challenge", { method: "POST", body: JSON.stringify(body), }); diff --git a/apps/web/src/components/game/AchievementPanel.tsx b/apps/web/src/components/game/AchievementPanel.tsx new file mode 100644 index 0000000..9f808ff --- /dev/null +++ b/apps/web/src/components/game/AchievementPanel.tsx @@ -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 ( +
+
{achievement.icon}
+
+

{achievement.name}

+

{achievement.description}

+

{conditionDescription(achievement)}

+ {achievement.reward?.crystals != null && ( +

πŸ’Ž +{achievement.reward.crystals} Crystals

+ )} +
+
+ {isUnlocked ? ( + βœ“ Unlocked + ) : ( + πŸ”’ + )} +
+
+ ); +}; + +export const AchievementPanel = (): React.JSX.Element => { + const { state } = useGame(); + + if (!state) return

Loading...

; + + const achievements = state.achievements ?? []; + const unlocked = achievements.filter((a) => a.unlockedAt !== null).length; + + return ( +
+

Achievements

+

+ {unlocked} / {achievements.length} unlocked +

+
+ {achievements.map((achievement) => ( + + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/game/AchievementToast.tsx b/apps/web/src/components/game/AchievementToast.tsx new file mode 100644 index 0000000..76d5a66 --- /dev/null +++ b/apps/web/src/components/game/AchievementToast.tsx @@ -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 ( +
{ onDismiss(achievement.id); }}> + {achievement.icon} +
+ Achievement Unlocked! + {achievement.name} + {achievement.reward?.crystals != null && ( + πŸ’Ž +{achievement.reward.crystals} + )} +
+
+ ); +}; + +export const AchievementToast = (): React.JSX.Element | null => { + const { newAchievements, dismissAchievement } = useGame(); + + if (newAchievements.length === 0) return null; + + return ( +
+ {newAchievements.map((achievement) => ( + + ))} +
+ ); +}; diff --git a/apps/web/src/components/game/BattleModal.tsx b/apps/web/src/components/game/BattleModal.tsx new file mode 100644 index 0000000..1a1cacc --- /dev/null +++ b/apps/web/src/components/game/BattleModal.tsx @@ -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 ( +
+
+

βš”οΈ Battle: {bossName}

+ +
+
+ Your Party DPS + {formatNumber(result.partyDPS)} +
+
vs
+
+ Boss DPS + {formatNumber(result.bossDPS)} +
+
+ +
+
+ πŸ‘Ή {bossName} +
+
+
+ + {formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)} + +
+ +
βš”οΈ VS βš”οΈ
+ +
+ πŸ›‘οΈ Your Party +
+
+
+ + {formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)} + +
+
+ + {phase === "animating" && ( +

Battling…

+ )} + + {phase === "result" && ( +
+ {result.won ? ( + <> +

πŸ† Victory!

+ {result.rewards && ( +
+

Rewards:

+ πŸͺ™ {formatNumber(result.rewards.gold)} gold + {result.rewards.essence > 0 && ( + ✨ {formatNumber(result.rewards.essence)} essence + )} + {result.rewards.crystals > 0 && ( + πŸ’Ž {formatNumber(result.rewards.crystals)} crystals + )} +
+ )} + + ) : ( + <> +

πŸ’€ Defeat

+

Your party was defeated. The boss has reset.

+ {result.casualties && result.casualties.length > 0 && ( +
+

Casualties:

+ {result.casualties.map((c) => ( + + ☠️ {c.killed} {c.adventurerId} lost + + ))} +
+ )} + + )} + +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index cb743d7..7149314 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -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 (
@@ -17,7 +27,9 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>

{boss.name}

{boss.description}

{isLocked && boss.status === "locked" && ( -

πŸ”’ Requires Prestige {boss.prestigeRequirement}

+

+ πŸ”’ Requires Prestige {boss.prestigeRequirement} +

)}
@@ -30,26 +42,40 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element => />
- {boss.currentHp.toLocaleString()} / {boss.maxHp.toLocaleString()} HP + {formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP
)} -
- πŸͺ™ {boss.goldReward.toLocaleString()} - {boss.essenceReward > 0 && ✨ {boss.essenceReward.toLocaleString()}} - {boss.crystalReward > 0 && πŸ’Ž {boss.crystalReward.toLocaleString()}} +
+ πŸ’’ Boss DPS: {formatNumber(boss.damagePerSecond)}
- {boss.status === "available" || boss.status === "in_progress" ? ( +
+ πŸͺ™ {formatNumber(boss.goldReward)} + {boss.essenceReward > 0 && ( + ✨ {formatNumber(boss.essenceReward)} + )} + {boss.crystalReward > 0 && ( + πŸ’Ž {formatNumber(boss.crystalReward)} + )} + {(boss.equipmentRewards ?? []).length > 0 && ( + πŸ—‘οΈ {boss.equipmentRewards.length} Equipment + )} +
+ + {(boss.status === "available" || boss.status === "in_progress") && ( - ) : null} + )} {boss.status === "defeated" && ( ☠️ Defeated @@ -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(null); if (!state) return

Loading...

; + // 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 => { + setChallengingBossId(bossId); + try { + await challengeBoss(bossId); + } finally { + setChallengingBossId(null); + } + }; + return (

Boss Encounters

+ +
+
+ βš”οΈ Party DPS + {formatNumber(partyDPS)} +
+
+ ❀️ Party HP + {formatNumber(partyHP)} +
+
+
{state.bosses.map((boss) => ( { + void handleChallenge(id); + }} /> ))}
diff --git a/apps/web/src/components/game/ClickArea.tsx b/apps/web/src/components/game/ClickArea.tsx index 6ad6308..e52e88e 100644 --- a/apps/web/src/components/game/ClickArea.tsx +++ b/apps/web/src/components/game/ClickArea.tsx @@ -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([]); + const nextIdRef = useRef(0); + + const handleClickWithFloat = useCallback( + (e: React.MouseEvent) => { + 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
; @@ -11,15 +41,26 @@ export const ClickArea = (): React.JSX.Element => { return (

Guild Hall

- -

+{clickPower.toFixed(1)} gold per click

+
+ + {floats.map((float) => ( + + {float.text} + + ))} +
+

+{formatNumber(clickPower)} gold/click

); }; diff --git a/apps/web/src/components/game/EquipmentPanel.tsx b/apps/web/src/components/game/EquipmentPanel.tsx new file mode 100644 index 0000000..3807914 --- /dev/null +++ b/apps/web/src/components/game/EquipmentPanel.tsx @@ -0,0 +1,102 @@ +import type { Equipment, EquipmentType } from "@elysium/types"; +import { useGame } from "../../context/GameContext.js"; + +const RARITY_LABEL: Record = { + common: "Common", + rare: "Rare", + epic: "Epic", + legendary: "Legendary", +}; + +const TYPE_ICON: Record = { + 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 ( +
+
{TYPE_ICON[item.type]}
+
+
+

{item.name}

+ {RARITY_LABEL[item.rarity]} +
+

{item.description}

+

{bonusDescription(item)}

+
+
+ {!item.owned && πŸ”’ Not yet obtained} + {item.owned && item.equipped && βœ“ Equipped} + {item.owned && !item.equipped && ( + + )} +
+
+ ); +}; + +const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"]; +const SLOT_LABEL: Record = { + weapon: "βš”οΈ Weapons", + armour: "πŸ›‘οΈ Armour", + trinket: "πŸ’ Trinkets", +}; + +export const EquipmentPanel = (): React.JSX.Element => { + const { state } = useGame(); + + if (!state) return

Loading...

; + + const equipment = state.equipment ?? []; + + return ( +
+

Equipment

+

+ Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time. +

+ + {SLOT_ORDER.map((slotType) => { + const items = equipment.filter((e) => e.type === slotType); + return ( +
+

{SLOT_LABEL[slotType]}

+
+ {items.map((item) => ( + + ))} +
+
+ ); + })} +
+ ); +}; diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index dd5e0cd..85a43f3 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -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("adventurers"); if (isLoading) { @@ -48,6 +54,10 @@ export const GameLayout = (): React.JSX.Element => { prestigeCount={state.prestige.count} /> + + {battleResult && ( + + )}
diff --git a/apps/web/src/components/game/UpgradePanel.tsx b/apps/web/src/components/game/UpgradePanel.tsx index 4e24ea9..f2e2ac6 100644 --- a/apps/web/src/components/game/UpgradePanel.tsx +++ b/apps/web/src/components/game/UpgradePanel.tsx @@ -12,6 +12,23 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps) const canAfford = currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence; + if (!upgrade.unlocked) { + return ( +
+
+

πŸ”’ {upgrade.name}

+

{upgrade.description}

+

Γ—{upgrade.multiplier} multiplier

+
+
+ {upgrade.costGold > 0 && πŸͺ™ {upgrade.costGold.toLocaleString()}} + {upgrade.costEssence > 0 && ✨ {upgrade.costEssence.toLocaleString()}} +
+ Locked +
+ ); + } + if (upgrade.purchased) { return (
@@ -49,16 +66,35 @@ export const UpgradePanel = (): React.JSX.Element => { if (!state) return

Loading...

; - 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 (

Upgrades

- {availableUpgrades.length === 0 ? ( +

{purchased.length} / {state.upgrades.length} purchased

+ {state.upgrades.length === 0 ? (

No upgrades available yet β€” keep adventuring!

) : (
- {availableUpgrades.map((upgrade) => ( + {available.map((upgrade) => ( + + ))} + {purchased.map((upgrade) => ( + + ))} + {locked.map((upgrade) => ( { - 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 => (
diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index 26428d7..2e37272 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -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; + /** Equip an owned equipment item (auto-unequips the same slot) */ + equipItem: (equipmentId: string) => void; /** Reload state from the server */ reload: () => Promise; /** 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(null); @@ -41,9 +56,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [offlineGold, setOfflineGold] = useState(0); + const [battleResult, setBattleResult] = useState(null); + const [newAchievements, setNewAchievements] = useState([]); const stateRef = useRef(null); const lastSaveRef = useRef(Date.now()); const rafRef = useRef(null); + const newlyUnlockedRef = useRef([]); 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 ( {children} diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 732dd07..3ba619b 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -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; }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 758277e..519c4c6 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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); diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts new file mode 100644 index 0000000..55c8c47 --- /dev/null +++ b/apps/web/src/utils/format.ts @@ -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); +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1c72e64..3534afd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; diff --git a/packages/types/src/interfaces/Achievement.ts b/packages/types/src/interfaces/Achievement.ts new file mode 100644 index 0000000..4eebaa6 --- /dev/null +++ b/packages/types/src/interfaces/Achievement.ts @@ -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; +} diff --git a/packages/types/src/interfaces/Adventurer.ts b/packages/types/src/interfaces/Adventurer.ts index 4e10401..a00b3bf 100644 --- a/packages/types/src/interfaces/Adventurer.ts +++ b/packages/types/src/interfaces/Adventurer.ts @@ -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; } diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index e8a100a..50f4e2a 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -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 { diff --git a/packages/types/src/interfaces/Boss.ts b/packages/types/src/interfaces/Boss.ts index 0cb375f..f7b4973 100644 --- a/packages/types/src/interfaces/Boss.ts +++ b/packages/types/src/interfaces/Boss.ts @@ -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; } diff --git a/packages/types/src/interfaces/Equipment.ts b/packages/types/src/interfaces/Equipment.ts new file mode 100644 index 0000000..6bddf32 --- /dev/null +++ b/packages/types/src/interfaces/Equipment.ts @@ -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; +} diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index c93dcac..7ecee90 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -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; diff --git a/packages/types/src/interfaces/Quest.ts b/packages/types/src/interfaces/Quest.ts index efae46a..cad3e29 100644 --- a/packages/types/src/interfaces/Quest.ts +++ b/packages/types/src/interfaces/Quest.ts @@ -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; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index d3b0572..2671502 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@nhcarrigan/typescript-config", "compilerOptions": { "outDir": "./prod", - "rootDir": "." + "rootDir": ".", + "declaration": true }, "exclude": ["test/**/*.ts"] }