diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index 898e528..752f08a 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -1,5 +1,6 @@ import type { ApotheosisData, ExplorationState, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types"; import { DEFAULT_ACHIEVEMENTS } from "./achievements.js"; +import { CURRENT_SCHEMA_VERSION } from "./schemaVersion.js"; import { DEFAULT_ADVENTURERS } from "./adventurers.js"; import { DEFAULT_BOSSES } from "./bosses.js"; import { DEFAULT_EQUIPMENT } from "./equipment.js"; @@ -70,4 +71,5 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS apotheosis: { ...INITIAL_APOTHEOSIS }, exploration: structuredClone(INITIAL_EXPLORATION), companions: { unlockedCompanionIds: [], activeCompanionId: null }, + schemaVersion: CURRENT_SCHEMA_VERSION, }); diff --git a/apps/api/src/data/schemaVersion.ts b/apps/api/src/data/schemaVersion.ts new file mode 100644 index 0000000..456ded1 --- /dev/null +++ b/apps/api/src/data/schemaVersion.ts @@ -0,0 +1,2 @@ +/** The current game state schema version. Bump this whenever a breaking change is made to GameState. */ +export const CURRENT_SCHEMA_VERSION = 1; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 3685826..f4eb542 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -4,15 +4,12 @@ import { createHmac } from "node:crypto"; import { Hono } from "hono"; import type { HonoEnv } from "../types/hono.js"; import { prisma } from "../db/client.js"; -import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js"; -import { DEFAULT_ADVENTURERS } from "../data/adventurers.js"; import { DEFAULT_BOSSES } from "../data/bosses.js"; -import { DEFAULT_EQUIPMENT } from "../data/equipment.js"; import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js"; -import { DEFAULT_EXPLORATIONS } from "../data/explorations.js"; -import { INITIAL_EXPLORATION, INITIAL_GAME_STATE } from "../data/initialState.js"; +import { INITIAL_GAME_STATE } from "../data/initialState.js"; import { DAILY_REWARDS } from "../data/loginBonus.js"; import { DEFAULT_QUESTS } from "../data/quests.js"; +import { CURRENT_SCHEMA_VERSION } from "../data/schemaVersion.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; @@ -440,274 +437,11 @@ gameRouter.get("/load", async (context) => { }); const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret ? computeHmac(JSON.stringify(freshState), secret) : undefined; - return context.json({ state: freshState, offlineGold: 0, offlineEssence: 0, offlineSeconds: 0, signature, loginBonus: null, loginStreak: playerRecord.loginStreak ?? 1 }); + return context.json({ state: freshState, offlineGold: 0, offlineEssence: 0, offlineSeconds: 0, signature, loginBonus: null, loginStreak: playerRecord.loginStreak ?? 1, schemaOutdated: false, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); } const state = record.state as unknown as GameState; - let needsBackfill = false; - - // Backfill combatPower and baseCost on saves that predate those fields - for (const adventurer of state.adventurers) { - const defaults = DEFAULT_ADVENTURERS.find((d) => d.id === adventurer.id); - if (adventurer.combatPower == null) { - adventurer.combatPower = defaults?.combatPower ?? 1; - needsBackfill = true; - } - if (adventurer.baseCost == null) { - adventurer.baseCost = defaults?.baseCost ?? 10; - 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 (will be synced below after defaults load) - 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 and rewards 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; - } - // Sync rewards to match defaults so newly-added rewards take effect - if (defaults && JSON.stringify(quest.rewards) !== JSON.stringify(defaults.rewards)) { - quest.rewards = structuredClone(defaults.rewards); - needsBackfill = true; - } - // Revert "available" quests back to "locked" if their zone is still locked - if (quest.status === "available") { - const zone = state.zones.find((z) => z.id === quest.zoneId); - if (zone?.status === "locked") { - quest.status = "locked"; - needsBackfill = true; - } - } - // Retroactively apply adventurer unlocks from already-completed quests - if (quest.status === "completed") { - for (const reward of quest.rewards) { - if (reward.type === "adventurer" && reward.targetId) { - const adventurer = state.adventurers.find((a) => a.id === reward.targetId); - if (adventurer && !adventurer.unlocked) { - adventurer.unlocked = true; - 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; - } - } - - const zoneUnlockConditionsMet = (zone: { unlockBossId: string | null; unlockQuestId: string | null }): boolean => { - const bossOk = - zone.unlockBossId == null || - state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated"); - const questOk = - zone.unlockQuestId == null || - state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed"); - return bossOk && questOk; - }; - - // Backfill zones - if (!Array.isArray(state.zones) || state.zones.length === 0) { - state.zones = structuredClone(DEFAULT_ZONES); - // Infer unlock state from current boss + quest completion - for (const zone of state.zones) { - if (zone.unlockBossId != null || zone.unlockQuestId != null) { - if (zoneUnlockConditionsMet(zone)) { - 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 current boss + quest completion - if (newZone.unlockBossId != null || newZone.unlockQuestId != null) { - if (zoneUnlockConditionsMet(newZone)) { - newZone.status = "unlocked"; - } - } - state.zones.push(newZone); - needsBackfill = true; - } - } - } - - // Sync unlockBossId and unlockQuestId from defaults in case zone gate requirements changed - for (const zone of state.zones) { - const defaultZone = DEFAULT_ZONES.find((z) => z.id === zone.id); - if (!defaultZone) continue; - if (zone.unlockBossId !== defaultZone.unlockBossId) { - zone.unlockBossId = defaultZone.unlockBossId; - needsBackfill = true; - } - if (!("unlockQuestId" in zone) || zone.unlockQuestId !== defaultZone.unlockQuestId) { - zone.unlockQuestId = defaultZone.unlockQuestId; - needsBackfill = true; - } - } - - // Re-verify zone unlock status against current unlock conditions - // (handles cases where gate requirements changed in a data update) - for (const zone of state.zones) { - if (zone.unlockBossId == null && zone.unlockQuestId == null) continue; - if (zone.status === "unlocked" && !zoneUnlockConditionsMet(zone)) { - zone.status = "locked"; - // Revert any "available" bosses in this zone back to "locked" - for (const boss of state.bosses) { - if (boss.zoneId === zone.id && boss.status === "available") { - boss.status = "locked"; - needsBackfill = true; - } - } - needsBackfill = true; - } - } - - // Backfill zoneId and sync rewards on bosses to match current defaults - for (const boss of state.bosses) { - const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); - if (!boss.zoneId) { - boss.zoneId = defaults?.zoneId ?? "verdant_vale"; - needsBackfill = true; - } - // Sync equipmentRewards, upgradeRewards, and prestigeRequirement to match defaults - if (defaults) { - if (JSON.stringify(boss.equipmentRewards) !== JSON.stringify(defaults.equipmentRewards)) { - boss.equipmentRewards = structuredClone(defaults.equipmentRewards); - needsBackfill = true; - } - if (JSON.stringify(boss.upgradeRewards) !== JSON.stringify(defaults.upgradeRewards)) { - boss.upgradeRewards = structuredClone(defaults.upgradeRewards); - needsBackfill = true; - } - if (boss.prestigeRequirement !== defaults.prestigeRequirement) { - boss.prestigeRequirement = defaults.prestigeRequirement; - 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; - } - } - - // Backfill exploration state on saves that predate the feature - if (!state.exploration) { - state.exploration = structuredClone(INITIAL_EXPLORATION); - // Unlock areas for zones already unlocked in this save - for (const area of state.exploration.areas) { - const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id); - if (!areaData) continue; - const zone = state.zones.find((z) => z.id === areaData.zoneId); - if (zone?.status === "unlocked") { - area.status = "available"; - } - } - needsBackfill = true; - } else { - // Merge any new exploration areas added since this save was created - for (const defaultArea of DEFAULT_EXPLORATIONS) { - if (!state.exploration.areas.some((a) => a.id === defaultArea.id)) { - const zone = state.zones.find((z) => z.id === defaultArea.zoneId); - state.exploration.areas.push({ - id: defaultArea.id, - status: zone?.status === "unlocked" ? "available" : "locked", - }); - needsBackfill = true; - } - } - } - const now = Date.now(); const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now); @@ -751,7 +485,6 @@ gameRouter.get("/load", async (context) => { day: dayIndex + 1, weekMultiplier, }; - needsBackfill = true; await prisma.player.update({ where: { discordId }, @@ -764,9 +497,9 @@ gameRouter.get("/load", async (context) => { state.lastTickAt = now; - if (needsBackfill || offlineGold > 0 || offlineEssence > 0) { - // Swallow write conflicts (P2034): the corrected state is still returned to the - // client and will be persisted on the next auto-save, so the backfill is not lost. + if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) { + // Swallow write conflicts (P2034): offline earnings and login bonus are applied + // server-side and must be persisted immediately so they aren't double-counted. await prisma.gameState.update({ where: { discordId }, data: { state: state as object, updatedAt: now }, @@ -776,9 +509,11 @@ gameRouter.get("/load", async (context) => { }); } + const schemaOutdated = (state.schemaVersion ?? 0) < CURRENT_SCHEMA_VERSION; + const secret = process.env.ANTI_CHEAT_SECRET; const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined; - return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature, loginBonus, loginStreak }); + return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature, loginBonus, loginStreak, schemaOutdated, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); }); gameRouter.post("/save", async (context) => { @@ -789,6 +524,10 @@ gameRouter.post("/save", async (context) => { return context.json({ error: "Missing state in request body" }, 400); } + if ((body.state.schemaVersion ?? 0) < CURRENT_SCHEMA_VERSION) { + return context.json({ error: "Save rejected: your save data is outdated. Please reset your progress to enable cloud saves." }, 409); + } + const secret = process.env.ANTI_CHEAT_SECRET; const [record, playerRecord] = await Promise.all([ prisma.gameState.findUnique({ where: { discordId } }), @@ -875,3 +614,44 @@ gameRouter.post("/save", async (context) => { const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined; return context.json({ savedAt: now, signature }); }); + +gameRouter.post("/reset", async (context) => { + const discordId = context.get("discordId") as string; + + const playerRecord = await prisma.player.findUnique({ where: { discordId } }); + if (!playerRecord) { + return context.json({ error: "No player found" }, 404); + } + + const freshState = INITIAL_GAME_STATE( + { + discordId: playerRecord.discordId, + username: playerRecord.username, + discriminator: playerRecord.discriminator, + avatar: playerRecord.avatar, + characterName: playerRecord.characterName, + createdAt: playerRecord.createdAt, + lastSavedAt: Date.now(), + totalGoldEarned: 0, + totalClicks: 0, + lifetimeGoldEarned: playerRecord.lifetimeGoldEarned, + lifetimeClicks: playerRecord.lifetimeClicks, + lifetimeBossesDefeated: playerRecord.lifetimeBossesDefeated, + lifetimeQuestsCompleted: playerRecord.lifetimeQuestsCompleted, + lifetimeAdventurersRecruited: playerRecord.lifetimeAdventurersRecruited, + lifetimeAchievementsUnlocked: playerRecord.lifetimeAchievementsUnlocked, + }, + playerRecord.characterName, + ); + + const createdAt = Date.now(); + await prisma.gameState.upsert({ + where: { discordId }, + create: { discordId, state: freshState as object, updatedAt: createdAt }, + update: { state: freshState as object, updatedAt: createdAt }, + }); + + const secret = process.env.ANTI_CHEAT_SECRET; + const signature = secret ? computeHmac(JSON.stringify(freshState), secret) : undefined; + return context.json({ state: freshState, offlineGold: 0, offlineEssence: 0, offlineSeconds: 0, signature, loginBonus: null, loginStreak: playerRecord.loginStreak ?? 1, schemaOutdated: false, currentSchemaVersion: CURRENT_SCHEMA_VERSION }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 67602ad..5a94f01 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -75,6 +75,9 @@ export const handleAuthCallback = async (code: string): Promise => export const loadGame = async (): Promise => request("/game/load"); +export const resetProgress = async (): Promise => + request("/game/reset", { method: "POST" }); + export const saveGame = async (body: SaveRequest): Promise => request("/game/save", { method: "POST", diff --git a/apps/web/src/components/game/ClickArea.tsx b/apps/web/src/components/game/ClickArea.tsx index 379a092..4d5b6ca 100644 --- a/apps/web/src/components/game/ClickArea.tsx +++ b/apps/web/src/components/game/ClickArea.tsx @@ -10,7 +10,7 @@ interface FloatText { } export const ClickArea = (): React.JSX.Element => { - const { state, handleClick, formatNumber } = useGame(); + const { state, handleClick, formatNumber, saveSchemaVersion, currentSchemaVersion } = useGame(); const [floats, setFloats] = useState([]); const nextIdRef = useRef(0); @@ -41,6 +41,11 @@ export const ClickArea = (): React.JSX.Element => {

Elysium

v{__WEB_VERSION__}

+ {currentSchemaVersion > 0 && ( +

+ Save: v{saveSchemaVersion} / Latest: v{currentSchemaVersion} +

+ )}

Guild Hall

+ +
+ + + ); +}; diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index 2b1ba8f..7ce94ec 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -113,6 +113,7 @@ import { craftRecipe as craftRecipeApi, loadGame, prestige as prestigeApi, + resetProgress as resetProgressApi, saveGame, startExploration as startExplorationApi, transcend as transcendApi, @@ -217,6 +218,14 @@ interface GameContextValue { dismissStoryChapter: (id: string) => void; /** Record the player's choice for a story chapter */ completeChapter: (chapterId: string, choiceId: string) => void; + /** True when the loaded save is from an older schema version */ + schemaOutdated: boolean; + /** The save's schema version (0 if missing) */ + saveSchemaVersion: number; + /** The server's current expected schema version */ + currentSchemaVersion: number; + /** Reset all progress to a fresh save state (resolves schema outdated) */ + resetProgress: () => Promise; } const GameContext = createContext(null); @@ -249,6 +258,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const isAutoPrestigingRef = useRef(false); const isAutoBossingRef = useRef(false); const reloadRef = useRef<() => Promise>(() => Promise.resolve()); + const [schemaOutdated, setSchemaOutdated] = useState(false); + const [saveSchemaVersion, setSaveSchemaVersion] = useState(0); + const [currentSchemaVersion, setCurrentSchemaVersion] = useState(0); const [newCodexEntryIds, setNewCodexEntryIds] = useState([]); const codexProcessedRef = useRef>(new Set()); const [newStoryChapterIds, setNewStoryChapterIds] = useState([]); @@ -278,6 +290,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React setLoginBonus(data.loginBonus); } setLoginStreak(data.loginStreak ?? 1); + setSchemaOutdated(data.schemaOutdated); + setSaveSchemaVersion(data.state.schemaVersion ?? 0); + setCurrentSchemaVersion(data.currentSchemaVersion); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`) .then(async (res) => { @@ -1029,6 +1044,28 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React }); }, []); + const resetProgress = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await resetProgressApi(); + setState(data.state); + setLastSavedAt(data.state.player.lastSavedAt); + setSchemaOutdated(false); + setOfflineGold(0); + setOfflineEssence(0); + setLoginBonus(null); + if (data.signature) { + signatureRef.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to reset progress"); + } finally { + setIsLoading(false); + } + }, []); + const dismissLoginBonus = useCallback(() => { setLoginBonus(null); }, []); @@ -1085,6 +1122,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React newStoryChapterIds, dismissStoryChapter, completeChapter, + schemaOutdated, + saveSchemaVersion, + currentSchemaVersion, + resetProgress, }} > {children} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ad31b60..aa6555d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -180,6 +180,13 @@ body { font-size: 0.65rem; color: var(--colour-text-muted); opacity: 0.7; + margin-bottom: 0.25rem; +} + +.game-schema-version { + font-size: 0.6rem; + color: var(--colour-text-muted); + opacity: 0.6; margin-bottom: 0.5rem; } @@ -833,6 +840,35 @@ body { background: var(--colour-accent-light); } +/* ===================== OUTDATED SCHEMA MODAL ===================== */ +.outdated-modal-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; +} + +.outdated-modal-reset-button { + background: #c0392b; + border: none; + border-radius: var(--radius); + color: #fff; + cursor: pointer; + font-size: 1rem; + font-weight: 700; + padding: 0.6rem 2rem; + transition: background 0.15s; +} + +.outdated-modal-reset-button:hover:not(:disabled) { + background: #e74c3c; +} + +.outdated-modal-reset-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + /* ===================== BATTLE MODAL ===================== */ .battle-modal { max-width: 520px; diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index f868e5e..72f9ac6 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -48,6 +48,10 @@ export interface LoadResponse { loginBonus: LoginBonusResult | null; /** Current login streak (always present) */ loginStreak: number; + /** True when the player's save data is from an older schema version */ + schemaOutdated: boolean; + /** The current expected schema version from the server */ + currentSchemaVersion: number; } export interface BossChallengeRequest { diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index 7fc9f2d..71078b0 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -49,4 +49,6 @@ export interface GameState { companions?: CompanionState; /** Story chapter unlock and completion state — optional for backwards compatibility */ story?: StoryState; + /** Schema version — used to detect saves from older game versions */ + schemaVersion?: number; }