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:
@@ -75,6 +75,9 @@ export const handleAuthCallback = async (code: string): Promise<AuthResponse> =>
|
||||
export const loadGame = async (): Promise<LoadResponse> =>
|
||||
request<LoadResponse>("/game/load");
|
||||
|
||||
export const resetProgress = async (): Promise<LoadResponse> =>
|
||||
request<LoadResponse>("/game/reset", { method: "POST" });
|
||||
|
||||
export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
|
||||
request<SaveResponse>("/game/save", {
|
||||
method: "POST",
|
||||
|
||||
@@ -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<FloatText[]>([]);
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
@@ -41,6 +41,11 @@ export const ClickArea = (): React.JSX.Element => {
|
||||
<section className="click-area">
|
||||
<h1 className="game-title">Elysium</h1>
|
||||
<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>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CodexToast } from "./CodexToast.js";
|
||||
import { EditProfileModal } from "./EditProfileModal.js";
|
||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||
import { OfflineModal } from "./OfflineModal.js";
|
||||
import { OutdatedSchemaModal } from "./OutdatedSchemaModal.js";
|
||||
import { PrestigePanel } from "./PrestigePanel.js";
|
||||
import { ApotheosisPanel } from "./ApotheosisPanel.js";
|
||||
import { TranscendencePanel } from "./TranscendencePanel.js";
|
||||
@@ -52,9 +53,10 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
];
|
||||
|
||||
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 [editingProfile, setEditingProfile] = useState(false);
|
||||
const [dismissedOutdatedWarning, setDismissedOutdatedWarning] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -91,6 +93,9 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
onForceSync={forceSync}
|
||||
/>
|
||||
<OfflineModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning && (
|
||||
<OutdatedSchemaModal onDismiss={() => { setDismissedOutdatedWarning(true); }} />
|
||||
)}
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
<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,
|
||||
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<void>;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(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<void>>(() => Promise.resolve());
|
||||
const [schemaOutdated, setSchemaOutdated] = useState(false);
|
||||
const [saveSchemaVersion, setSaveSchemaVersion] = useState(0);
|
||||
const [currentSchemaVersion, setCurrentSchemaVersion] = useState(0);
|
||||
const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]);
|
||||
const codexProcessedRef = useRef<Set<string>>(new Set());
|
||||
const [newStoryChapterIds, setNewStoryChapterIds] = useState<string[]>([]);
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user