generated from nhcarrigan/template
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:
+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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user