Files
elysium/apps/api/src/routes/game.ts
T
hikari 772d733e86 feat: major content expansion with essence and crystal sinks
- 6 zones (up from 3): Shadow Marshes, Volcanic Depths, Astral Void
- 18 bosses (up from 4): 3 per zone with zone-based sequential unlock
- 24 quests (up from 9): 3-4 per zone, reorganised by zone
- 15 adventurer tiers (up from 10): Shadow Assassin through Divine Champion
- 28 equipment pieces (up from 12): boss drops + purchasable with essence/crystals
- 24 upgrades (up from 13): crystal-cost upgrades + new adventurer upgrades
- 22 achievements (up from 14): new milestones for bosses, quests, gold, armies
- Purchasable equipment system: buy items directly with essence or crystals
- Crystal-cost upgrades: spend crystals on global and click power boosts
- Zone-based boss progression: defeating a zone's last boss unlocks the next zone
2026-03-06 14:36:41 -08:00

227 lines
7.3 KiB
TypeScript

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";
export const gameRouter = new Hono();
gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async (context) => {
const discordId = context.get("discordId") as string;
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
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, upgrades, zones, and bosses from defaults (add missing ones)
const { DEFAULT_QUESTS } = await import("../data/quests.js");
const { DEFAULT_UPGRADES } = await import("../data/upgrades.js");
const { DEFAULT_ZONES } = await import("../data/zones.js");
const { DEFAULT_BOSSES } = await import("../data/bosses.js");
for (const defaultQuest of DEFAULT_QUESTS) {
if (!state.quests.some((q) => q.id === defaultQuest.id)) {
state.quests.push(structuredClone(defaultQuest));
needsBackfill = true;
}
}
// Sync zoneId on quests to match current defaults
for (const quest of state.quests) {
const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id);
if (defaults && quest.zoneId !== defaults.zoneId) {
quest.zoneId = defaults.zoneId;
needsBackfill = true;
}
if (!quest.zoneId) {
quest.zoneId = defaults?.zoneId ?? "verdant_vale";
needsBackfill = true;
}
}
for (const defaultUpgrade of DEFAULT_UPGRADES) {
if (!state.upgrades.some((u) => u.id === defaultUpgrade.id)) {
state.upgrades.push(structuredClone(defaultUpgrade));
needsBackfill = true;
}
}
// Backfill costCrystals on upgrades that predate the field
for (const upgrade of state.upgrades) {
if (upgrade.costCrystals == null) {
upgrade.costCrystals = 0;
needsBackfill = true;
}
}
// Merge new adventurers from defaults
for (const defaultAdventurer of DEFAULT_ADVENTURERS) {
if (!state.adventurers.some((a) => a.id === defaultAdventurer.id)) {
state.adventurers.push(structuredClone(defaultAdventurer));
needsBackfill = true;
}
}
// Backfill zones
if (!Array.isArray(state.zones) || state.zones.length === 0) {
state.zones = structuredClone(DEFAULT_ZONES);
// Infer unlock state from defeated bosses
for (const zone of state.zones) {
if (zone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (unlockBoss?.status === "defeated") {
zone.status = "unlocked";
}
}
}
needsBackfill = true;
} else {
// Merge new zones from defaults
for (const defaultZone of DEFAULT_ZONES) {
if (!state.zones.some((z) => z.id === defaultZone.id)) {
const newZone = structuredClone(defaultZone);
// Infer unlock state from defeated bosses
if (newZone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === newZone.unlockBossId);
if (unlockBoss?.status === "defeated") {
newZone.status = "unlocked";
}
}
state.zones.push(newZone);
needsBackfill = true;
}
}
}
// Backfill zoneId on bosses that predate the field
for (const boss of state.bosses) {
if (!boss.zoneId) {
const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id);
boss.zoneId = defaults?.zoneId ?? "verdant_vale";
needsBackfill = true;
}
}
// Merge new bosses from defaults (new zones' bosses)
for (const defaultBoss of DEFAULT_BOSSES) {
if (!state.bosses.some((b) => b.id === defaultBoss.id)) {
const newBoss = structuredClone(defaultBoss);
// If the zone for this boss is already unlocked, make the first boss in that zone available
const zone = state.zones.find((z) => z.id === newBoss.zoneId);
if (zone?.status === "unlocked") {
const zoneBossesInState = state.bosses.filter((b) => b.zoneId === newBoss.zoneId);
if (zoneBossesInState.length === 0 && newBoss.status === "locked") {
newBoss.status = "available";
}
}
state.bosses.push(newBoss);
needsBackfill = true;
}
}
const now = Date.now();
const { offlineGold, offlineSeconds } = calculateOfflineGold(state, now);
if (offlineGold > 0) {
state.resources.gold += offlineGold;
state.player.totalGoldEarned += offlineGold;
}
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 });
});
gameRouter.post("/save", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<SaveRequest>();
if (!body.state) {
return context.json({ error: "Missing state in request body" }, 400);
}
const now = Date.now();
await prisma.player.update({
where: { discordId },
data: {
lastSavedAt: now,
totalGoldEarned: body.state.player.totalGoldEarned,
totalClicks: body.state.player.totalClicks,
characterName: body.state.player.characterName,
},
});
await prisma.gameState.upsert({
where: { discordId },
create: { discordId, state: body.state, updatedAt: now },
update: { state: body.state, updatedAt: now },
});
return context.json({ savedAt: now });
});