generated from nhcarrigan/template
772d733e86
- 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
227 lines
7.3 KiB
TypeScript
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 });
|
|
});
|