diff --git a/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.cpp b/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.cpp index dc5417285a30..c078db7459e0 100644 --- a/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.cpp +++ b/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.cpp @@ -5,7 +5,9 @@ */ #include "EventsBasedObjectDependencyFinder.h" +#include "GDCore/Extensions/PlatformExtension.h" #include "GDCore/Project/EventsBasedObject.h" +#include "GDCore/Project/EventsFunctionsExtension.h" #include "GDCore/Project/Object.h" #include "GDCore/Project/Project.h" @@ -38,4 +40,69 @@ bool EventsBasedObjectDependencyFinder::IsDependentFromEventsBasedObject( } return false; } + +std::vector +EventsBasedObjectDependencyFinder::GetExtensionDependencyCycleCreatedByObject( + const gd::Project &project, const gd::String &parentExtensionName, + const gd::String &objectType) { + const gd::String usedExtensionName = + gd::PlatformExtension::GetExtensionFromFullObjectType(objectType); + if (usedExtensionName.empty() || usedExtensionName == parentExtensionName || + !project.HasEventsFunctionsExtensionNamed(usedExtensionName)) { + // Objects from the platform (or from the same extension) can't create + // a cycle between extensions. + return {}; + } + + std::set visitedExtensionNames; + std::vector path; + if (!FindExtensionDependencyPath(project, usedExtensionName, + parentExtensionName, visitedExtensionNames, + path)) { + return {}; + } + std::vector cycle; + cycle.push_back(parentExtensionName); + cycle.insert(cycle.end(), path.begin(), path.end()); + return cycle; +} + +bool EventsBasedObjectDependencyFinder::FindExtensionDependencyPath( + const gd::Project &project, const gd::String &fromExtensionName, + const gd::String &toExtensionName, + std::set &visitedExtensionNames, std::vector &path) { + if (fromExtensionName == toExtensionName) { + path.push_back(fromExtensionName); + return true; + } + if (visitedExtensionNames.find(fromExtensionName) != + visitedExtensionNames.end()) { + return false; + } + visitedExtensionNames.insert(fromExtensionName); + + const auto &extension = + project.GetEventsFunctionsExtension(fromExtensionName); + path.push_back(fromExtensionName); + for (auto &eventsBasedObject : + extension.GetEventsBasedObjects().GetInternalVector()) { + for (auto &object : eventsBasedObject->GetObjects().GetObjects()) { + const gd::String childExtensionName = + gd::PlatformExtension::GetExtensionFromFullObjectType( + object->GetType()); + if (childExtensionName.empty() || + childExtensionName == fromExtensionName || + !project.HasEventsFunctionsExtensionNamed(childExtensionName)) { + continue; + } + if (FindExtensionDependencyPath(project, childExtensionName, + toExtensionName, visitedExtensionNames, + path)) { + return true; + } + } + } + path.pop_back(); + return false; +} } // namespace gd diff --git a/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.h b/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.h index 8eb375d8aa17..7ff7607bdb4f 100644 --- a/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.h +++ b/Core/GDCore/IDE/Project/EventsBasedObjectDependencyFinder.h @@ -6,6 +6,9 @@ #pragma once +#include +#include + #include "GDCore/String.h" namespace gd { @@ -16,7 +19,8 @@ class EventsBasedObject; namespace gd { /** - * \brief Find resource usages in several parts of the project. + * \brief Find dependencies between events-based objects and between + * the extensions declaring them. * * \ingroup IDE */ @@ -27,11 +31,37 @@ class EventsBasedObjectDependencyFinder { const gd::EventsBasedObject &eventsBasedObject, const gd::EventsBasedObject &dependency); + /** + * \brief Check if adding an object of the given type as a child of an + * events-based object of the given extension would create a dependency + * cycle between events functions extensions. + * + * Extensions are unserialized in dependency order when the project is + * opened (see `Project::GetUnserializingOrderExtensionNames`), and + * extensions forming a cycle can't be ordered: their entire content would + * be skipped at loading (and erased for good at the next save). + * + * \return the extension names forming the cycle, starting and ending with + * the given extension (e.g: ["A", "B", "A"]), or an empty vector if no + * cycle would be created. + */ + static std::vector GetExtensionDependencyCycleCreatedByObject( + const gd::Project &project, + const gd::String &parentExtensionName, + const gd::String &objectType); + private: static bool IsDependentFromEventsBasedObject( const gd::Project &project, const gd::EventsBasedObject &eventsBasedObject, const gd::EventsBasedObject &dependency, int depth); + + static bool FindExtensionDependencyPath( + const gd::Project &project, + const gd::String &fromExtensionName, + const gd::String &toExtensionName, + std::set &visitedExtensionNames, + std::vector &path); }; } // namespace gd diff --git a/Core/tests/EventsBasedObjectDependencyFinder.cpp b/Core/tests/EventsBasedObjectDependencyFinder.cpp new file mode 100644 index 000000000000..8e5acced88cd --- /dev/null +++ b/Core/tests/EventsBasedObjectDependencyFinder.cpp @@ -0,0 +1,199 @@ +/* + * GDevelop Core + * Copyright 2008-2026 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +/** + * @file Tests covering dependencies between events-based objects and + * between the extensions declaring them. + */ +#include "GDCore/IDE/Project/EventsBasedObjectDependencyFinder.h" + +#include + +#include "DummyPlatform.h" +#include "GDCore/Extensions/Platform.h" +#include "GDCore/Project/EventsBasedObject.h" +#include "GDCore/Project/EventsFunctionsExtension.h" +#include "GDCore/Project/Project.h" +#include "catch.hpp" + +namespace { + +gd::EventsBasedObject & +InsertEventsBasedObject(gd::Project &project, const gd::String &extensionName, + const gd::String &objectName) { + auto &extension = + project.HasEventsFunctionsExtensionNamed(extensionName) + ? project.GetEventsFunctionsExtension(extensionName) + : project.InsertNewEventsFunctionsExtension( + extensionName, project.GetEventsFunctionsExtensionsCount()); + return extension.GetEventsBasedObjects().InsertNew( + objectName, extension.GetEventsBasedObjects().GetCount()); +} + +void AddChildObject(gd::Project &project, + gd::EventsBasedObject &eventsBasedObject, + const gd::String &childObjectType) { + auto &childObjects = eventsBasedObject.GetObjects(); + childObjects.InsertNewObject( + project, childObjectType, + "Child" + gd::String::From(childObjects.GetObjectsCount()), + childObjects.GetObjectsCount()); +} + +} // namespace + +TEST_CASE("EventsBasedObjectDependencyFinder", "[common]") { + + SECTION("GetExtensionDependencyCycleCreatedByObject") { + SECTION("Allows an object from the platform") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + InsertEventsBasedObject(project, "A", "ObjectA"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject( + project, "A", "MyExtension::Sprite") + .empty()); + } + + SECTION("Allows an object from the same extension") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + InsertEventsBasedObject(project, "A", "ObjectA"); + InsertEventsBasedObject(project, "A", "ObjectA2"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject(project, "A", + "A::ObjectA2") + .empty()); + } + + SECTION("Allows an object from an extension without dependencies") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + InsertEventsBasedObject(project, "A", "ObjectA"); + InsertEventsBasedObject(project, "B", "ObjectB"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject(project, "A", + "B::ObjectB") + .empty()); + } + + SECTION("Allows an object creating a one-way dependency") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + auto &objectA = InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectB = InsertEventsBasedObject(project, "B", "ObjectB"); + InsertEventsBasedObject(project, "C", "ObjectC"); + + // B already depends on A. + AddChildObject(project, objectB, "A::ObjectA"); + + // A new dependency of C on A is one-way: no cycle. + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject(project, "C", + "A::ObjectA") + .empty()); + } + + SECTION("Forbids an object creating a direct cycle between 2 extensions") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectB = InsertEventsBasedObject(project, "B", "ObjectB"); + + // B depends on A. + AddChildObject(project, objectB, "A::ObjectA"); + + // Making A depend on B would create a cycle. + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject(project, "A", + "B::ObjectB") == + std::vector({"A", "B", "A"})); + } + + SECTION( + "Forbids an object creating a cycle through an intermediate " + "extension") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectB = InsertEventsBasedObject(project, "B", "ObjectB"); + auto &objectC = InsertEventsBasedObject(project, "C", "ObjectC"); + + // B depends on C, which depends on A. + AddChildObject(project, objectB, "C::ObjectC"); + AddChildObject(project, objectC, "A::ObjectA"); + + // Making A depend on B would create a cycle going through C. + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + GetExtensionDependencyCycleCreatedByObject(project, "A", + "B::ObjectB") == + std::vector({"A", "B", "C", "A"})); + } + } + + SECTION("IsDependentFromEventsBasedObject") { + SECTION("An object is dependent on itself") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + auto &objectA = InsertEventsBasedObject(project, "A", "ObjectA"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + IsDependentFromEventsBasedObject(project, objectA, + objectA) == true); + } + + SECTION("An object is dependent on a direct child") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + auto &objectA = InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectA2 = InsertEventsBasedObject(project, "A", "ObjectA2"); + AddChildObject(project, objectA2, "A::ObjectA"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + IsDependentFromEventsBasedObject(project, objectA2, + objectA) == true); + } + + SECTION("An object is dependent on an indirect child") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + auto &objectA = InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectB = InsertEventsBasedObject(project, "B", "ObjectB"); + auto &objectC = InsertEventsBasedObject(project, "C", "ObjectC"); + AddChildObject(project, objectB, "C::ObjectC"); + AddChildObject(project, objectC, "A::ObjectA"); + + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + IsDependentFromEventsBasedObject(project, objectB, + objectA) == true); + } + + SECTION("An object is not dependent on an object it doesn't contain") { + gd::Project project; + gd::Platform platform; + SetupProjectWithDummyPlatform(project, platform); + auto &objectA = InsertEventsBasedObject(project, "A", "ObjectA"); + auto &objectB = InsertEventsBasedObject(project, "B", "ObjectB"); + AddChildObject(project, objectB, "A::ObjectA"); + + // ObjectB contains ObjectA, but not the reverse. + REQUIRE(gd::EventsBasedObjectDependencyFinder:: + IsDependentFromEventsBasedObject(project, objectA, + objectB) == false); + } + } +} diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 1fead03e43de..3748fbb3f644 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -3063,6 +3063,10 @@ interface EventsBasedObjectDependencyFinder { [Const, Ref] Project project, [Const, Ref] EventsBasedObject eventsBasedObject, [Const, Ref] EventsBasedObject dependency); + [Value] VectorString STATIC_GetExtensionDependencyCycleCreatedByObject( + [Const, Ref] Project project, + [Const] DOMString parentExtensionName, + [Const] DOMString objectType); }; interface PropertyFunctionGenerator { diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index 9241f772d0a6..efc6a7692989 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -780,6 +780,8 @@ typedef std::vector VectorPropertyDescriptorChoice #define STATIC_GetSwitchableVariableInstructionIdentifier GetSwitchableVariableInstructionIdentifier #define STATIC_GetSwitchableInstructionVariableType GetSwitchableInstructionVariableType #define STATIC_IsDependentFromEventsBasedObject IsDependentFromEventsBasedObject +#define STATIC_GetExtensionDependencyCycleCreatedByObject \ + GetExtensionDependencyCycleCreatedByObject #define STATIC_IsFreeFunctionOnlyCallingItself IsFreeFunctionOnlyCallingItself #define STATIC_IsBehaviorFunctionOnlyCallingItself \ diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 001dbf6db90c..75eb052d68e5 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -2212,6 +2212,7 @@ export class ObjectTools extends EmscriptenObject { export class EventsBasedObjectDependencyFinder extends EmscriptenObject { static isDependentFromEventsBasedObject(project: Project, eventsBasedObject: EventsBasedObject, dependency: EventsBasedObject): boolean; + static getExtensionDependencyCycleCreatedByObject(project: Project, parentExtensionName: string, objectType: string): VectorString; } export class PropertyFunctionGenerator extends EmscriptenObject { diff --git a/GDevelop.js/types/gdeventsbasedobjectdependencyfinder.js b/GDevelop.js/types/gdeventsbasedobjectdependencyfinder.js index 7ad70a6e1a1b..a7ca4e4b00df 100644 --- a/GDevelop.js/types/gdeventsbasedobjectdependencyfinder.js +++ b/GDevelop.js/types/gdeventsbasedobjectdependencyfinder.js @@ -1,6 +1,7 @@ // Automatically generated by GDevelop.js/scripts/generate-types.js declare class gdEventsBasedObjectDependencyFinder { static isDependentFromEventsBasedObject(project: gdProject, eventsBasedObject: gdEventsBasedObject, dependency: gdEventsBasedObject): boolean; + static getExtensionDependencyCycleCreatedByObject(project: gdProject, parentExtensionName: string, objectType: string): gdVectorString; delete(): void; ptr: number; }; \ No newline at end of file diff --git a/newIDE/app/src/AssetStore/NewObjectFromScratch.js b/newIDE/app/src/AssetStore/NewObjectFromScratch.js index 6cd556c234ac..8686168eceef 100644 --- a/newIDE/app/src/AssetStore/NewObjectFromScratch.js +++ b/newIDE/app/src/AssetStore/NewObjectFromScratch.js @@ -32,6 +32,7 @@ import ElementWithMenu from '../UI/Menu/ElementWithMenu'; import IconButton from '../UI/IconButton'; import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; import ExtensionInstallDialog from './ExtensionStore/ExtensionInstallDialog'; +import { getDependencyCycleCreatedByObject } from '../Utils/ExtensionDependencyCycle'; const gd: libGDevelop = global.gd; @@ -190,17 +191,12 @@ export default function NewObjectFromScratch({ return objectMetadataList .filter(object => !object.objectMetadata.isHidden() && object.type) .map(object => { - let isDependentWithParent = false; - if (eventsBasedObject && project.hasEventsBasedObject(object.name)) { - const otherEventBasedObject = project.getEventsBasedObject( - object.name - ); - isDependentWithParent = gd.EventsBasedObjectDependencyFinder.isDependentFromEventsBasedObject( - project, - otherEventBasedObject, - eventsBasedObject - ); - } + const isDependentWithParent = !!getDependencyCycleCreatedByObject( + project, + eventsFunctionsExtension, + eventsBasedObject, + object.type + ); // $FlowFixMe[incompatible-type] return { type: object.type, diff --git a/newIDE/app/src/AssetStore/ObjectListItem.js b/newIDE/app/src/AssetStore/ObjectListItem.js index 416c6a18dba0..f8f19840116e 100644 --- a/newIDE/app/src/AssetStore/ObjectListItem.js +++ b/newIDE/app/src/AssetStore/ObjectListItem.js @@ -79,7 +79,8 @@ export const ObjectListItem = ({ ); }; - const isEnabled = isEngineCompatible; + const isDependentWithParent = !!objectShortHeader.isDependentWithParent; + const isEnabled = isEngineCompatible && !isDependentWithParent; const chooseObject = React.useCallback( () => { @@ -92,7 +93,7 @@ export const ObjectListItem = ({ const [hover, setHover] = React.useState(false); - return ( + const button = ( ); + + return isDependentWithParent ? ( + + This object can't be used here because it would create a circular + dependency, with the object being edited or between their extensions. + + } + > + {button} + + ) : ( + button + ); }; diff --git a/newIDE/app/src/AssetStore/ObjectStoreContext.js b/newIDE/app/src/AssetStore/ObjectStoreContext.js index 149ed36aa524..7d76df5644e1 100644 --- a/newIDE/app/src/AssetStore/ObjectStoreContext.js +++ b/newIDE/app/src/AssetStore/ObjectStoreContext.js @@ -374,6 +374,10 @@ export const ObjectStoreStateProvider = ({ type: installedObjectMetadata.type, name: installedObjectMetadata.name, extensionName: installedObjectMetadata.extensionName, + // Computed from the objects of the project, so only the + // installed extension knows it. + isDependentWithParent: + installedObjectMetadata.isDependentWithParent, // Attributes switching between both diff --git a/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js b/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js index cd72ee40d37a..6ea95bc8e675 100644 --- a/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js +++ b/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js @@ -21,6 +21,10 @@ import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; import { type HTMLDataset } from '../Utils/HTMLDataset'; import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; +import { + getDependencyCycleCreatedByObject, + getDependencyCycleAlertOptions, +} from '../Utils/ExtensionDependencyCycle'; const gd: libGDevelop = global.gd; @@ -58,6 +62,8 @@ export type ObjectFolderTreeViewItemCallbacks = {| export type ObjectFolderTreeViewItemProps = {| ...ObjectFolderTreeViewItemCallbacks, project: gdProject, + eventsFunctionsExtension: gdEventsFunctionsExtension | null, + eventsBasedObject: gdEventsBasedObject | null, globalObjectsContainer: gdObjectsContainer | null, objectsContainer: gdObjectsContainer, editName: (itemId: string) => void, @@ -75,6 +81,7 @@ export type ObjectFolderTreeViewItemProps = {| objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext ) => void, showDeleteConfirmation: (options: any) => Promise, + showAlert: (options: any) => Promise, selectObjectFolderOrObjectWithContext: ( objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext ) => void, @@ -431,14 +438,29 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { const { project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, expandFolders, onObjectModified, onObjectCreated, + showAlert, } = this.props; + // A cycle between extensions would make the project unable to load them. + const dependencyCycle = getDependencyCycleCreatedByObject( + project, + eventsFunctionsExtension, + eventsBasedObject, + objectType + ); + if (dependencyCycle) { + showAlert(getDependencyCycleAlertOptions(dependencyCycle)); + return; + } + const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( project, objectType diff --git a/newIDE/app/src/ObjectsList/ObjectTreeViewItemContent.js b/newIDE/app/src/ObjectsList/ObjectTreeViewItemContent.js index 7074d9869256..a3055d6c8198 100644 --- a/newIDE/app/src/ObjectsList/ObjectTreeViewItemContent.js +++ b/newIDE/app/src/ObjectsList/ObjectTreeViewItemContent.js @@ -22,6 +22,10 @@ import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; import { type HTMLDataset } from '../Utils/HTMLDataset'; import { isVariantEditable } from '../ObjectEditor/Editors/CustomObjectPropertiesEditor'; import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; +import { + getDependencyCycleCreatedByObject, + getDependencyCycleAlertOptions, +} from '../Utils/ExtensionDependencyCycle'; const gd: libGDevelop = global.gd; @@ -67,6 +71,8 @@ export type ObjectTreeViewItemCallbacks = {| export type ObjectTreeViewItemProps = {| ...ObjectTreeViewItemCallbacks, project: gdProject, + eventsFunctionsExtension: gdEventsFunctionsExtension | null, + eventsBasedObject: gdEventsBasedObject | null, globalObjectsContainer: gdObjectsContainer | null, objectsContainer: gdObjectsContainer, swapObjectAsset: (objectWithContext: ObjectWithContext) => void, @@ -88,6 +94,7 @@ export type ObjectTreeViewItemProps = {| folder?: gdObjectFolderOrObject, |}) => void, showDeleteConfirmation: (options: any) => Promise, + showAlert: (options: any) => Promise, selectObjectFolderOrObjectWithContext: ( objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext ) => void, @@ -656,13 +663,28 @@ export class ObjectTreeViewItemContent implements TreeViewItemContent { const { project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, onObjectModified, onObjectCreated, + showAlert, } = this.props; + // A cycle between extensions would make the project unable to load them. + const dependencyCycle = getDependencyCycleCreatedByObject( + project, + eventsFunctionsExtension, + eventsBasedObject, + objectType + ); + if (dependencyCycle) { + showAlert(getDependencyCycleAlertOptions(dependencyCycle)); + return; + } + const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( project, objectType diff --git a/newIDE/app/src/ObjectsList/index.js b/newIDE/app/src/ObjectsList/index.js index 6f22aa5b438e..25c2a77b4f56 100644 --- a/newIDE/app/src/ObjectsList/index.js +++ b/newIDE/app/src/ObjectsList/index.js @@ -57,6 +57,10 @@ import type { MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; import type { EventsScope } from '../InstructionOrExpression/EventsScope'; import { type InstallAssetOutput } from '../AssetStore/InstallAsset'; import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; +import { + getDependencyCycleCreatedByObject, + getDependencyCycleAlertOptions, +} from '../Utils/ExtensionDependencyCycle'; const gd: libGDevelop = global.gd; @@ -568,7 +572,7 @@ const ObjectsList = React.forwardRef( InAppTutorialContext ); const [searchText, setSearchText] = React.useState(''); - const { showDeleteConfirmation } = useAlertDialog(); + const { showDeleteConfirmation, showAlert } = useAlertDialog(); const treeViewRef = React.useRef>(null); const forceUpdate = useForceUpdate(); const { isMobile } = useResponsiveWindowSize(); @@ -621,6 +625,21 @@ const ObjectsList = React.forwardRef( const addObject = React.useCallback( (objectType: string) => { + // The dialog filters out objects that would create a dependency + // cycle, but check again in case this is called from elsewhere: + // a cycle between extensions would make the project unable to + // load them. + const dependencyCycle = getDependencyCycleCreatedByObject( + project, + eventsFunctionsExtension, + eventsBasedObject, + objectType + ); + if (dependencyCycle) { + showAlert(getDependencyCycleAlertOptions(dependencyCycle)); + return; + } + const defaultName = project.hasEventsBasedObject(objectType) ? 'New' + (project.getEventsBasedObject(objectType).getDefaultName() || @@ -710,6 +729,9 @@ const ObjectsList = React.forwardRef( }, [ project, + eventsFunctionsExtension, + eventsBasedObject, + showAlert, newObjectDialogOpen, onEditObject, objectsContainer, @@ -1031,6 +1053,8 @@ const ObjectsList = React.forwardRef( const objectTreeViewItemProps = React.useMemo( () => ({ project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, @@ -1052,6 +1076,7 @@ const ObjectsList = React.forwardRef( setAsGlobalObject, getThumbnail, showDeleteConfirmation, + showAlert, selectObjectFolderOrObjectWithContext, addFolder, forceUpdateList, @@ -1060,6 +1085,8 @@ const ObjectsList = React.forwardRef( }), [ project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, @@ -1081,6 +1108,7 @@ const ObjectsList = React.forwardRef( setAsGlobalObject, getThumbnail, showDeleteConfirmation, + showAlert, selectObjectFolderOrObjectWithContext, addFolder, forceUpdateList, @@ -1092,6 +1120,8 @@ const ObjectsList = React.forwardRef( const objectFolderTreeViewItemProps = React.useMemo( () => ({ project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, @@ -1106,12 +1136,15 @@ const ObjectsList = React.forwardRef( onDeleteObjects, selectObjectFolderOrObjectWithContext, showDeleteConfirmation, + showAlert, forceUpdateList, forceUpdate, isListLocked, }), [ project, + eventsFunctionsExtension, + eventsBasedObject, globalObjectsContainer, objectsContainer, onObjectPasted, @@ -1126,6 +1159,7 @@ const ObjectsList = React.forwardRef( onDeleteObjects, selectObjectFolderOrObjectWithContext, showDeleteConfirmation, + showAlert, forceUpdateList, forceUpdate, isListLocked, diff --git a/newIDE/app/src/Utils/ExtensionDependencyCycle.js b/newIDE/app/src/Utils/ExtensionDependencyCycle.js new file mode 100644 index 000000000000..5db00e94ef07 --- /dev/null +++ b/newIDE/app/src/Utils/ExtensionDependencyCycle.js @@ -0,0 +1,85 @@ +// @flow +import { t } from '@lingui/macro'; +import { type ShowAlertDialogOptions } from '../UI/Alert/AlertContext'; + +const gd: libGDevelop = global.gd; + +export type DependencyCycle = + | {| + kind: 'extension-dependency', + /** + * The extension names forming the cycle, starting and ending with the + * extension of the edited events-based object + * (e.g: ['GameManager', 'MainMenu', 'GameManager']). + */ + extensionNames: Array, + |} + | {| kind: 'object-containment' |}; + +/** + * Check if adding an object of the given type as a child of the given + * events-based object would create a dependency cycle, either: + * - between events functions extensions: extensions forming a cycle can't + * be ordered at loading - their entire content would be skipped (and + * erased for good at the next save). + * - between events-based objects: an object containing itself (directly or + * indirectly) can't be properly instantiated. + * + * Returns the found cycle, or null if the object can safely be added. + */ +export const getDependencyCycleCreatedByObject = ( + project: gdProject, + eventsFunctionsExtension: gdEventsFunctionsExtension | null, + eventsBasedObject: gdEventsBasedObject | null, + objectType: string +): DependencyCycle | null => { + if (!eventsFunctionsExtension || !eventsBasedObject) { + // Objects added outside of an events-based object (e.g: in a scene) + // can't create a cycle. + return null; + } + + // Note: the returned VectorString is a value owned by the binding layer + // (it must not be deleted). + const extensionNames = gd.EventsBasedObjectDependencyFinder.getExtensionDependencyCycleCreatedByObject( + project, + eventsFunctionsExtension.getName(), + objectType + ).toJSArray(); + if (extensionNames.length > 0) { + return { kind: 'extension-dependency', extensionNames }; + } + + if ( + project.hasEventsBasedObject(objectType) && + gd.EventsBasedObjectDependencyFinder.isDependentFromEventsBasedObject( + project, + project.getEventsBasedObject(objectType), + eventsBasedObject + ) + ) { + return { kind: 'object-containment' }; + } + + return null; +}; + +/** + * Build the options of the alert dialog to show when an object can't be + * added because of a dependency cycle. + */ +export const getDependencyCycleAlertOptions = ( + dependencyCycle: DependencyCycle +): ShowAlertDialogOptions => { + if (dependencyCycle.kind === 'extension-dependency') { + const cycleAsText = dependencyCycle.extensionNames.join(' → '); + return { + title: t`Circular dependency between extensions`, + message: t`Using this object here would create a circular dependency between extensions (${cycleAsText}) - the project would not be able to load them anymore. To use this object here, move it to this extension, or reorganize your objects so that dependencies between extensions only go one way.`, + }; + } + return { + title: t`An object can't contain itself`, + message: t`Using this object here would create an object that directly or indirectly contains itself. Reorganize your objects so that they don't contain each other.`, + }; +}; diff --git a/newIDE/app/src/Utils/ExtensionDependencyCycle.spec.js b/newIDE/app/src/Utils/ExtensionDependencyCycle.spec.js new file mode 100644 index 000000000000..228fc05039f9 --- /dev/null +++ b/newIDE/app/src/Utils/ExtensionDependencyCycle.spec.js @@ -0,0 +1,72 @@ +// @flow +import { getDependencyCycleCreatedByObject } from './ExtensionDependencyCycle'; + +const gd: libGDevelop = global.gd; + +// The detection logic is exhaustively tested in +// Core/tests/EventsBasedObjectDependencyFinder.cpp. These tests only cover +// the wrapping done by `getDependencyCycleCreatedByObject`. +describe('getDependencyCycleCreatedByObject', () => { + let project: gdProject; + let extensionA: gdEventsFunctionsExtension; + let extensionB: gdEventsFunctionsExtension; + let objectA: gdEventsBasedObject; + let objectB: gdEventsBasedObject; + + beforeEach(() => { + project = gd.ProjectHelper.createNewGDJSProject(); + extensionA = project.insertNewEventsFunctionsExtension('A', 0); + extensionB = project.insertNewEventsFunctionsExtension('B', 1); + objectA = extensionA.getEventsBasedObjects().insertNew('ObjectA', 0); + objectB = extensionB.getEventsBasedObjects().insertNew('ObjectB', 0); + + // B depends on A. + objectB.getObjects().insertNewObject(project, 'A::ObjectA', 'Child', 0); + }); + + afterEach(() => { + project.delete(); + }); + + it('returns null when not editing an events-based object', () => { + expect( + getDependencyCycleCreatedByObject(project, null, null, 'B::ObjectB') + ).toBe(null); + }); + + it('returns null for an object not creating a cycle', () => { + expect( + getDependencyCycleCreatedByObject( + project, + extensionB, + objectB, + 'A::ObjectA' + ) + ).toBe(null); + }); + + it('returns the cycle for an object creating a cycle between extensions', () => { + expect( + getDependencyCycleCreatedByObject( + project, + extensionA, + objectA, + 'B::ObjectB' + ) + ).toEqual({ + kind: 'extension-dependency', + extensionNames: ['A', 'B', 'A'], + }); + }); + + it('returns a containment cycle for an object containing itself', () => { + expect( + getDependencyCycleCreatedByObject( + project, + extensionA, + objectA, + 'A::ObjectA' + ) + ).toEqual({ kind: 'object-containment' }); + }); +}); diff --git a/newIDE/app/src/Utils/GDevelopServices/Extension.js b/newIDE/app/src/Utils/GDevelopServices/Extension.js index 684073e4ceec..880c7c928fcb 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Extension.js +++ b/newIDE/app/src/Utils/GDevelopServices/Extension.js @@ -113,6 +113,12 @@ export type ObjectShortHeader = { * @see adaptObjectHeader */ type: string, + /** + * This attribute is computed for `installed` extensions: true when using + * the object would create a dependency cycle with the edited + * events-based object. + */ + isDependentWithParent?: boolean, }; /**