feat: add schema version system with outdated save detection

Introduces a schema version field to GameState. Saves without the
current schema version are flagged on load (showing a modal prompting
reset or proceed), and cloud saves from outdated clients are rejected
server-side. Removes all backfill code now that outdated saves are
handled via the reset flow instead.

New POST /game/reset endpoint creates a fresh save for players who
choose to reset. Save version and current schema version are displayed
in the sidebar below the app version.
This commit is contained in:
2026-03-07 17:34:26 -08:00
committed by Naomi Carrigan
parent 4ed3ccc69c
commit 5d2d71983a
11 changed files with 205 additions and 276 deletions
+2
View File
@@ -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,
});
+2
View File
@@ -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;
+54 -274
View File
@@ -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 });
});