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 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,
}); });
+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 { 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 });
});
+3
View File
@@ -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",
+6 -1
View File
@@ -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
+6 -1
View File
@@ -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>
);
};
+41
View File
@@ -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}
+36
View File
@@ -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;
+4
View File
@@ -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;
} }