generated from nhcarrigan/template
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:
@@ -1,5 +1,6 @@
|
|||||||
import type { ApotheosisData, ExplorationState, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types";
|
import type { ApotheosisData, ExplorationState, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types";
|
||||||
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
|
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
|
||||||
|
import { CURRENT_SCHEMA_VERSION } from "./schemaVersion.js";
|
||||||
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
|
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
|
||||||
import { DEFAULT_BOSSES } from "./bosses.js";
|
import { DEFAULT_BOSSES } from "./bosses.js";
|
||||||
import { DEFAULT_EQUIPMENT } from "./equipment.js";
|
import { DEFAULT_EQUIPMENT } from "./equipment.js";
|
||||||
@@ -70,4 +71,5 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS
|
|||||||
apotheosis: { ...INITIAL_APOTHEOSIS },
|
apotheosis: { ...INITIAL_APOTHEOSIS },
|
||||||
exploration: structuredClone(INITIAL_EXPLORATION),
|
exploration: structuredClone(INITIAL_EXPLORATION),
|
||||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -4,15 +4,12 @@ import { createHmac } from "node:crypto";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { HonoEnv } from "../types/hono.js";
|
import type { HonoEnv } from "../types/hono.js";
|
||||||
import { prisma } from "../db/client.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_BOSSES } from "../data/bosses.js";
|
||||||
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
|
|
||||||
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||||
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
|
import { INITIAL_GAME_STATE } from "../data/initialState.js";
|
||||||
import { INITIAL_EXPLORATION, INITIAL_GAME_STATE } from "../data/initialState.js";
|
|
||||||
import { DAILY_REWARDS } from "../data/loginBonus.js";
|
import { DAILY_REWARDS } from "../data/loginBonus.js";
|
||||||
import { DEFAULT_QUESTS } from "../data/quests.js";
|
import { DEFAULT_QUESTS } from "../data/quests.js";
|
||||||
|
import { CURRENT_SCHEMA_VERSION } from "../data/schemaVersion.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
@@ -440,274 +437,11 @@ gameRouter.get("/load", async (context) => {
|
|||||||
});
|
});
|
||||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
const signature = secret ? computeHmac(JSON.stringify(freshState), secret) : undefined;
|
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;
|
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 now = Date.now();
|
||||||
|
|
||||||
const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now);
|
const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now);
|
||||||
@@ -751,7 +485,6 @@ gameRouter.get("/load", async (context) => {
|
|||||||
day: dayIndex + 1,
|
day: dayIndex + 1,
|
||||||
weekMultiplier,
|
weekMultiplier,
|
||||||
};
|
};
|
||||||
needsBackfill = true;
|
|
||||||
|
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
@@ -764,9 +497,9 @@ gameRouter.get("/load", async (context) => {
|
|||||||
|
|
||||||
state.lastTickAt = now;
|
state.lastTickAt = now;
|
||||||
|
|
||||||
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
|
if (offlineGold > 0 || offlineEssence > 0 || loginBonus !== null) {
|
||||||
// Swallow write conflicts (P2034): the corrected state is still returned to the
|
// Swallow write conflicts (P2034): offline earnings and login bonus are applied
|
||||||
// client and will be persisted on the next auto-save, so the backfill is not lost.
|
// server-side and must be persisted immediately so they aren't double-counted.
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
data: { state: state as object, updatedAt: now },
|
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 secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined;
|
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) => {
|
gameRouter.post("/save", async (context) => {
|
||||||
@@ -789,6 +524,10 @@ gameRouter.post("/save", async (context) => {
|
|||||||
return context.json({ error: "Missing state in request body" }, 400);
|
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 secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
const [record, playerRecord] = await Promise.all([
|
const [record, playerRecord] = await Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
prisma.gameState.findUnique({ where: { discordId } }),
|
||||||
@@ -875,3 +614,44 @@ gameRouter.post("/save", async (context) => {
|
|||||||
const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined;
|
const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined;
|
||||||
return context.json({ savedAt: now, signature });
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export const handleAuthCallback = async (code: string): Promise<AuthResponse> =>
|
|||||||
export const loadGame = async (): Promise<LoadResponse> =>
|
export const loadGame = async (): Promise<LoadResponse> =>
|
||||||
request<LoadResponse>("/game/load");
|
request<LoadResponse>("/game/load");
|
||||||
|
|
||||||
|
export const resetProgress = async (): Promise<LoadResponse> =>
|
||||||
|
request<LoadResponse>("/game/reset", { method: "POST" });
|
||||||
|
|
||||||
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||||
request<SaveResponse>("/game/save", {
|
request<SaveResponse>("/game/save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface FloatText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ClickArea = (): React.JSX.Element => {
|
export const ClickArea = (): React.JSX.Element => {
|
||||||
const { state, handleClick, formatNumber } = useGame();
|
const { state, handleClick, formatNumber, saveSchemaVersion, currentSchemaVersion } = useGame();
|
||||||
const [floats, setFloats] = useState<FloatText[]>([]);
|
const [floats, setFloats] = useState<FloatText[]>([]);
|
||||||
const nextIdRef = useRef(0);
|
const nextIdRef = useRef(0);
|
||||||
|
|
||||||
@@ -41,6 +41,11 @@ export const ClickArea = (): React.JSX.Element => {
|
|||||||
<section className="click-area">
|
<section className="click-area">
|
||||||
<h1 className="game-title">Elysium</h1>
|
<h1 className="game-title">Elysium</h1>
|
||||||
<p className="game-version">v{__WEB_VERSION__}</p>
|
<p className="game-version">v{__WEB_VERSION__}</p>
|
||||||
|
{currentSchemaVersion > 0 && (
|
||||||
|
<p className="game-schema-version">
|
||||||
|
Save: v{saveSchemaVersion} / Latest: v{currentSchemaVersion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<h2>Guild Hall</h2>
|
<h2>Guild Hall</h2>
|
||||||
<div className="click-button-wrapper">
|
<div className="click-button-wrapper">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CodexToast } from "./CodexToast.js";
|
|||||||
import { EditProfileModal } from "./EditProfileModal.js";
|
import { EditProfileModal } from "./EditProfileModal.js";
|
||||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||||
import { OfflineModal } from "./OfflineModal.js";
|
import { OfflineModal } from "./OfflineModal.js";
|
||||||
|
import { OutdatedSchemaModal } from "./OutdatedSchemaModal.js";
|
||||||
import { PrestigePanel } from "./PrestigePanel.js";
|
import { PrestigePanel } from "./PrestigePanel.js";
|
||||||
import { ApotheosisPanel } from "./ApotheosisPanel.js";
|
import { ApotheosisPanel } from "./ApotheosisPanel.js";
|
||||||
import { TranscendencePanel } from "./TranscendencePanel.js";
|
import { TranscendencePanel } from "./TranscendencePanel.js";
|
||||||
@@ -52,9 +53,10 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const GameLayout = (): React.JSX.Element => {
|
export const GameLayout = (): React.JSX.Element => {
|
||||||
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, newStoryChapterIds, loginBonus, dismissLoginBonus } = useGame();
|
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, newStoryChapterIds, loginBonus, dismissLoginBonus, schemaOutdated } = useGame();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
|
const [dismissedOutdatedWarning, setDismissedOutdatedWarning] = useState(false);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -91,6 +93,9 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
onForceSync={forceSync}
|
onForceSync={forceSync}
|
||||||
/>
|
/>
|
||||||
<OfflineModal />
|
<OfflineModal />
|
||||||
|
{schemaOutdated && !dismissedOutdatedWarning && (
|
||||||
|
<OutdatedSchemaModal onDismiss={() => { setDismissedOutdatedWarning(true); }} />
|
||||||
|
)}
|
||||||
<AchievementToast />
|
<AchievementToast />
|
||||||
<CodexToast />
|
<CodexToast />
|
||||||
<StoryToast />
|
<StoryToast />
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useGame } from "../../context/GameContext.js";
|
||||||
|
|
||||||
|
interface OutdatedSchemaModalProps {
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OutdatedSchemaModal = ({ onDismiss }: OutdatedSchemaModalProps): React.JSX.Element => {
|
||||||
|
const { resetProgress } = useGame();
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const handleReset = async (): Promise<void> => {
|
||||||
|
setIsResetting(true);
|
||||||
|
await resetProgress();
|
||||||
|
setIsResetting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal offline-modal">
|
||||||
|
<h2>⚠️ Outdated Save Data</h2>
|
||||||
|
<p>
|
||||||
|
Your save data is from an older version of Elysium and may cause bugs or unexpected
|
||||||
|
behaviour. Cloud saves are <strong>disabled</strong> until you reset your progress.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Resetting will start you fresh — all progress will be lost.
|
||||||
|
</p>
|
||||||
|
<div className="outdated-modal-actions">
|
||||||
|
<button
|
||||||
|
className="outdated-modal-reset-button"
|
||||||
|
onClick={() => { void handleReset(); }}
|
||||||
|
disabled={isResetting}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isResetting ? "Resetting…" : "Reset Progress"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Proceed with Bugs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
craftRecipe as craftRecipeApi,
|
craftRecipe as craftRecipeApi,
|
||||||
loadGame,
|
loadGame,
|
||||||
prestige as prestigeApi,
|
prestige as prestigeApi,
|
||||||
|
resetProgress as resetProgressApi,
|
||||||
saveGame,
|
saveGame,
|
||||||
startExploration as startExplorationApi,
|
startExploration as startExplorationApi,
|
||||||
transcend as transcendApi,
|
transcend as transcendApi,
|
||||||
@@ -217,6 +218,14 @@ interface GameContextValue {
|
|||||||
dismissStoryChapter: (id: string) => void;
|
dismissStoryChapter: (id: string) => void;
|
||||||
/** Record the player's choice for a story chapter */
|
/** Record the player's choice for a story chapter */
|
||||||
completeChapter: (chapterId: string, choiceId: string) => void;
|
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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameContext = createContext<GameContextValue | null>(null);
|
const GameContext = createContext<GameContextValue | null>(null);
|
||||||
@@ -249,6 +258,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
const isAutoPrestigingRef = useRef(false);
|
const isAutoPrestigingRef = useRef(false);
|
||||||
const isAutoBossingRef = useRef(false);
|
const isAutoBossingRef = useRef(false);
|
||||||
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||||
|
const [schemaOutdated, setSchemaOutdated] = useState(false);
|
||||||
|
const [saveSchemaVersion, setSaveSchemaVersion] = useState(0);
|
||||||
|
const [currentSchemaVersion, setCurrentSchemaVersion] = useState(0);
|
||||||
const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]);
|
const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]);
|
||||||
const codexProcessedRef = useRef<Set<string>>(new Set());
|
const codexProcessedRef = useRef<Set<string>>(new Set());
|
||||||
const [newStoryChapterIds, setNewStoryChapterIds] = useState<string[]>([]);
|
const [newStoryChapterIds, setNewStoryChapterIds] = useState<string[]>([]);
|
||||||
@@ -278,6 +290,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
setLoginBonus(data.loginBonus);
|
setLoginBonus(data.loginBonus);
|
||||||
}
|
}
|
||||||
setLoginStreak(data.loginStreak ?? 1);
|
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)
|
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||||
void fetch(`/api/profile/${data.state.player.discordId}`)
|
void fetch(`/api/profile/${data.state.player.discordId}`)
|
||||||
.then(async (res) => {
|
.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(() => {
|
const dismissLoginBonus = useCallback(() => {
|
||||||
setLoginBonus(null);
|
setLoginBonus(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1085,6 +1122,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
newStoryChapterIds,
|
newStoryChapterIds,
|
||||||
dismissStoryChapter,
|
dismissStoryChapter,
|
||||||
completeChapter,
|
completeChapter,
|
||||||
|
schemaOutdated,
|
||||||
|
saveSchemaVersion,
|
||||||
|
currentSchemaVersion,
|
||||||
|
resetProgress,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ body {
|
|||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--colour-text-muted);
|
color: var(--colour-text-muted);
|
||||||
opacity: 0.7;
|
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;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,6 +840,35 @@ body {
|
|||||||
background: var(--colour-accent-light);
|
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 ===================== */
|
||||||
.battle-modal {
|
.battle-modal {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export interface LoadResponse {
|
|||||||
loginBonus: LoginBonusResult | null;
|
loginBonus: LoginBonusResult | null;
|
||||||
/** Current login streak (always present) */
|
/** Current login streak (always present) */
|
||||||
loginStreak: number;
|
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 {
|
export interface BossChallengeRequest {
|
||||||
|
|||||||
@@ -49,4 +49,6 @@ export interface GameState {
|
|||||||
companions?: CompanionState;
|
companions?: CompanionState;
|
||||||
/** Story chapter unlock and completion state — optional for backwards compatibility */
|
/** Story chapter unlock and completion state — optional for backwards compatibility */
|
||||||
story?: StoryState;
|
story?: StoryState;
|
||||||
|
/** Schema version — used to detect saves from older game versions */
|
||||||
|
schemaVersion?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user