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
This commit is contained in:
2026-03-06 13:27:48 -08:00
committed by Naomi Carrigan
parent a3daed1683
commit e9e0df31fd
33 changed files with 2066 additions and 133 deletions
+139 -29
View File
@@ -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);
});
+76
View File
@@ -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 });
});