diff --git a/Extensions/SaveState/JsExtension.js b/Extensions/SaveState/JsExtension.js index 1794ad424b2e..16d826533e86 100644 --- a/Extensions/SaveState/JsExtension.js +++ b/Extensions/SaveState/JsExtension.js @@ -20,7 +20,7 @@ module.exports = { extension .setExtensionInformation( 'SaveState', - _('Save State (experimental)'), + _('Save State'), _( 'Allows to save and load the full state of a game, usually on the device storage. A Save State, by default, contains the full state of the game (objects, variables, sounds, music, effects etc.). Using the "Save Configuration" behavior, you can customize which objects should not be saved in a Save State. You can also use the "Change the save configuration of a variable" action to change the save configuration of a variable. Finally, both objects, variables and scene/game data can be given a profile name: in this case, when saving or loading with one or more profile names specified, only the object/variables/data belonging to one of the specified profiles will be saved or loaded.' ), @@ -32,7 +32,7 @@ module.exports = { ) .setExtensionHelpPath('/all-features/save-state') .setCategory('Game mechanic') - .addInstructionOrExpressionGroupMetadata(_('Save State (experimental)')) + .addInstructionOrExpressionGroupMetadata(_('Save State')) .setIcon('res/actions/saveDown.svg'); extension @@ -61,7 +61,8 @@ module.exports = { .addIncludeFile( 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' ) - .setFunctionName('gdjs.saveState.createGameSaveStateInVariable'); + .setFunctionName('gdjs.saveState.createGameSaveStateInVariable') + .setAsyncFunctionName('gdjs.saveState.createGameSaveStateInVariable'); extension .addAction( @@ -82,13 +83,13 @@ module.exports = { 'Comma-separated list of profile names that must be saved. Only objects tagged with at least one of these profiles will be saved. If no profile names are specified, all objects will be saved (unless they have a "Save Configuration" behavior set to "Do not save").' ) ) - .setDefaultValue('no') .getCodeExtraInformation() .setIncludeFile('Extensions/SaveState/SaveStateTools.js') .addIncludeFile( 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' ) - .setFunctionName('gdjs.saveState.createGameSaveStateInStorage'); + .setFunctionName('gdjs.saveState.createGameSaveStateInStorage') + .setAsyncFunctionName('gdjs.saveState.createGameSaveStateInStorage'); extension .addAction( @@ -125,7 +126,8 @@ module.exports = { .addIncludeFile( 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' ) - .setFunctionName('gdjs.saveState.restoreGameSaveStateFromVariable'); + .setFunctionName('gdjs.saveState.restoreGameSaveStateFromVariable') + .setAsyncFunctionName('gdjs.saveState.restoreGameSaveStateFromVariable'); extension .addAction( @@ -165,7 +167,270 @@ module.exports = { .addIncludeFile( 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' ) - .setFunctionName('gdjs.saveState.restoreGameSaveStateFromStorage'); + .setFunctionName('gdjs.saveState.restoreGameSaveStateFromStorage') + .setAsyncFunctionName('gdjs.saveState.restoreGameSaveStateFromStorage'); + + extension + .addAction( + 'DeleteSaveFromStorage', + _('Delete a save from device storage'), + _('Delete a save stored on the device storage.'), + _('Delete save from device storage named _PARAM1_'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter( + 'string', + _('Storage name of the save to delete'), + '', + false + ) + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.deleteSaveFromStorage') + .setAsyncFunctionName('gdjs.saveState.deleteSaveFromStorage'); + + extension + .addAction( + 'DuplicateSaveInStorage', + _('Duplicate a save in device storage'), + _('Duplicate a save stored on the device storage to another name.'), + _('Duplicate save _PARAM1_ to _PARAM2_ in device storage'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter( + 'string', + _('Storage name of the save to duplicate'), + '', + false + ) + .addParameter('string', _('Storage name of the new save'), '', false) + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.duplicateSaveInStorage') + .setAsyncFunctionName('gdjs.saveState.duplicateSaveInStorage'); + + extension + .addAction( + 'CheckSaveExistsInStorage', + _('Check if a save exists in device storage'), + _( + 'Check if a save with the given name exists in the device storage, and store the result (yes/no) in a variable. The check is asynchronous: use the condition "Save existence check completed" to know when the result is available.' + ), + _( + 'Check if save _PARAM1_ exists in device storage (result in _PARAM2_)' + ), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter('string', _('Storage name of the save to check'), '', false) + .addParameter( + 'variable', + _('Variable where to store the result (yes/no)'), + '', + false + ) + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.checkSaveExistsInStorage') + .setAsyncFunctionName('gdjs.saveState.checkSaveExistsInStorage'); + + extension + .addAction( + 'ListSavesInVariable', + _('List existing saves'), + _( + 'List the saves stored on the device storage and store them in a variable. The check is asynchronous: use the condition "Saves listing completed" to know when the result is available.' + ), + _('List existing saves in variable _PARAM1_'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter( + 'variable', + _('Variable where to store the list of saves'), + '', + false + ) + .setParameterLongDescription( + _( + 'The variable will contain an array of structures, one per save, sorted from the most recently updated to the oldest. Each structure has the children: "name" (the save name), "savedAt" and "updatedAt" (timestamps in milliseconds since 1970, or 0 if unknown for older saves).' + ) + ) + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.listSavesInVariable') + .setAsyncFunctionName('gdjs.saveState.listSavesInVariable'); + + extension + .addCondition( + 'DeleteJustSucceeded', + _('Delete just succeeded'), + _('The last delete attempt just succeeded.'), + _('Delete just succeeded'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasDeleteJustSucceeded'); + + extension + .addCondition( + 'DeleteJustFailed', + _('Delete just failed'), + _('The last delete attempt just failed.'), + _('Delete just failed'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasDeleteJustFailed'); + + extension + .addCondition( + 'DuplicateJustSucceeded', + _('Duplicate just succeeded'), + _('The last duplicate attempt just succeeded.'), + _('Duplicate just succeeded'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasDuplicateJustSucceeded'); + + extension + .addCondition( + 'DuplicateJustFailed', + _('Duplicate just failed'), + _('The last duplicate attempt just failed.'), + _('Duplicate just failed'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasDuplicateJustFailed'); + + extension + .addCondition( + 'CheckJustCompleted', + _('Save existence check completed'), + _( + 'The last "Check if a save exists" action just completed (its result is now available).' + ), + _('Save existence check completed'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasCheckJustCompleted'); + + extension + .addCondition( + 'CheckedSaveExists', + _('Checked save exists'), + _( + 'The save checked by the last completed "Check if a save exists" action does exist.' + ), + _('Checked save exists'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.doesCheckedSaveExist'); + + extension + .addCondition( + 'ListJustCompleted', + _('Saves listing completed'), + _( + 'The last "List existing saves" action just completed (the variable now contains the list).' + ), + _('Saves listing completed'), + _('Manage saves'), + 'res/actions/saveDown.svg', + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.hasListJustCompleted'); + + extension + .addStrExpression( + 'LastCheckedSaveName', + _('Last checked save name'), + _( + 'The name of the save used in the last "Check if a save exists" action.' + ), + _('Manage saves'), + 'res/actions/saveDown.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .setIncludeFile('Extensions/SaveState/SaveStateTools.js') + .addIncludeFile( + 'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js' + ) + .setFunctionName('gdjs.saveState.getLastCheckedSaveName'); extension .addExpressionAndCondition( diff --git a/Extensions/SaveState/SaveStateTools.ts b/Extensions/SaveState/SaveStateTools.ts index 93eb6c354b1c..0d423aedcdb1 100644 --- a/Extensions/SaveState/SaveStateTools.ts +++ b/Extensions/SaveState/SaveStateTools.ts @@ -18,6 +18,10 @@ namespace gdjs { fromStorageName?: string; fromVariable?: gdjs.Variable; + + // Called once the (deferred) restore has finished, so an awaitable load + // action can resolve its task. No-op for the fire-and-forget usage. + resolveTask?: () => void; }; /** @category Behaviors > Save State */ @@ -33,6 +37,69 @@ namespace gdjs { return `save-${key}`; }; + /** + * The format version a save is written with. Bump this (and add a migration + * step below) whenever the stored format changes in a non-backward-compatible + * way - this includes the shape of the inner `gameSaveState` (the network sync + * data), not just the envelope, as that's what most often evolves over time. + */ + export const CURRENT_SAVE_FORMAT_VERSION = 1; + + /** + * Migration steps from one format version to the next. `migrations[v]` migrates + * a save from version `v` to version `v + 1`. Saves written before the + * `StoredSave` envelope existed (raw `GameSaveState`, no `formatVersion`) are + * treated as version 0. + */ + const migrations: Array<(save: any, name: string) => any> = [ + // 0 -> 1: wrap a legacy raw GameSaveState in the metadata envelope. + // Timestamps are unknown for these old saves, so they default to 0. + (raw, name) => ({ + formatVersion: 1, + metadata: { name, savedAt: 0, updatedAt: 0 }, + gameSaveState: raw, + }), + ]; + + /** + * Normalize a record read from storage into an up-to-date `GameSaveState` and + * its metadata, applying any needed format migrations. Handles both the current + * `StoredSave` envelope and legacy raw `GameSaveState` records. + */ + const migrateStoredSave = ( + raw: any, + name: string + ): { + gameSaveState: GameSaveState; + metadata: SaveStateMetadata; + } | null => { + if (!raw) return null; + + // No `formatVersion` means a legacy raw GameSaveState, i.e. version 0. + let version = + typeof raw.formatVersion === 'number' ? raw.formatVersion : 0; + + if (version > CURRENT_SAVE_FORMAT_VERSION) { + // The save was written by a newer version of the game than this one. + // We can't know how to read it for sure, but the inner data is often + // forward-compatible, so we still try as a best effort. + logger.warn( + `Save "${name}" has format version ${version}, but this game supports up to ${CURRENT_SAVE_FORMAT_VERSION}. It may not load correctly.` + ); + } + + let migrated = raw; + while (version < CURRENT_SAVE_FORMAT_VERSION) { + migrated = migrations[version](migrated, name); + version++; + } + + return { + gameSaveState: migrated.gameSaveState, + metadata: migrated.metadata || { name, savedAt: 0, updatedAt: 0 }, + }; + }; + const variablesSaveConfiguration: WeakMap< Variable, ArbitrarySaveConfiguration @@ -138,6 +205,14 @@ namespace gdjs { let saveJustFailed: boolean = false; let loadJustSucceeded: boolean = false; let loadJustFailed: boolean = false; + let deleteJustSucceeded: boolean = false; + let deleteJustFailed: boolean = false; + let duplicateJustSucceeded: boolean = false; + let duplicateJustFailed: boolean = false; + let checkJustCompleted: boolean = false; + let listJustCompleted: boolean = false; + let lastCheckedSaveExists: boolean = false; + let lastCheckedSaveName: string = ''; let restoreRequestOptions: RestoreRequestOptions | null = null; @@ -175,6 +250,30 @@ namespace gdjs { export const markLoadJustFailed = (_: RuntimeScene) => { loadJustFailed = true; }; + export const hasDeleteJustSucceeded = (_: RuntimeScene) => { + return deleteJustSucceeded; + }; + export const hasDeleteJustFailed = (_: RuntimeScene) => { + return deleteJustFailed; + }; + export const hasDuplicateJustSucceeded = (_: RuntimeScene) => { + return duplicateJustSucceeded; + }; + export const hasDuplicateJustFailed = (_: RuntimeScene) => { + return duplicateJustFailed; + }; + export const hasCheckJustCompleted = (_: RuntimeScene) => { + return checkJustCompleted; + }; + export const doesCheckedSaveExist = (_: RuntimeScene) => { + return lastCheckedSaveExists; + }; + export const getLastCheckedSaveName = (_: RuntimeScene) => { + return lastCheckedSaveName; + }; + export const hasListJustCompleted = (_: RuntimeScene) => { + return listJustCompleted; + }; // Ensure that the condition "save/load just succeeded/failed" are valid only for one frame. gdjs.registerRuntimeScenePostEventsCallback(() => { @@ -182,6 +281,12 @@ namespace gdjs { saveJustFailed = false; loadJustSucceeded = false; loadJustFailed = false; + deleteJustSucceeded = false; + deleteJustFailed = false; + duplicateJustSucceeded = false; + duplicateJustFailed = false; + checkJustCompleted = false; + listJustCompleted = false; }); gdjs.registerRuntimeScenePostEventsCallback( @@ -304,11 +409,13 @@ namespace gdjs { return gameSaveState; }; - export const createGameSaveStateInVariable = async function ( + // Returns a gdjs.AsyncTask so the action can be awaited in the events sheet. + // When not awaited, the returned task is simply ignored (fire-and-forget). + export const createGameSaveStateInVariable = function ( runtimeScene: RuntimeScene, variable: gdjs.Variable, commaSeparatedProfileNames: string - ) { + ): gdjs.AsyncTask { try { const gameSaveState = createGameSaveState(runtimeScene.getGame(), { profileNames: parseCommaSeparatedProfileNamesOrDefault( @@ -321,30 +428,41 @@ namespace gdjs { logger.error('Error saving to variable:', error); markSaveJustFailed(runtimeScene); } + // The work is synchronous, so the task is already resolved. + return new gdjs.ResolveTask(); }; - export const createGameSaveStateInStorage = async function ( + export const createGameSaveStateInStorage = function ( runtimeScene: RuntimeScene, storageKey: string, commaSeparatedProfileNames: string - ) { - try { - const gameSaveState = createGameSaveState(runtimeScene.getGame(), { - profileNames: parseCommaSeparatedProfileNamesOrDefault( - commaSeparatedProfileNames - ), - }); - await gdjs.indexedDb.saveToIndexedDB( - getIndexedDbDatabaseName(), - getIndexedDbObjectStore(), - getIndexedDbStorageKey(storageKey), - gameSaveState - ); - markSaveJustSucceeded(runtimeScene); - } catch (error) { - logger.error('Error saving to IndexedDB:', error); - markSaveJustFailed(runtimeScene); - } + ): gdjs.PromiseTask { + const promise = (async () => { + try { + const gameSaveState = createGameSaveState(runtimeScene.getGame(), { + profileNames: parseCommaSeparatedProfileNamesOrDefault( + commaSeparatedProfileNames + ), + }); + const now = Date.now(); + const storedSave: StoredSave = { + formatVersion: CURRENT_SAVE_FORMAT_VERSION, + metadata: { name: storageKey, savedAt: now, updatedAt: now }, + gameSaveState, + }; + await gdjs.indexedDb.saveToIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore(), + getIndexedDbStorageKey(storageKey), + storedSave + ); + markSaveJustSucceeded(runtimeScene); + } catch (error) { + logger.error('Error saving to IndexedDB:', error); + markSaveJustFailed(runtimeScene); + } + })(); + return new gdjs.PromiseTask(promise); }; const checkAndRestoreGameSaveStateAtEndOfFrame = function ( @@ -353,8 +471,13 @@ namespace gdjs { const runtimeGame = runtimeScene.getGame(); if (!restoreRequestOptions) return; - const { fromVariable, fromStorageName, profileNames, clearSceneStack } = - restoreRequestOptions; + const { + fromVariable, + fromStorageName, + profileNames, + clearSceneStack, + resolveTask, + } = restoreRequestOptions; // Reset it so we don't load it twice. restoreRequestOptions = null; @@ -372,6 +495,8 @@ namespace gdjs { logger.error('Error loading from variable:', error); markLoadJustFailed(runtimeScene); } + // Resolve the awaitable task (no-op if the action was not awaited). + if (resolveTask) resolveTask(); } else if (fromStorageName) { gdjs.indexedDb .loadFromIndexedDB( @@ -380,8 +505,13 @@ namespace gdjs { getIndexedDbStorageKey(fromStorageName) ) .then((jsonData) => { - const saveState = jsonData as GameSaveState; - restoreGameSaveState(runtimeGame, saveState, { + const migrated = migrateStoredSave(jsonData, fromStorageName); + if (!migrated) { + throw new Error( + `No save state found in storage named "${fromStorageName}".` + ); + } + restoreGameSaveState(runtimeGame, migrated.gameSaveState, { profileNames, clearSceneStack, }); @@ -390,7 +520,15 @@ namespace gdjs { .catch((error) => { logger.error('Error loading from IndexedDB:', error); markLoadJustFailed(runtimeScene); + }) + .then(() => { + // Resolve the awaitable task once the restore finished, whether it + // succeeded or failed (no-op if the action was not awaited). + if (resolveTask) resolveTask(); }); + } else if (resolveTask) { + // Nothing to restore, but still resolve the task if any. + resolveTask(); } }; @@ -608,40 +746,178 @@ namespace gdjs { ); }; - export const restoreGameSaveStateFromVariable = async function ( + export const restoreGameSaveStateFromVariable = function ( _: gdjs.RuntimeScene, variable: gdjs.Variable, commaSeparatedProfileNames: string, clearSceneStack: boolean - ) { + ): gdjs.AsyncTask { // The information is saved, so that the restore can be done - // at the end of the frame, - // and avoid possible conflicts with running events. + // at the end of the frame, and avoid possible conflicts with running + // events. The task is resolved once that deferred restore has run. + const task = new gdjs.ManuallyResolvableTask(); restoreRequestOptions = { fromVariable: variable, profileNames: parseCommaSeparatedProfileNamesOrDefault( commaSeparatedProfileNames ), clearSceneStack, + resolveTask: () => task.resolve(), }; + return task; }; - export const restoreGameSaveStateFromStorage = async function ( + export const restoreGameSaveStateFromStorage = function ( _: gdjs.RuntimeScene, storageName: string, commaSeparatedProfileNames: string, clearSceneStack: boolean - ) { + ): gdjs.AsyncTask { // The information is saved, so that the restore can be done - // at the end of the frame, - // and avoid possible conflicts with running events. + // at the end of the frame, and avoid possible conflicts with running + // events. The task is resolved once that deferred restore has run. + const task = new gdjs.ManuallyResolvableTask(); restoreRequestOptions = { fromStorageName: storageName, profileNames: parseCommaSeparatedProfileNamesOrDefault( commaSeparatedProfileNames ), clearSceneStack, + resolveTask: () => task.resolve(), }; + return task; + }; + + export const deleteSaveFromStorage = function ( + runtimeScene: RuntimeScene, + storageKey: string + ): gdjs.PromiseTask { + const promise = (async () => { + try { + await gdjs.indexedDb.deleteFromIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore(), + getIndexedDbStorageKey(storageKey) + ); + deleteJustSucceeded = true; + } catch (error) { + logger.error('Error deleting save from IndexedDB:', error); + deleteJustFailed = true; + } + })(); + return new gdjs.PromiseTask(promise); + }; + + export const duplicateSaveInStorage = function ( + runtimeScene: RuntimeScene, + sourceStorageKey: string, + destinationStorageKey: string + ): gdjs.PromiseTask { + const promise = (async () => { + try { + const raw = await gdjs.indexedDb.loadFromIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore(), + getIndexedDbStorageKey(sourceStorageKey) + ); + const migrated = migrateStoredSave(raw, sourceStorageKey); + if (!migrated) { + logger.error( + `Cannot duplicate save: no save found named "${sourceStorageKey}".` + ); + duplicateJustFailed = true; + return; + } + const now = Date.now(); + const storedSave: StoredSave = { + formatVersion: CURRENT_SAVE_FORMAT_VERSION, + metadata: { + name: destinationStorageKey, + savedAt: migrated.metadata.savedAt || now, + updatedAt: now, + }, + gameSaveState: migrated.gameSaveState, + }; + await gdjs.indexedDb.saveToIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore(), + getIndexedDbStorageKey(destinationStorageKey), + storedSave + ); + duplicateJustSucceeded = true; + } catch (error) { + logger.error('Error duplicating save in IndexedDB:', error); + duplicateJustFailed = true; + } + })(); + return new gdjs.PromiseTask(promise); + }; + + export const checkSaveExistsInStorage = function ( + _: RuntimeScene, + storageKey: string, + resultVariable: gdjs.Variable + ): gdjs.PromiseTask { + const promise = (async () => { + try { + const exists = await gdjs.indexedDb.keyExistsInIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore(), + getIndexedDbStorageKey(storageKey) + ); + lastCheckedSaveExists = exists; + lastCheckedSaveName = storageKey; + checkJustCompleted = true; + if (resultVariable) resultVariable.setBoolean(exists); + } catch (error) { + logger.error('Error checking save existence in IndexedDB:', error); + lastCheckedSaveExists = false; + lastCheckedSaveName = storageKey; + checkJustCompleted = true; + if (resultVariable) resultVariable.setBoolean(false); + } + })(); + return new gdjs.PromiseTask(promise); + }; + + export const listSavesInVariable = function ( + _: RuntimeScene, + resultVariable: gdjs.Variable + ): gdjs.PromiseTask { + const promise = (async () => { + try { + const all = await gdjs.indexedDb.getAllFromIndexedDB( + getIndexedDbDatabaseName(), + getIndexedDbObjectStore() + ); + const prefix = getIndexedDbStorageKey(''); + const list = all + .filter( + ({ key }) => + typeof key === 'string' && (key as string).indexOf(prefix) === 0 + ) + .map(({ key, value }) => { + const name = (key as string).substring(prefix.length); + // Skip corrupt/unreadable records so one bad save doesn't hide all + // the others. + const migrated = migrateStoredSave(value, name); + if (!migrated) return null; + return { + name, + savedAt: migrated.metadata.savedAt || 0, + updatedAt: migrated.metadata.updatedAt || 0, + }; + }) + .filter(Boolean) + .sort((a, b) => b!.updatedAt - a!.updatedAt); + resultVariable.fromJSObject(list); + listJustCompleted = true; + } catch (error) { + logger.error('Error listing saves from IndexedDB:', error); + listJustCompleted = true; + } + })(); + return new gdjs.PromiseTask(promise); }; /** diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 55e4fd012dac..5cb2f5b9f935 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1560,4 +1560,360 @@ describe('SaveState', () => { expect(notSaved1Links).not.to.contain(saved1); }); }); + + describe('Save State management (list/delete/check/duplicate/metadata)', () => { + const dbName = () => gdjs.saveState.getIndexedDbDatabaseName(); + const storeName = () => gdjs.saveState.getIndexedDbObjectStore(); + const storageKey = (key) => gdjs.saveState.getIndexedDbStorageKey(key); + + let previousProjectData; + before(() => { + previousProjectData = gdjs.projectData; + // The IndexedDB database name is derived from the project UUID. + gdjs.projectData = /** @type {any} */ ({ + properties: { projectUuid: 'savestate-management-test' }, + }); + }); + after(() => { + gdjs.projectData = previousProjectData; + }); + + const clearAllSaves = async () => { + const keys = await gdjs.indexedDb.getAllKeysFromIndexedDB( + dbName(), + storeName() + ); + for (const key of keys) { + await gdjs.indexedDb.deleteFromIndexedDB( + dbName(), + storeName(), + /** @type {string} */ (key) + ); + } + }; + beforeEach(clearAllSaves); + afterEach(clearAllSaves); + + const makeGameAndScene = async () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + layouts: [getFakeSceneData({ name: 'Scene1' })], + }); + await runtimeGame._resourcesLoader.loadAllResources(() => {}); + const runtimeScene = runtimeGame + .getSceneStack() + .push({ sceneName: 'Scene1' }); + if (!runtimeScene) throw new Error('No current scene was created.'); + return { runtimeGame, runtimeScene }; + }; + + const waitUntil = async (predicate, timeoutMs = 2000) => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out while waiting for condition.'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + + // The async actions return a gdjs.AsyncTask. The IndexedDB-backed ones + // (PromiseTask) expose the underlying promise: await it so assertions run + // after the operation actually completed. (Load actions resolve at end of + // frame and are awaited via renderAndStep + waitUntil instead.) + const completed = (task) => + task && task.promise ? task.promise : Promise.resolve(); + + it('saves to storage with metadata and lists it (no save- prefix)', async () => { + const { runtimeScene } = await makeGameAndScene(); + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'slot1', + 'default' + ) + ); + + const listVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.listSavesInVariable(runtimeScene, listVariable) + ); + expect(gdjs.saveState.hasListJustCompleted(runtimeScene)).to.be(true); + + const list = listVariable.toJSObject(); + expect(list.length).to.be(1); + expect(list[0].name).to.be('slot1'); + expect(list[0].savedAt).to.be.greaterThan(0); + expect(list[0].updatedAt).to.be.greaterThan(0); + }); + + it('lists a legacy raw save (no metadata) with savedAt 0', async () => { + const { runtimeGame, runtimeScene } = await makeGameAndScene(); + const rawSaveState = gdjs.saveState.createGameSaveState(runtimeGame, { + profileNames: ['default'], + }); + // Store as the legacy raw GameSaveState format (no envelope). + await gdjs.indexedDb.saveToIndexedDB( + dbName(), + storeName(), + storageKey('legacy'), + rawSaveState + ); + + const listVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.listSavesInVariable(runtimeScene, listVariable) + ); + const list = listVariable.toJSObject(); + expect(list.length).to.be(1); + expect(list[0].name).to.be('legacy'); + expect(list[0].savedAt).to.be(0); + expect(list[0].updatedAt).to.be(0); + }); + + it('restores a legacy raw save from storage', async () => { + const { runtimeGame, runtimeScene } = await makeGameAndScene(); + const object = runtimeScene.createObject('MySpriteObject'); + if (!object) throw new Error('Object was not created.'); + object.setX(123); + object.setY(456); + + const rawSaveState = gdjs.saveState.createGameSaveState(runtimeGame, { + profileNames: ['default'], + }); + await gdjs.indexedDb.saveToIndexedDB( + dbName(), + storeName(), + storageKey('legacy'), + rawSaveState + ); + + // Move the object, then restore the legacy save from storage. + object.setX(0); + object.setY(0); + gdjs.saveState.restoreGameSaveStateFromStorage( + runtimeScene, + 'legacy', + 'default', + false + ); + // Run a frame to consume the restore request (which then loads asynchronously). + runtimeScene.renderAndStep(1000 / 60); + await waitUntil(() => { + const objects = runtimeScene.getObjects('MySpriteObject'); + return objects.length === 1 && objects[0].getX() === 123; + }); + + const objects = runtimeScene.getObjects('MySpriteObject'); + expect(objects.length).to.be(1); + expect(objects[0].getX()).to.be(123); + expect(objects[0].getY()).to.be(456); + }); + + it('restores (best effort) a save from a newer/unknown format version', async () => { + const { runtimeGame, runtimeScene } = await makeGameAndScene(); + const object = runtimeScene.createObject('MySpriteObject'); + if (!object) throw new Error('Object was not created.'); + object.setX(789); + object.setY(987); + + const gameSaveState = gdjs.saveState.createGameSaveState(runtimeGame, { + profileNames: ['default'], + }); + // Store an envelope with a format version far newer than this engine knows. + await gdjs.indexedDb.saveToIndexedDB( + dbName(), + storeName(), + storageKey('future'), + { + formatVersion: gdjs.saveState.CURRENT_SAVE_FORMAT_VERSION + 999, + metadata: { name: 'future', savedAt: 1, updatedAt: 1 }, + gameSaveState, + } + ); + + // It should still be loaded as a best effort (with a warning logged). + object.setX(0); + object.setY(0); + gdjs.saveState.restoreGameSaveStateFromStorage( + runtimeScene, + 'future', + 'default', + false + ); + runtimeScene.renderAndStep(1000 / 60); + await waitUntil(() => { + const objects = runtimeScene.getObjects('MySpriteObject'); + return objects.length === 1 && objects[0].getX() === 789; + }); + + const objects = runtimeScene.getObjects('MySpriteObject'); + expect(objects.length).to.be(1); + expect(objects[0].getX()).to.be(789); + expect(objects[0].getY()).to.be(987); + }); + + it('checks whether a save exists', async () => { + const { runtimeScene } = await makeGameAndScene(); + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'present', + 'default' + ) + ); + + const existsVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.checkSaveExistsInStorage( + runtimeScene, + 'present', + existsVariable + ) + ); + expect(existsVariable.getAsBoolean()).to.be(true); + expect(gdjs.saveState.hasCheckJustCompleted(runtimeScene)).to.be(true); + expect(gdjs.saveState.doesCheckedSaveExist(runtimeScene)).to.be(true); + expect(gdjs.saveState.getLastCheckedSaveName(runtimeScene)).to.be( + 'present' + ); + + const missingVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.checkSaveExistsInStorage( + runtimeScene, + 'missing', + missingVariable + ) + ); + expect(missingVariable.getAsBoolean()).to.be(false); + expect(gdjs.saveState.doesCheckedSaveExist(runtimeScene)).to.be(false); + }); + + it('deletes a save and resets the flag after a frame', async () => { + const { runtimeScene } = await makeGameAndScene(); + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'toDelete', + 'default' + ) + ); + + await completed( + gdjs.saveState.deleteSaveFromStorage(runtimeScene, 'toDelete') + ); + expect(gdjs.saveState.hasDeleteJustSucceeded(runtimeScene)).to.be(true); + + const existsVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.checkSaveExistsInStorage( + runtimeScene, + 'toDelete', + existsVariable + ) + ); + expect(existsVariable.getAsBoolean()).to.be(false); + + // The "delete just succeeded" flag is reset after a frame. + runtimeScene.renderAndStep(1000 / 60); + expect(gdjs.saveState.hasDeleteJustSucceeded(runtimeScene)).to.be(false); + }); + + it('duplicates a save under a new name', async () => { + const { runtimeScene } = await makeGameAndScene(); + const object = runtimeScene.createObject('MySpriteObject'); + if (!object) throw new Error('Object was not created.'); + object.setX(11); + object.setY(22); + + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'slotA', + 'default' + ) + ); + await completed( + gdjs.saveState.duplicateSaveInStorage(runtimeScene, 'slotA', 'slotB') + ); + expect(gdjs.saveState.hasDuplicateJustSucceeded(runtimeScene)).to.be( + true + ); + + const listVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.listSavesInVariable(runtimeScene, listVariable) + ); + const names = listVariable + .toJSObject() + .map((save) => save.name) + .sort(); + expect(names).to.eql(['slotA', 'slotB']); + + const sourceSave = await gdjs.indexedDb.loadFromIndexedDB( + dbName(), + storeName(), + storageKey('slotA') + ); + const duplicatedSave = await gdjs.indexedDb.loadFromIndexedDB( + dbName(), + storeName(), + storageKey('slotB') + ); + expect(duplicatedSave.metadata.name).to.be('slotB'); + expect(duplicatedSave.gameSaveState).to.eql(sourceSave.gameSaveState); + }); + + it('fails to duplicate a save that does not exist', async () => { + const { runtimeScene } = await makeGameAndScene(); + await completed( + gdjs.saveState.duplicateSaveInStorage( + runtimeScene, + 'doesNotExist', + 'destination' + ) + ); + expect(gdjs.saveState.hasDuplicateJustFailed(runtimeScene)).to.be(true); + + const existsVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.checkSaveExistsInStorage( + runtimeScene, + 'destination', + existsVariable + ) + ); + expect(existsVariable.getAsBoolean()).to.be(false); + }); + + it('lists saves sorted from most recently updated to oldest', async () => { + const { runtimeScene } = await makeGameAndScene(); + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'first', + 'default' + ) + ); + // Ensure a different timestamp for the second save. + await new Promise((resolve) => setTimeout(resolve, 5)); + await completed( + gdjs.saveState.createGameSaveStateInStorage( + runtimeScene, + 'second', + 'default' + ) + ); + + const listVariable = new gdjs.Variable(); + await completed( + gdjs.saveState.listSavesInVariable(runtimeScene, listVariable) + ); + const list = listVariable.toJSObject(); + expect(list.map((save) => save.name)).to.eql(['second', 'first']); + list.forEach((save) => { + expect(save.name.indexOf('save-')).to.be(-1); + }); + }); + }); }); diff --git a/GDJS/Runtime/indexeddb.ts b/GDJS/Runtime/indexeddb.ts index 9a3b568c509d..89cfafb1f467 100644 --- a/GDJS/Runtime/indexeddb.ts +++ b/GDJS/Runtime/indexeddb.ts @@ -104,5 +104,187 @@ namespace gdjs { } }); }; + + export const deleteFromIndexedDB = async function ( + dbName: string, + objectStoreName: string, + key: string + ): Promise { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = function () { + const db = request.result; + if (!db.objectStoreNames.contains(objectStoreName)) { + db.createObjectStore(objectStoreName); + } + }; + request.onsuccess = function () { + const db = request.result; + const tx = db.transaction(objectStoreName, 'readwrite'); + const store = tx.objectStore(objectStoreName); + const deleteRequest = store.delete(key); + + deleteRequest.onsuccess = function () { + resolve(); + }; + + deleteRequest.onerror = function () { + console.error( + 'Error deleting data from IndexedDB:', + deleteRequest.error + ); + reject(deleteRequest.error); + }; + }; + + request.onerror = function () { + console.error('Error opening IndexedDB:', request.error); + reject(request.error); + }; + } catch (err) { + console.error('Exception thrown while opening IndexedDB:', err); + reject(err); + return; + } + }); + }; + + export const keyExistsInIndexedDB = async function ( + dbName: string, + objectStoreName: string, + key: string + ): Promise { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = function () { + const db = request.result; + if (!db.objectStoreNames.contains(objectStoreName)) { + db.createObjectStore(objectStoreName); + } + }; + request.onsuccess = function () { + const db = request.result; + const tx = db.transaction(objectStoreName, 'readonly'); + const store = tx.objectStore(objectStoreName); + // Only look up the single key instead of materializing every key. + const countRequest = store.count(key); + + countRequest.onsuccess = function () { + resolve(countRequest.result > 0); + }; + + countRequest.onerror = function () { + console.error( + 'Error checking key existence in IndexedDB:', + countRequest.error + ); + reject(countRequest.error); + }; + }; + + request.onerror = function () { + console.error('Error opening IndexedDB:', request.error); + reject(request.error); + }; + } catch (err) { + console.error('Exception thrown while opening IndexedDB:', err); + reject(err); + return; + } + }); + }; + + export const getAllKeysFromIndexedDB = async function ( + dbName: string, + objectStoreName: string + ): Promise { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = function () { + const db = request.result; + if (!db.objectStoreNames.contains(objectStoreName)) { + db.createObjectStore(objectStoreName); + } + }; + request.onsuccess = function () { + const db = request.result; + const tx = db.transaction(objectStoreName, 'readonly'); + const store = tx.objectStore(objectStoreName); + const getKeysRequest = store.getAllKeys(); + + getKeysRequest.onsuccess = function () { + resolve(getKeysRequest.result || []); + }; + + getKeysRequest.onerror = function () { + console.error( + 'Error listing keys from IndexedDB:', + getKeysRequest.error + ); + reject(getKeysRequest.error); + }; + }; + + request.onerror = function () { + console.error('Error opening IndexedDB:', request.error); + reject(request.error); + }; + } catch (err) { + console.error('Exception thrown while opening IndexedDB:', err); + reject(err); + return; + } + }); + }; + + export const getAllFromIndexedDB = async function ( + dbName: string, + objectStoreName: string + ): Promise<{ key: IDBValidKey; value: any }[]> { + return new Promise((resolve, reject) => { + try { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = function () { + const db = request.result; + if (!db.objectStoreNames.contains(objectStoreName)) { + db.createObjectStore(objectStoreName); + } + }; + request.onsuccess = function () { + const db = request.result; + const tx = db.transaction(objectStoreName, 'readonly'); + const store = tx.objectStore(objectStoreName); + // Read keys and values in the same transaction so they stay aligned. + const getKeysRequest = store.getAllKeys(); + const getValuesRequest = store.getAll(); + + tx.oncomplete = function () { + const keys = getKeysRequest.result || []; + const values = getValuesRequest.result || []; + resolve( + keys.map((key, index) => ({ key, value: values[index] })) + ); + }; + + tx.onerror = function () { + console.error('Error reading all data from IndexedDB:', tx.error); + reject(tx.error); + }; + }; + + request.onerror = function () { + console.error('Error opening IndexedDB:', request.error); + reject(request.error); + }; + } catch (err) { + console.error('Exception thrown while opening IndexedDB:', err); + reject(err); + return; + } + }); + }; } } diff --git a/GDJS/Runtime/types/save-state.d.ts b/GDJS/Runtime/types/save-state.d.ts index b8dd873c9de4..89ce1d4f34a8 100644 --- a/GDJS/Runtime/types/save-state.d.ts +++ b/GDJS/Runtime/types/save-state.d.ts @@ -7,3 +7,22 @@ declare type GameSaveState = { gameNetworkSyncData: GameNetworkSyncData | null; layoutNetworkSyncDatas: SceneSaveState[]; }; + +declare type SaveStateMetadata = { + name: string; + /** Timestamp (ms since epoch) of the first time the save was created. 0 if unknown (legacy saves). */ + savedAt: number; + /** Timestamp (ms since epoch) of the last time the save was written. 0 if unknown (legacy saves). */ + updatedAt: number; +}; + +/** + * The versioned envelope actually stored in device storage. Wraps a + * `GameSaveState` with metadata. Saves created before this format existed are + * stored as a raw `GameSaveState` (no `formatVersion`) and are still readable. + */ +declare type StoredSave = { + formatVersion: number; + metadata: SaveStateMetadata; + gameSaveState: GameSaveState; +}; diff --git a/GDJS/tests/karma.conf.js b/GDJS/tests/karma.conf.js index 2387442c6221..aa2067451776 100644 --- a/GDJS/tests/karma.conf.js +++ b/GDJS/tests/karma.conf.js @@ -104,6 +104,7 @@ module.exports = function (config) { './newIDE/app/resources/GDJS/Runtime/events-tools/cameratools.js', './newIDE/app/resources/GDJS/Runtime/events-tools/soundtools.js', './newIDE/app/resources/GDJS/Runtime/events-tools/storagetools.js', + './newIDE/app/resources/GDJS/Runtime/indexeddb.js', './newIDE/app/resources/GDJS/Runtime/events-tools/stringtools.js', './newIDE/app/resources/GDJS/Runtime/events-tools/windowtools.js', './newIDE/app/resources/GDJS/Runtime/debugger-client/hot-reloader.js',