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