diff --git a/Core/GDCore/Extensions/Builtin/CommonInstructionsExtension.cpp b/Core/GDCore/Extensions/Builtin/CommonInstructionsExtension.cpp index 300e20b8b09f..bda02cd3e8ce 100644 --- a/Core/GDCore/Extensions/Builtin/CommonInstructionsExtension.cpp +++ b/Core/GDCore/Extensions/Builtin/CommonInstructionsExtension.cpp @@ -64,10 +64,17 @@ BuiltinExtensionsImplementer::ImplementsCommonInstructionsExtension( .AddCondition( "Or", _("Or"), - _("Checks if at least one sub-condition is true. If no " - "sub-condition is specified, it will always be false. " - "This is rarely used — multiple events and sub-events are " - "usually a better approach."), + _("True if at least one sub-condition is true.\n\n" + "Picked objects: the action only sees objects picked by a " + "true branch. Objects mentioned only by false branches end " + "up with no selection.\n\n" + "Use this when the action acts on the specific object whose " + "condition was tested.\n" + "Example: \"Door collided with Player OR Coin collided with " + "Player\" → hide the touched object. (When the Door branch " + "fires, the Door and Player from that collision are picked; " + "the Coin is not selected, so an action on Coin does " + "nothing.)"), _("If one of these conditions is true:"), "", "res/conditions/or24_black.png", @@ -75,6 +82,29 @@ BuiltinExtensionsImplementer::ImplementsCommonInstructionsExtension( .SetCanHaveSubInstructions() .MarkAsAdvanced(); + extension + .AddCondition( + "OrDistributive", + _("Or (independent object picking)"), + _("True if at least one sub-condition is true.\n\n" + "Picked objects stay the same as before the Or, unless a " + "true branch explicitly filters them. A true branch that " + "does not reference an object leaves that object's " + "selection alone.\n\n" + "Use this when the action acts on an object that some " + "branches do not mention.\n" + "Example: \"Text input is submitted OR Submit button is " + "clicked\" → set a variable to the text input's value. " + "(The button branch doesn't pick the text input; with the " + "regular Or the input would no longer be selected.)"), + _("If one of these conditions is true (independent object " + "picking):"), + "", + "res/conditions/or24_black.png", + "res/conditions/or_black.png") + .SetCanHaveSubInstructions() + .MarkAsAdvanced(); + extension .AddCondition( "And", diff --git a/Core/GDCore/Project/Project.cpp b/Core/GDCore/Project/Project.cpp index f7c9a7042936..0f9ba4457503 100644 --- a/Core/GDCore/Project/Project.cpp +++ b/Core/GDCore/Project/Project.cpp @@ -67,6 +67,7 @@ Project::Project() projectUuid(""), useDeprecatedZeroAsDefaultZOrder(false), useDeprecatedZeroAsDefaultStringVariable(false), + useDeprecatedOrConditionPicking(false), isPlayableWithKeyboard(false), isPlayableWithGamepad(false), isPlayableWithMobile(false), @@ -803,6 +804,17 @@ void Project::UnserializeFrom(const SerializerElement& element) { } // end of compatibility code + // Compatibility with GD <= 5.6.268 + if (VersionWrapper::IsOlderOrEqual( + gdMajorVersion, gdMinorVersion, gdBuildVersion, 0, 5, 6, 268, 0) && + !propElement.HasAttribute("useDeprecatedOrConditionPicking")) { + useDeprecatedOrConditionPicking = true; + } else { + useDeprecatedOrConditionPicking = propElement.GetBoolAttribute( + "useDeprecatedOrConditionPicking", false); + } + // end of compatibility code + // Compatibility with GD <= 5.0.0-beta101 if (!propElement.HasAttribute("projectUuid") && !propElement.HasChild("projectUuid")) { @@ -1174,6 +1186,12 @@ void Project::SerializeTo(SerializerElement& element) const { } // end of compatibility code + // Compatibility with GD <= 5.6.268 + if (useDeprecatedOrConditionPicking) { + propElement.SetAttribute("useDeprecatedOrConditionPicking", true); + } + // end of compatibility code + extensionProperties.SerializeTo(propElement.AddChild("extensionProperties")); SerializerElement& platformsElement = propElement.AddChild("platforms"); @@ -1305,6 +1323,7 @@ void Project::Init(const gd::Project& game) { useDeprecatedZeroAsDefaultZOrder = game.useDeprecatedZeroAsDefaultZOrder; useDeprecatedZeroAsDefaultStringVariable = game.useDeprecatedZeroAsDefaultStringVariable; + useDeprecatedOrConditionPicking = game.useDeprecatedOrConditionPicking; author = game.author; authorIds = game.authorIds; diff --git a/Core/GDCore/Project/Project.h b/Core/GDCore/Project/Project.h index 95ae2685edf9..aa1cefd47a64 100644 --- a/Core/GDCore/Project/Project.h +++ b/Core/GDCore/Project/Project.h @@ -454,6 +454,25 @@ class GD_CORE_API Project { useDeprecatedZeroAsDefaultStringVariable = enable; } + /** + * \brief Check if the project should use the deprecated "Or" condition + * object-picking semantics (the pre-5.6.269 behavior, in which the Or + * unconditionally overwrote the parent event's picked object lists with + * the union of branch contributions, including when no branch actually + * contributed for a given object — wiping outside-Or picks). + */ + bool GetUseDeprecatedOrConditionPicking() const { + return useDeprecatedOrConditionPicking; + } + + /** + * \brief Set whether the project should use the deprecated "Or" condition + * object-picking semantics (the pre-5.6.269 behavior). + */ + void SetUseDeprecatedOrConditionPicking(bool enable) { + useDeprecatedOrConditionPicking = enable; + } + /** * \brief Change the project UUID. */ @@ -1147,6 +1166,14 @@ class GD_CORE_API Project { ///< no stored value default to "0" ///< at runtime (behavior before ///< 5.6.267). + bool useDeprecatedOrConditionPicking = + false; ///< If true, the "Or" condition uses + ///< the pre-5.6.269 picking semantics + ///< (always overwrite parent's picked + ///< list with the union of branch + ///< contributions, even when no branch + ///< contributed for a given object — + ///< wiping outside-Or picks). std::vector > scenes; ///< List of all scenes gd::VariablesContainer variables; ///< Initial global variables gd::ObjectsContainer objectsContainer; diff --git a/GDJS/GDJS/Extensions/Builtin/CommonInstructionsExtension.cpp b/GDJS/GDJS/Extensions/Builtin/CommonInstructionsExtension.cpp index 2a657380bed5..991b4b222081 100644 --- a/GDJS/GDJS/Extensions/Builtin/CommonInstructionsExtension.cpp +++ b/GDJS/GDJS/Extensions/Builtin/CommonInstructionsExtension.cpp @@ -253,6 +253,24 @@ CommonInstructionsExtension::CommonInstructionsExtension() { gd::String conditionsCode; gd::InstructionsList &conditions = instruction.GetSubInstructions(); + gd::String codeNamespace = codeGenerator.GetCodeNamespaceAccessor(); + auto finalListName = [&](const gd::String &objectName) { + return codeNamespace + ManObjListName(objectName) + + gd::String::From(parentContext.GetContextDepth()) + "_" + + gd::String::From(parentContext.GetCurrentConditionDepth()) + + "final"; + }; + // Per-object runtime flag: was there at least one branch that was + // true AND that referenced (declared) this object? If not, we leave + // the parent's picked list untouched at the end of the Or, instead + // of overwriting it with an empty "final" list. + auto hasContribName = [&](const gd::String &objectName) { + return codeNamespace + ManObjListName(objectName) + + gd::String::From(parentContext.GetContextDepth()) + "_" + + gd::String::From(parentContext.GetCurrentConditionDepth()) + + "hasContrib"; + }; + // The Or "return" true by setting the upper boolean to true. // So, it needs to be initialized to false. conditionsCode += codeGenerator.GenerateUpperScopeBooleanFullName( @@ -279,8 +297,17 @@ CommonInstructionsExtension::CommonInstructionsExtension() { // Create new objects lists and generate condition conditionsCode += codeGenerator.GenerateObjectsDeclarationCode(context); - if (!conditions[cId].GetType().empty()) + if (!conditions[cId].GetType().empty()) { + // Reset the branch's boolean to false: condition codegen does + // not zero its return boolean, so without this each branch + // would inherit the previous branch's truth value, causing a + // false branch that picks 0 to be treated as a contribution. + conditionsCode += + codeGenerator.GenerateBooleanFullName("isConditionTrue", + context) + + " = false;\n"; conditionsCode += conditionCode; + } // If the condition is true : merge all objects picked in the // final object lists. @@ -298,17 +325,20 @@ CommonInstructionsExtension::CommonInstructionsExtension() { it != objectsListsToBeDeclared.end(); ++it) { emptyListsNeeded.insert(*it); gd::String objList = codeGenerator.GetObjectListName(*it, context); - gd::String finalObjList = - codeGenerator.GetCodeNamespaceAccessor() + ManObjListName(*it) + - gd::String::From(parentContext.GetContextDepth()) + "_" + - gd::String::From(parentContext.GetCurrentConditionDepth()) + - "final"; + gd::String finalObjList = finalListName(*it); + gd::String finalObjSet = finalObjList + "Set"; + // Mark that this object got a contribution from a true branch + // that referenced it. + conditionsCode += " " + hasContribName(*it) + " = true;\n"; conditionsCode += " for (let j = 0, jLen = " + objList + ".length; j < jLen ; ++j) {\n"; - conditionsCode += " if ( " + finalObjList + ".indexOf(" + - objList + "[j]) === -1 )\n"; + conditionsCode += " if (!" + finalObjSet + ".has(" + + objList + "[j])) {\n"; + conditionsCode += " " + finalObjSet + ".add(" + + objList + "[j]);\n"; conditionsCode += " " + finalObjList + ".push(" + objList + "[j]);\n"; + conditionsCode += " }\n"; conditionsCode += " }\n"; } conditionsCode += "}\n"; @@ -319,7 +349,6 @@ CommonInstructionsExtension::CommonInstructionsExtension() { gd::String declarationsCode; // Declarations code - gd::String codeNamespace = codeGenerator.GetCodeNamespaceAccessor(); for (set::iterator it = emptyListsNeeded.begin(); it != emptyListsNeeded.end(); ++it) { //"OR" condition must declare objects list, but without getting @@ -330,13 +359,15 @@ CommonInstructionsExtension::CommonInstructionsExtension() { // be filled with objects by conditions, but they will have no // incidence on further conditions, as conditions use "normal" // ones. - gd::String finalObjList = - codeNamespace + ManObjListName(*it) + - gd::String::From(parentContext.GetContextDepth()) + "_" + - gd::String::From(parentContext.GetCurrentConditionDepth()) + - "final"; + gd::String finalObjList = finalListName(*it); + gd::String finalObjSet = finalObjList + "Set"; codeGenerator.AddGlobalDeclaration(finalObjList + " = [];\n"); + codeGenerator.AddGlobalDeclaration(finalObjSet + " = new Set();\n"); declarationsCode += finalObjList + ".length = 0;\n"; + declarationsCode += finalObjSet + ".clear();\n"; + // Per-object contribution flag, reset at the start of every Or. + codeGenerator.AddGlobalDeclaration(hasContribName(*it) + " = false;\n"); + declarationsCode += hasContribName(*it) + " = false;\n"; } declarationsCode += "let " + codeGenerator.GenerateBooleanFullName( @@ -349,23 +380,187 @@ CommonInstructionsExtension::CommonInstructionsExtension() { code += conditionsCode; // When condition is finished, "final" objects lists become the - // "normal" ones. + // "normal" ones — but only for objects that received at least one + // contribution (a true branch that actually referenced the object). + // If no true branch contributed, leave the parent's picked list + // untouched so picks established before the Or are preserved. + // + // Backwards compatibility: projects from before 5.6.269 expect the + // old "always overwrite parent.X with final.X" semantics. The + // runtime flag `gdjs.useDeprecatedOrConditionPicking` is set from + // the project's `useDeprecatedOrConditionPicking` property, and + // when true forces the unconditional overwrite — reproducing the + // pre-fix behavior for those projects. code += "{\n"; for (set::iterator it = emptyListsNeeded.begin(); it != emptyListsNeeded.end(); ++it) { - gd::String finalObjList = - codeNamespace + ManObjListName(*it) + - gd::String::From(parentContext.GetContextDepth()) + "_" + - gd::String::From(parentContext.GetCurrentConditionDepth()) + - "final"; - code += "gdjs.copyArray(" + finalObjList + ", " + - codeGenerator.GetObjectListName(*it, parentContext) + ");\n"; + gd::String finalObjList = finalListName(*it); + code += "if (gdjs.useDeprecatedOrConditionPicking || " + + hasContribName(*it) + ") gdjs.copyArray(" + finalObjList + + ", " + codeGenerator.GetObjectListName(*it, parentContext) + + ");\n"; } code += "}\n"; return code; }); + GetAllConditions()["BuiltinCommonInstructions::OrDistributive"] + .SetCustomCodeGenerator( + [](gd::Instruction &instruction, + gd::EventsCodeGenerator &codeGenerator, + gd::EventsCodeGenerationContext &parentContext) { + gd::InstructionsList &conditions = + instruction.GetSubInstructions(); + gd::String codeNamespace = + codeGenerator.GetCodeNamespaceAccessor(); + + auto finalListName = [&](const gd::String &objectName) { + return codeNamespace + ManObjListName(objectName) + + gd::String::From(parentContext.GetContextDepth()) + "_" + + gd::String::From( + parentContext.GetCurrentConditionDepth()) + + "distFinal"; + }; + + // PASS 1: Discover every object referenced across all branches + // by generating each branch's condition code into a discovery + // context. The output is discarded; we only keep the union of + // referenced objects, which is needed before we can emit the + // real per-branch code with the correct pre-registration. + std::set allReferencedObjects; + for (unsigned int cId = 0; cId < conditions.size(); ++cId) { + gd::EventsCodeGenerationContext discoveryContext; + discoveryContext.InheritsFrom(parentContext); + discoveryContext.ForbidReuse(); + codeGenerator.GenerateConditionCode( + conditions[cId], "isConditionTrue", discoveryContext); + std::set branchObjects = + discoveryContext.GetAllObjectsToBeDeclared(); + allReferencedObjects.insert(branchObjects.begin(), + branchObjects.end()); + } + + // Distributive Or: every referenced object must be available in + // the parent's picked list, filled from the scene if not already + // picked by a previous condition. This is what allows a branch + // that doesn't reference an object to behave as "unconstrained" + // (i.e. contribute the parent's picked list rather than empty). + for (auto &objectName : allReferencedObjects) { + parentContext.ObjectsListNeeded(objectName); + } + + // PASS 2: Emit per-branch code. Each branch's child context is + // pre-registered with the entire union of referenced objects, + // so the branch's child holds a copy of the parent's picked + // list for every union object — even those the branch itself + // doesn't constrain. + gd::String conditionsCode; + conditionsCode += codeGenerator.GenerateUpperScopeBooleanFullName( + "isConditionTrue", parentContext) + + " = false;\n"; + + for (unsigned int cId = 0; cId < conditions.size(); ++cId) { + gd::EventsCodeGenerationContext context; + context.InheritsFrom(parentContext); + context.ForbidReuse(); + + // Pre-register every union object on this branch's child so + // GenerateObjectsDeclarationCode emits a copy from the parent + // even for objects the branch doesn't itself touch. + for (auto &objectName : allReferencedObjects) { + context.ObjectsListNeeded(objectName); + } + + gd::String conditionCode = codeGenerator.GenerateConditionCode( + conditions[cId], "isConditionTrue", context); + + conditionsCode += "{\n"; + conditionsCode += + codeGenerator.GenerateObjectsDeclarationCode(context); + if (!conditions[cId].GetType().empty()) { + // See the matching reset comment in the Or generator above: + // each branch must start with isConditionTrue = false so a + // previous true branch does not leak into this one. + conditionsCode += + codeGenerator.GenerateBooleanFullName("isConditionTrue", + context) + + " = false;\n"; + conditionsCode += conditionCode; + } + + conditionsCode += "if(" + + codeGenerator.GenerateBooleanFullName( + "isConditionTrue", context) + + ") {\n"; + conditionsCode += + " " + + codeGenerator.GenerateUpperScopeBooleanFullName( + "isConditionTrue", context) + + " = true;\n"; + for (auto &objectName : allReferencedObjects) { + gd::String objList = + codeGenerator.GetObjectListName(objectName, context); + gd::String finalObjList = finalListName(objectName); + gd::String finalObjSet = finalObjList + "Set"; + conditionsCode += " for (let j = 0, jLen = " + objList + + ".length; j < jLen ; ++j) {\n"; + conditionsCode += " if (!" + finalObjSet + ".has(" + + objList + "[j])) {\n"; + conditionsCode += " " + finalObjSet + ".add(" + + objList + "[j]);\n"; + conditionsCode += " " + finalObjList + ".push(" + + objList + "[j]);\n"; + conditionsCode += " }\n"; + conditionsCode += " }\n"; + } + conditionsCode += "}\n"; + conditionsCode += "}\n"; + } + + // Emit "final" lists declarations. + gd::String declarationsCode; + for (auto &objectName : allReferencedObjects) { + gd::String finalObjList = finalListName(objectName); + gd::String finalObjSet = finalObjList + "Set"; + codeGenerator.AddGlobalDeclaration(finalObjList + " = [];\n"); + codeGenerator.AddGlobalDeclaration(finalObjSet + " = new Set();\n"); + declarationsCode += finalObjList + ".length = 0;\n"; + declarationsCode += finalObjSet + ".clear();\n"; + } + declarationsCode += "let " + + codeGenerator.GenerateBooleanFullName( + "isConditionTrue", parentContext) + + " = false;\n"; + + gd::String code; + code += declarationsCode; + code += conditionsCode; + + // Final copy: distributive Or overwrites the parent's picked + // list with the union built up across branches when at least + // one branch was true. Each true branch contributes (either + // its own picks if it referenced the object, or the parent's + // list as it was at the start of the Or otherwise). When no + // branch was true the Or returns false; in that case we leave + // the parent's picks untouched so behavior is consistent with + // the regular Or in the all-false case. + code += "if (" + + codeGenerator.GenerateUpperScopeBooleanFullName( + "isConditionTrue", parentContext) + + ") {\n"; + for (auto &objectName : allReferencedObjects) { + gd::String finalObjList = finalListName(objectName); + code += "gdjs.copyArray(" + finalObjList + ", " + + codeGenerator.GetObjectListName(objectName, + parentContext) + + ");\n"; + } + code += "}\n"; + + return code; + }); + GetAllConditions()["BuiltinCommonInstructions::And"].SetCustomCodeGenerator( [](gd::Instruction &instruction, gd::EventsCodeGenerator &codeGenerator, gd::EventsCodeGenerationContext &parentContext) { diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index 169137fcd596..b5d735ac9feb 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -9,6 +9,19 @@ namespace gdjs { const sleep = (ms: float) => new Promise((resolve) => setTimeout(resolve, ms)); + /** + * If true, the "Or" sub-condition uses the deprecated pre-5.6.269 + * object-picking semantics: it unconditionally overwrites the parent + * event's picked object lists with the union of branch contributions, + * even when no branch contributed for a given object — wiping + * outside-Or picks. Set by `RuntimeGame` on startup based on the + * project's `useDeprecatedOrConditionPicking` property. + * + * The generated event code reads this flag at the final copy step of + * the Or so old projects keep their existing behavior at runtime. + */ + export let useDeprecatedOrConditionPicking: boolean = false; + /** * Identify a script file, with its content hash (useful for hot-reloading). * @category Core Engine > Game @@ -309,6 +322,8 @@ namespace gdjs { this._updateSceneAndExtensionsData(); gdjs.Variable.useDeprecatedZeroAsDefaultStringVariable = !!data.properties.useDeprecatedZeroAsDefaultStringVariable; + gdjs.useDeprecatedOrConditionPicking = + !!data.properties.useDeprecatedOrConditionPicking; this._sceneResourcesPreloading = this._data.properties.sceneResourcesPreloading || 'at-startup'; diff --git a/GDJS/Runtime/types/project-data.d.ts b/GDJS/Runtime/types/project-data.d.ts index e8d4a6a8cac3..fed6e76906f8 100644 --- a/GDJS/Runtime/types/project-data.d.ts +++ b/GDJS/Runtime/types/project-data.d.ts @@ -559,6 +559,7 @@ declare interface ProjectPropertiesData { extensionProperties: Array; useDeprecatedZeroAsDefaultZOrder?: boolean; useDeprecatedZeroAsDefaultStringVariable?: boolean; + useDeprecatedOrConditionPicking?: boolean; projectUuid?: string; sceneResourcesPreloading?: 'at-startup' | 'never'; sceneResourcesUnloading?: 'at-scene-exit' | 'never'; diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 53a9c97893ed..a8e23a1b3163 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -609,6 +609,8 @@ interface Project { boolean GetUseDeprecatedZeroAsDefaultZOrder(); void SetUseDeprecatedZeroAsDefaultStringVariable(boolean enable); boolean GetUseDeprecatedZeroAsDefaultStringVariable(); + void SetUseDeprecatedOrConditionPicking(boolean enable); + boolean GetUseDeprecatedOrConditionPicking(); boolean AreEffectsHiddenInEditor(); void SetEffectsHiddenInEditor(boolean enable); diff --git a/GDevelop.js/TestUtils/GDJSMocks.js b/GDevelop.js/TestUtils/GDJSMocks.js index 4b82d0c2527c..d0e12b505aa0 100644 --- a/GDevelop.js/TestUtils/GDJSMocks.js +++ b/GDevelop.js/TestUtils/GDJSMocks.js @@ -1080,6 +1080,11 @@ function makeMinimalGDJSMock(options) { ManuallyResolvableTask, Variable, VariablesContainer, + // Deprecated-Or-picking compatibility flag, defaults to false here. + // Tests that need to exercise the pre-5.6.269 picking semantics flip + // this on the returned `gdjs` object before calling the compiled + // events, and reset it afterwards. + useDeprecatedOrConditionPicking: false, }, mocks: { runRuntimeScenePreEventsCallbacks: () => { diff --git a/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationStressTests.js b/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationStressTests.js new file mode 100644 index 000000000000..c72ce591e56b --- /dev/null +++ b/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationStressTests.js @@ -0,0 +1,321 @@ +/** + * Stress tests for the object-picking semantics of the "Or" and + * "OrDistributive" sub-event conditions, focused on the per-object + * union dedup that runs at the end of every Or branch. + * + * The dedup loop pushes each branch's contributed instances into the + * per-object "final" list while skipping ones already present. The + * Or codegen uses a Set to do the "already present" check in O(1), + * which turns the overall dedup work for an Or with K branches each + * contributing N instances from O(K · N²) (pre-optimization, when the + * check was an `indexOf` scan over the growing final array) to + * O(K · N). + * + * The tests below pick K and N high enough that the difference between + * O(K · N²) and O(K · N) is meaningful (millions of comparisons vs. + * thousands of Set ops). Each test asserts on the per-instance Touched + * variable so the dedup at scale is verified for correctness, not just + * for runtime. + */ + +const initializeGDevelopJs = require('../../Binaries/embuild/GDevelop.js/libGD.js'); +const { makeMinimalGDJSMock } = require('../TestUtils/GDJSMocks'); +const { + generateCompiledEventsFromSerializedEvents, +} = require('../TestUtils/CodeGenerationHelpers.js'); + +describe('libGD.js - GDJS Or object picking stress tests', () => { + let gd = null; + beforeAll(async () => { + gd = await initializeGDevelopJs(); + }); + + /** + * Generic stress-test fixture. Pass an object spec of the form + * { ObjectName: arrayOfMyVariableValuesPerInstance, ... } + * and the supplied events run with `ObjectName` parameters and the + * matching number of pre-picked instances. Returns the runtime scene + * and the per-object instance arrays so tests can inspect each + * instance's Touched variable. + */ + const runEventsWithObjects = (events, objectsSpec) => { + const serializerElement = gd.Serializer.fromJSObject(events); + const parameterTypes = {}; + for (const objectName in objectsSpec) { + parameterTypes[objectName] = 'object'; + } + const runCompiledEvents = generateCompiledEventsFromSerializedEvents( + gd, + serializerElement, + { parameterTypes, logCode: false } + ); + + const { gdjs, runtimeScene } = makeMinimalGDJSMock(); + + const allInsts = {}; + const argLists = []; + for (const objectName in objectsSpec) { + const insts = objectsSpec[objectName].map((value) => { + const obj = runtimeScene.createObject(objectName); + obj.getVariables().get('MyVariable').setNumber(value); + return obj; + }); + const lists = new gdjs.Hashtable(); + // Pre-pick every instance so the picked list starts populated. + lists.put(objectName, insts.slice()); + allInsts[objectName] = insts; + argLists.push(lists); + } + + runCompiledEvents(gdjs, runtimeScene, argLists); + return { runtimeScene, allInsts }; + }; + + const touchedFlags = (insts) => + insts.map((o) => o.getVariables().get('Touched').getAsNumber()); + + const pickByVar = (objectName, expectedValue) => ({ + type: { value: 'VarObjet' }, + parameters: [objectName, 'MyVariable', '=', String(expectedValue)], + }); + const freeCondition = (alwaysTrue) => ({ + type: { value: 'Egal' }, + parameters: ['1', '=', alwaysTrue ? '1' : '0'], + }); + const touch = (objectName) => ({ + type: { value: 'ModVarObjet' }, + parameters: [objectName, 'Touched', '+', '1'], + }); + const orOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::Or' }, + parameters: [], + subInstructions, + }); + const orDistributiveOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::OrDistributive' }, + parameters: [], + subInstructions, + }); + const andOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::And' }, + parameters: [], + subInstructions, + }); + + /* ------------------------------------------------------------------ */ + /* 1. Or — many branches all picking the same large set of instances. */ + /* */ + /* Worst-case dedup scenario for the regular Or: every branch */ + /* contributes the same N instances, so the final list never */ + /* grows but every push is rejected by the dedup check. + /* O(K · N) (Set membership check). + /* ------------------------------------------------------------------ */ + it('Or: 100 identical branches each picking 20000 instances', () => { + const N = 20000; + const K = 100; + const aValues = new Array(N).fill(1); // every instance matches MyVariable=1 + const branches = new Array(K).fill(null).map(() => pickByVar('ObjectA', 1)); + + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(...branches)], + actions: [touch('ObjectA')], + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, { ObjectA: aValues }); + + const touched = touchedFlags(allInsts.ObjectA); + expect(touched.length).toBe(N); + // Every instance touched exactly once — the dedup must not let any + // duplicate through, and must not drop any either. + expect(touched.every((t) => t === 1)).toBe(true); + // Sum sanity-check (cheap to compute and protects against the + // tautological `every === 1` if Touched ever became something odd). + expect(touched.reduce((a, b) => a + b, 0)).toBe(N); + }); + + /* ------------------------------------------------------------------ */ + /* 2. OrDistributive — many branches, one referencing each of many */ + /* objects, plus one free branch that contributes parent.X for */ + /* every object it doesn't reference. */ + /* */ + /* OrDistributive's per-branch contribution iterates over */ + /* `allReferencedObjects`, so every true branch pushes every */ + /* union object's list into the corresponding final list. With K */ + /* objects, M branches, and N instances per object that's */ + /* M · K · N dedup attempts. With M = 5, K = 5, N = 500 that's */ + /* 12 500 attempts of which 2500 are unique — the Set keeps each */ + /* O(1). */ + /* ------------------------------------------------------------------ */ + it('OrDistributive: 5 branches × 5 objects × 500 instances → every object fully picked once', () => { + const N = 500; + const objectNames = [ + 'ObjectA', + 'ObjectB', + 'ObjectC', + 'ObjectD', + 'ObjectE', + ]; + const objectsSpec = {}; + objectNames.forEach((name) => { + objectsSpec[name] = new Array(N).fill(1); + }); + + // 5 branches, each picks one of the 5 objects. Every branch is + // true (every instance has MyVariable=1), so each branch contributes + // its own filtered object's instances and parent's full list for the + // four other "unconstrained" objects → 5 × 5 × 500 = 12 500 + // contributions per Or, deduplicated to 5 × 500 = 2500 picks. + const branches = objectNames.map((name) => pickByVar(name, 1)); + + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(...branches)], + actions: objectNames.map(touch), + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, objectsSpec); + + for (const objectName of objectNames) { + const touched = touchedFlags(allInsts[objectName]); + expect(touched.length).toBe(N); + expect(touched.every((t) => t === 1)).toBe(true); + } + }); + + /* ------------------------------------------------------------------ */ + /* 3. Or — many branches each picking a partially overlapping subset */ + /* of one large object. Shows that dedup correctness is preserved */ + /* across non-trivial overlap, not just full overlap. */ + /* */ + /* Each instance has MyVariable in [0, K). Branch i picks */ + /* MyVariable = i, so each instance ends up in exactly one */ + /* branch's filtered list — the union is a partition of the */ + /* instances. + /* ------------------------------------------------------------------ */ + it('Or: many partition branches over many instances → every instance touched once', () => { + const K = 100; + const N = 200; + const aValues = []; + for (let i = 0; i < K; i++) { + for (let j = 0; j < N; j++) aValues.push(i); + } + const branches = []; + for (let i = 0; i < K; i++) branches.push(pickByVar('ObjectA', i)); + + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(...branches)], + actions: [touch('ObjectA')], + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, { ObjectA: aValues }); + + const touched = touchedFlags(allInsts.ObjectA); + expect(touched.length).toBe(K * N); + expect(touched.every((t) => t === 1)).toBe(true); + }); + + /* ------------------------------------------------------------------ */ + /* 4. OrDistributive — many free branches that all leave A */ + /* unconstrained. Every true free branch contributes the parent's */ + /* full A list, so the union accumulates K copies of N instances */ + /* that all dedup back to N. + /* K · N Set ops. + /* ------------------------------------------------------------------ */ + it('OrDistributive: 1 picking branch + 100 free branches over 20000 instances → all touched once', () => { + const N = 20000; + const numFreeBranches = 100; + const aValues = new Array(N).fill(1); + + const branches = [pickByVar('ObjectA', 1)]; + for (let i = 0; i < numFreeBranches; i++) { + branches.push(freeCondition(true)); + } + + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(...branches)], + actions: [touch('ObjectA')], + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, { ObjectA: aValues }); + + const touched = touchedFlags(allInsts.ObjectA); + expect(touched.length).toBe(N); + expect(touched.every((t) => t === 1)).toBe(true); + }); + + /* ------------------------------------------------------------------ */ + /* 5. Or with And-inside — multi-condition branches that pick several */ + /* objects together at scale. + /* ------------------------------------------------------------------ */ + it('Or: And-branches inside', () => { + const K = 15; + const PER_BRANCH = 250; + // Instances are partitioned by branch: aValues = [0,...,0, 1,...,1, ...]. + // Branch i is And{ pickA=i, pickB=i }, picking its own A-slice and + // B-slice. Every instance is in exactly one branch's slice, so the + // unions across branches add up to K · PER_BRANCH for both A and B. + const aValues = []; + const bValues = []; + for (let i = 0; i < K; i++) { + for (let j = 0; j < PER_BRANCH; j++) { + aValues.push(i); + bValues.push(i); + } + } + const branches = []; + for (let i = 0; i < K; i++) { + branches.push(andOf(pickByVar('ObjectA', i), pickByVar('ObjectB', i))); + } + + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(...branches)], + actions: [touch('ObjectA'), touch('ObjectB')], + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, { + ObjectA: aValues, + ObjectB: bValues, + }); + + const total = K * PER_BRANCH; + const aTouched = touchedFlags(allInsts.ObjectA); + const bTouched = touchedFlags(allInsts.ObjectB); + expect(aTouched.length).toBe(total); + expect(bTouched.length).toBe(total); + expect(aTouched.every((t) => t === 1)).toBe(true); + expect(bTouched.every((t) => t === 1)).toBe(true); + }); + + /* ------------------------------------------------------------------ */ + /* 6. Pathological — many branches all picking the SAME single */ + /* instance. + /* ------------------------------------------------------------------ */ + it('Or: many branches all picking the same single instance → Touched = 1', () => { + const K = 1000; + const branches = new Array(K).fill(null).map(() => pickByVar('ObjectA', 1)); + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(...branches)], + actions: [touch('ObjectA')], + events: [], + }, + ]; + const { allInsts } = runEventsWithObjects(events, { ObjectA: [1] }); + expect(touchedFlags(allInsts.ObjectA)).toEqual([1]); + }); +}); diff --git a/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationTests.js b/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationTests.js new file mode 100644 index 000000000000..e29765a71586 --- /dev/null +++ b/GDevelop.js/__tests__/GDJSOrObjectPickingCodeGenerationIntegrationTests.js @@ -0,0 +1,1052 @@ +/** + * Regression tests for the object-picking semantics of the two "Or" sub-event + * conditions: + * + * - "BuiltinCommonInstructions::Or" (the existing Or, with a fix that stops + * overwriting the parent's picked list when no true branch contributed for a + * given object). Mental model: each branch contributes only the instances + * it actually picked; objects that no true branch referenced are left as + * they were before the Or. Use this when the action should act on the + * specific object whose state was tested in the branch that fired + * (Door/Coin pattern). + * + * - "BuiltinCommonInstructions::OrDistributive" (new condition). Mental + * model: a branch that does not constrain a given object behaves as if it + * contributed the parent's full picked list for that object. The picked + * list at the action is the union over all true branches. Use this when + * the action acts on objects that some other branch did pick but the + * firing branch doesn't reference (TextInput + SubmitButton pattern). + * + * Each test seeds three instances per object (MyVariable=1, 2 and 3) so a + * picking condition such as `VarObjet(ObjectX, "MyVariable", "=", N)` + * deterministically picks the Nth instance. After running, an + * `ModVarObjet(..., "Touched", "+", "1")` action is used per relevant object + * to reveal exactly which instances were picked at action time — the test + * asserts on the per-instance `Touched` array, not just on totals, so + * each instance's fate is checked individually. + */ + +const initializeGDevelopJs = require('../../Binaries/embuild/GDevelop.js/libGD.js'); +const { makeMinimalGDJSMock } = require('../TestUtils/GDJSMocks'); +const { + generateCompiledEventsFromSerializedEvents, +} = require('../TestUtils/CodeGenerationHelpers.js'); + +describe('libGD.js - GDJS Or object picking semantics integration tests', () => { + let gd = null; + beforeAll(async () => { + gd = await initializeGDevelopJs(); + }); + + /** + * Build a small project with three object parameters (ObjectA, ObjectB, + * ObjectC), seed each with three instances whose `MyVariable` is 1, 2 and + * 3 respectively, run the supplied events, and return both the runtime + * scene and the per-object instance arrays so tests can assert on the + * `Touched` flag of each individual instance. + */ + const runEventsWithThreeObjectsThreeInstances = (events, logCode) => { + const serializerElement = gd.Serializer.fromJSObject(events); + const runCompiledEvents = generateCompiledEventsFromSerializedEvents( + gd, + serializerElement, + { + parameterTypes: { + ObjectA: 'object', + ObjectB: 'object', + ObjectC: 'object', + }, + logCode: !!logCode, + } + ); + + const { gdjs, runtimeScene } = makeMinimalGDJSMock(); + + const makeInstances = (objectName) => { + const insts = [1, 2, 3].map((value) => { + const obj = runtimeScene.createObject(objectName); + obj.getVariables().get('MyVariable').setNumber(value); + return obj; + }); + const lists = new gdjs.Hashtable(); + // Pre-pick every instance so the picked list starts populated. The + // generated events function uses `eventsFunctionContext.getObjects` for + // the parameter as the source for picking, so a pre-populated list is + // what conditions filter against. + lists.put(objectName, insts.slice()); + return { insts, lists }; + }; + + const a = makeInstances('ObjectA'); + const b = makeInstances('ObjectB'); + const c = makeInstances('ObjectC'); + + runCompiledEvents(gdjs, runtimeScene, [a.lists, b.lists, c.lists]); + + return { + runtimeScene, + aInsts: a.insts, + bInsts: b.insts, + cInsts: c.insts, + }; + }; + + // Returns [touched1, touched2, touched3] for the given instance array, + // where each entry is the value of the "Touched" object variable. A 0 in + // the array means "the action did not run on this instance". + const touchedFlags = (insts) => + insts.map((o) => o.getVariables().get('Touched').getAsNumber()); + + const pickByVar = (objectName, expectedValue) => ({ + type: { value: 'VarObjet' }, + parameters: [objectName, 'MyVariable', '=', String(expectedValue)], + }); + const pickAByVar = (v) => pickByVar('ObjectA', v); + const pickBByVar = (v) => pickByVar('ObjectB', v); + const pickCByVar = (v) => pickByVar('ObjectC', v); + + // Inverted picking: keeps the *complement* (instances where MyVariable + // is NOT equal to the supplied value). + const invertedPickByVar = (objectName, expectedValue) => ({ + type: { inverted: true, value: 'VarObjet' }, + parameters: [objectName, 'MyVariable', '=', String(expectedValue)], + }); + const invertedPickAByVar = (v) => invertedPickByVar('ObjectA', v); + + // Free condition with constant truth value (does not reference any object). + const freeCondition = (alwaysTrue) => ({ + type: { value: 'Egal' }, + parameters: ['1', '=', alwaysTrue ? '1' : '0'], + }); + + // Action that increments the per-instance "Touched" variable. Runs once + // per picked instance. + const touch = (objectName) => ({ + type: { value: 'ModVarObjet' }, + parameters: [objectName, 'Touched', '+', '1'], + }); + const touchA = touch('ObjectA'); + const touchB = touch('ObjectB'); + const touchC = touch('ObjectC'); + + // Action that sets a scene variable, runs once regardless of picks. Used + // to confirm the Or's truth value (whether the action block ran at all). + const setScene = (name, value) => ({ + type: { value: 'ModVarScene' }, + parameters: [name, '=', String(value)], + }); + + const andOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::And' }, + parameters: [], + subInstructions, + }); + const orOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::Or' }, + parameters: [], + subInstructions, + }); + const orDistributiveOf = (...subInstructions) => ({ + type: { value: 'BuiltinCommonInstructions::OrDistributive' }, + parameters: [], + subInstructions, + }); + + /* ================================================================== */ + /* 1. Door/Coin — the preserve-picks Or scopes picks to the branch */ + /* that fired. Only the matching instance(s) get touched. */ + /* ================================================================== */ + describe('Or — Door/Coin (act on the touched object)', () => { + it('only branch A matches: only A2 is touched, no B is touched', () => { + // Branch A picks A=2 (matches A2). Branch B picks B=999 (no + // instance has var=999, so the branch is false). The action + // touches whatever survives in each picked list. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchB, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + + it('only branch B matches: only B3 is touched, no A is touched', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(999), pickBByVar(3))], + actions: [touchA, touchB, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 1]); + }); + + it('both branches match: only the picked instances get touched, others stay untouched', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickBByVar(2))], + actions: [touchA, touchB, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 1, 0]); + }); + }); + + /* ================================================================== */ + /* 2. AllowedArea pair — outside-Or picking that the Or must not */ + /* erase (the bug fix). Only the originally picked A2 should be */ + /* touched. */ + /* ================================================================== */ + describe('Or — preserves outside picks when no true branch contributed', () => { + it('keeps the outside-Or pick (A2) when only the free branch is true', () => { + // Outside the Or, ObjectA is filtered to A2. Inside the Or: + // branch 1: pickA=999 — references A but is false. + // branch 2: free, true — does not reference A. + // No true branch contributed for A, so the bug-fixed Or leaves + // the outside pick of A2 alone. Only A2 should be touched. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + pickAByVar(2), + orOf(pickAByVar(999), freeCondition(true)), + ], + actions: [touchA, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + }); + + it('keeps the outside-Or pick (A2) when the free branch comes BEFORE the false A-branch', () => { + // Branch order matters: this test exercises the boolean-reset + // fix. Without it, the true free branch would leak its truth + // value into the next branch's "if(isConditionTrue)" check, and + // the false pickA(999) branch would be wrongly treated as a + // contribution — collapsing parent.A to the empty filtered list. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + pickAByVar(2), + orOf(freeCondition(true), pickAByVar(999)), + ], + actions: [touchA, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + }); + }); + + /* ================================================================== */ + /* 3. All-false Or returns false (sanity check that the Or correctly */ + /* fails the parent event so the action does not run). */ + /* ================================================================== */ + describe('Or — all-false returns false', () => { + it('Or: action does not run when both branches are false', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(999), pickBByVar(999))], + actions: [touchA, touchB, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().has('OrFired')).toBe(false); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + + it('OrDistributive: action does not run when both branches are false', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(pickAByVar(999), pickBByVar(999))], + actions: [touchA, touchB, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().has('OrFired')).toBe(false); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + }); + + /* ================================================================== */ + /* 4. Input + SubmitButton — canonical case for OrDistributive. */ + /* A branch references A (and is false), the other branch does */ + /* not reference A. With OrDistributive every A is preserved at */ + /* action time; with the regular Or, parent.A collapses to empty. */ + /* ================================================================== */ + describe('OrDistributive vs Or — Input/Button (act on ObjectA regardless)', () => { + it('OrDistributive: every A stays picked when only the free branch is true', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf(pickAByVar(999), freeCondition(true)), + ], + actions: [touchA, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([1, 1, 1]); + }); + + it('OrDistributive: when both branches true, every A is still picked (union of constrained + unconstrained = all)', () => { + // Branch 1 contributes only A2. Branch 2 is unconstrained on A so + // it contributes the parent's full A list. Union = all three. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(pickAByVar(2), freeCondition(true))], + actions: [touchA, setScene('OrFired', 1)], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 1, 1]); + }); + + it('preserve-picks Or fails on this pattern: action runs on zero A (documents why a separate condition is needed)', () => { + // With the regular Or, branch 1 is false and branch 2 does not + // reference A. No true branch contributed for A, so the bug-fixed + // Or leaves A at whatever it was at the start of the Or — and + // because A is registered by the Or as "empty if just declared", + // that's the empty list. The action runs zero times. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(999), freeCondition(true))], + actions: [touchA, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts } = + runEventsWithThreeObjectsThreeInstances(events); + // The Or itself is true (the free branch is true), so the action + // block runs — but it sees no picked A. + expect(runtimeScene.getVariables().get('OrFired').getAsNumber()).toBe(1); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + }); + }); + + /* ================================================================== */ + /* 5. Distributive vs Or with the action acting on B, where exactly */ + /* one branch references B (and is false). This isolates the */ + /* distinguishing behavior on the action's target. */ + /* ================================================================== */ + describe('OrDistributive vs Or — action on B, only one branch references B and is false', () => { + it('OrDistributive: every B is touched (the unconstrained free branch contributes parent.B)', () => { + // Branch 1 references B and fails (no B has var=999). Branch 2 + // is free and true. Distributive Or treats branch 2 as + // unconstrained on B, contributing parent's full B list. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf(pickBByVar(999), freeCondition(true)), + ], + actions: [touchB], + events: [], + }, + ]; + const { bInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(bInsts)).toEqual([1, 1, 1]); + }); + + it('Or: zero B is touched on the same shape (the false B-branch wipes parent.B)', () => { + // Same shape with the regular Or: parent.B starts as the + // "empty if just declared" list (because the Or registers B), + // branch 1 is false → no contribution for B, branch 2 does not + // reference B → no contribution. parent.B stays empty. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickBByVar(999), freeCondition(true))], + actions: [touchB], + events: [], + }, + ]; + const { bInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + }); + + /* ================================================================== */ + /* 6. Sanity check: OrDistributive is NOT a drop-in replacement for */ + /* Or in the Door/Coin pattern. This test documents the leak. */ + /* ================================================================== */ + describe('OrDistributive — Door/Coin shows why both operators are needed', () => { + it('only A=2 matches but distributive Or also touches every B (use regular Or for this case)', () => { + // Branch 1 picks A=2. Branch 2 picks B=999 (false). With + // distributive semantics, branch 1 leaves B unconstrained and + // contributes B's entire parent list, so the action ends up + // touching every B. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([1, 1, 1]); + }); + }); + + /* ================================================================== */ + /* 7. Branches with `And` — a single branch can constrain several */ + /* objects at once. The branch is true only when every */ + /* sub-condition is true; when true, every sub-condition's picks */ + /* contribute to the corresponding object's picked list. */ + /* ================================================================== */ + describe('Or — branches with And { picks A and B together }', () => { + it('Or: each true And-branch contributes its own A and B picks; others untouched', () => { + // Branch 1: And{A=1, B=1} — both match → contribute A1, B1. + // Branch 2: And{A=3, B=3} — both match → contribute A3, B3. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orOf( + andOf(pickAByVar(1), pickBByVar(1)), + andOf(pickAByVar(3), pickBByVar(3)) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + expect(touchedFlags(bInsts)).toEqual([1, 0, 1]); + }); + + it('Or: And-branch is false if any sub-condition fails → no contribution from that branch', () => { + // Branch 1: And{A=1, B=1} — both match → contribute A1, B1. + // Branch 2: And{A=2, B=999} — A=2 picks A2 but B=999 picks + // nothing, so the And is false → no contribution. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orOf( + andOf(pickAByVar(1), pickBByVar(1)), + andOf(pickAByVar(2), pickBByVar(999)) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([1, 0, 0]); + }); + }); + + describe('OrDistributive — branches with And { picks A and B together }', () => { + it('OrDistributive: when every branch references both A and B, the result is identical to the regular Or', () => { + // The unconstrained-fill of OrDistributive only engages for an + // object that some branch leaves unreferenced. Here every branch + // references both A and B, so the result is identical to the + // regular Or above. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf( + andOf(pickAByVar(1), pickBByVar(1)), + andOf(pickAByVar(3), pickBByVar(3)) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + expect(touchedFlags(bInsts)).toEqual([1, 0, 1]); + }); + + it('OrDistributive: asymmetric branches — And{A=1,B=1} + pickA=3 → every B in the union, only the picked A', () => { + // Branch 1: And{A=1, B=1} — references both A and B, both true, + // contributes A1 + B1. + // Branch 2: pickA=3 — references only A, contributes A3 from + // its own pick AND parent's full B list (B is unconstrained + // on this branch). Union B = all three. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf( + andOf(pickAByVar(1), pickBByVar(1)), + pickAByVar(3) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + expect(touchedFlags(bInsts)).toEqual([1, 1, 1]); + }); + + it('Or: same asymmetric shape only touches the picked B (preserve-picks)', () => { + // Same branches as the asymmetric OrDistributive case above, but + // with the regular Or: branch 2 doesn't reference B, so it + // contributes nothing for B. Only A's union (A1, A3) and B1 + // survive. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orOf(andOf(pickAByVar(1), pickBByVar(1)), pickAByVar(3)), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + expect(touchedFlags(bInsts)).toEqual([1, 0, 0]); + }); + }); + + /* ================================================================== */ + /* 8. Unrelated objects — within the same event, picks for objects */ + /* the Or doesn't reference must follow their own conditions */ + /* (and lack of conditions = unconstrained). The Or must not */ + /* register or alter the picked list of an object it doesn't */ + /* constrain. */ + /* ================================================================== */ + describe('Or — unrelated ObjectC is untouched by the Or in the same event', () => { + it('no condition on C: action acts on every C even though the Or above on A/B is constrained', () => { + // The Or only references A and B. C has no condition, so the + // action should run on every C instance. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchB, touchC], + events: [], + }, + ]; + const { aInsts, bInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(cInsts)).toEqual([1, 1, 1]); + }); + + it('C picked by a sibling condition BEFORE the Or: only the picked C is touched, the Or does not erase it', () => { + // pickC=2 picks C2 outside the Or. Then the Or runs (true via + // branch A). The Or does not reference C, so C2 must survive + // into the action. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [pickCByVar(2), orOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchC], + events: [], + }, + ]; + const { aInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 1, 0]); + }); + + it('C picked by a sibling condition AFTER the Or: only the picked C is touched, the Or does not pre-empt it', () => { + // The Or runs first, then pickC=3 filters C. The Or must not + // have side-effected C's picked list in any way. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickBByVar(999)), pickCByVar(3)], + actions: [touchA, touchC], + events: [], + }, + ]; + const { aInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 0, 1]); + }); + }); + + describe('OrDistributive — unrelated ObjectC is untouched by the OrDistributive in the same event', () => { + it('no condition on C: action acts on every C even though OrDistributive above on A/B is constrained', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchB, touchC], + events: [], + }, + ]; + const { aInsts, bInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + // ObjectA picked from branch 1; ObjectB unconstrained on branch 1 + // (distributive) so all three B's contributed; C never referenced. + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([1, 1, 1]); + expect(touchedFlags(cInsts)).toEqual([1, 1, 1]); + }); + + it('C picked by a sibling condition BEFORE the OrDistributive: only the picked C is touched', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + pickCByVar(2), + orDistributiveOf(pickAByVar(2), pickBByVar(999)), + ], + actions: [touchA, touchC], + events: [], + }, + ]; + const { aInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 1, 0]); + }); + + it('C picked by a sibling condition AFTER the OrDistributive: only the picked C is touched', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf(pickAByVar(2), pickBByVar(999)), + pickCByVar(3), + ], + actions: [touchA, touchC], + events: [], + }, + ]; + const { aInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 0, 1]); + }); + }); + + /* ================================================================== */ + /* 9. Empty Or / OrDistributive — per the metadata, an Or with no */ + /* sub-conditions is always false. Confirm the parent event fails */ + /* and no action runs. */ + /* ================================================================== */ + describe('Or / OrDistributive — empty (no sub-conditions) is always false', () => { + it('Or with zero sub-conditions: action does not run', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf()], + actions: [touchA, touchB, touchC, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().has('OrFired')).toBe(false); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 0, 0]); + }); + + it('OrDistributive with zero sub-conditions: action does not run', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf()], + actions: [touchA, touchB, touchC, setScene('OrFired', 1)], + events: [], + }, + ]; + const { runtimeScene, aInsts, bInsts, cInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(runtimeScene.getVariables().has('OrFired')).toBe(false); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + expect(touchedFlags(cInsts)).toEqual([0, 0, 0]); + }); + }); + + /* ================================================================== */ + /* 10. Inverted sub-condition inside Or — for object-picking */ + /* conditions, the inversion picks the COMPLEMENT (instances */ + /* for which the predicate is false). The Or must propagate the */ + /* inverted branch's picks correctly. */ + /* */ + /* Note: setting `inverted: true` directly on Or/And/OrDistributive */ + /* itself is currently a no-op in GDevelop's custom lambdas (a */ + /* pre-existing limitation, not introduced by these changes); the */ + /* canonical way to invert an Or is to wrap it in a Not condition */ + /* and that path is covered by the existing Boolean-operators */ + /* test file. */ + /* ================================================================== */ + describe('Or / OrDistributive — inverted picking sub-condition picks the complement', () => { + it('Or { !pickA=2, false }: branch 1 picks A1 and A3 (complement of A=2)', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(invertedPickAByVar(2), freeCondition(false))], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + }); + + it('OrDistributive { !pickA=2, false }: branch 1 picks A1 and A3 (complement of A=2)', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf(invertedPickAByVar(2), freeCondition(false)), + ], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + }); + }); + + /* ================================================================== */ + /* 11. Parent pre-pick narrowing — when an outside-Or condition */ + /* narrows an object's picked list, OrDistributive must inherit */ + /* the narrowed list (not refill from scene). The unconstrained */ + /* branch contributes the parent's already-narrowed list. */ + /* ================================================================== */ + describe('OrDistributive — respects parent pre-pick narrowing', () => { + it('OrDistributive { pickB=999 [false], free [true] } after outside pickB=2: only B2 stays picked', () => { + // Outside the OrDistributive, pickB=2 narrows parent.B to [B2]. + // Branch 1 references B and is false. Branch 2 is free (true) and + // unconstrained on B — it must contribute parent's narrowed B + // ([B2]), NOT the scene's full B list. After the OrDistributive, + // parent.B should still be [B2], so only B2 gets touched. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + pickBByVar(2), + orDistributiveOf(pickBByVar(999), freeCondition(true)), + ], + actions: [touchB], + events: [], + }, + ]; + const { bInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(bInsts)).toEqual([0, 1, 0]); + }); + }); + + /* ================================================================== */ + /* 12. Nested OrDistributive — an OrDistributive inside an And */ + /* inside another OrDistributive must propagate context depth */ + /* and object-list copies correctly. */ + /* ================================================================== */ + describe('Or / OrDistributive — nested operators', () => { + it('Or wrapping And { pickA=1, inner OrDistributive { pickB=1, pickB=2 } }: inner OD union {B1,B2}, outer And true → A1, B1, B2 touched', () => { + // Outer Or has a single branch: And { pickA=1, OrDistributive { pickB=1, pickB=2 } }. + // The inner OrDistributive's two branches both reference B and + // pick B1 and B2 respectively → its union is [B1, B2]. The And's + // pickA=1 picks A1. Both And-children are true → outer branch + // contributes A1 and B1, B2. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orOf( + andOf( + pickAByVar(1), + orDistributiveOf(pickBByVar(1), pickBByVar(2)) + ) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 0]); + expect(touchedFlags(bInsts)).toEqual([1, 1, 0]); + }); + + it('OrDistributive wrapping And { pickA=1, inner OrDistributive { pickB=1, pickB=2 } } + pickA=3: distributive outer leaks unconstrained B for branch 2', () => { + // Outer is OrDistributive with two branches: + // branch 1: And { pickA=1, inner OrDistributive { pickB=1, pickB=2 } } + // → contributes A1 and B1, B2. + // branch 2: pickA=3 → references only A; B is unconstrained + // on this branch, so the parent's full B list (all + // three) is contributed. + // Final A = {A1, A3}; final B = union → all three. + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + orDistributiveOf( + andOf( + pickAByVar(1), + orDistributiveOf(pickBByVar(1), pickBByVar(2)) + ), + pickAByVar(3) + ), + ], + actions: [touchA, touchB], + events: [], + }, + ]; + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 1]); + expect(touchedFlags(bInsts)).toEqual([1, 1, 1]); + }); + }); + + /* ================================================================== */ + /* 13. Duplicate-instance union — two true branches that both pick */ + /* the same instance must not double-count it. The action is */ + /* declared once so the Touched variable should be 1 (not 2), */ + /* proving the per-object indexOf dedup at union time. */ + /* ================================================================== */ + describe('Or / OrDistributive — duplicate-instance union does not double-count', () => { + it('Or { pickA=2, pickA=2 }: A2 only touched once', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickAByVar(2))], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + }); + + it('OrDistributive { pickA=2, pickA=2 }: A2 only touched once', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orDistributiveOf(pickAByVar(2), pickAByVar(2))], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + }); + }); + + /* ================================================================== */ + /* 14. Residual non-equivalence between Or and OrDistributive even */ + /* when an outside-Or condition pre-narrows X — pins down the */ + /* "★ row 8" of the Setup B truth table. */ + /* */ + /* Outside narrows ObjectA to {A1, A2} (= S0). Then: */ + /* branch 1: pickA=1 — references A, true, narrows to {A1}. */ + /* branch 2: free, true — does not reference A. */ + /* */ + /* - Or commits to the narrower branch-1 pick: parent.A = {A1}. */ + /* - OrDistributive un-narrows back to S0: branch 2 contributes */ + /* parent.A unchanged, so the union is {A1, A2}. */ + /* */ + /* This case maps to the original Door/Coin vs Input/Button */ + /* intent split at the "subset of subset" scale; it is the only */ + /* residual difference between fixed Or and OrDistributive when */ + /* parent already has a pre-pick. */ + /* ================================================================== */ + describe('Or vs OrDistributive — residual non-equivalence even with outside pre-pick', () => { + it('Or: a true narrowing X-ref branch + a true free branch → parent.A narrows further to {A1}', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + invertedPickAByVar(3), // narrow A to {A1, A2} + orOf(pickAByVar(1), freeCondition(true)), + ], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 0, 0]); + }); + + it('OrDistributive: same shape → parent.A stays at the outside pre-pick {A1, A2}', () => { + const events = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + invertedPickAByVar(3), // narrow A to {A1, A2} + orDistributiveOf(pickAByVar(1), freeCondition(true)), + ], + actions: [touchA], + events: [], + }, + ]; + const { aInsts } = runEventsWithThreeObjectsThreeInstances(events); + expect(touchedFlags(aInsts)).toEqual([1, 1, 0]); + }); + }); + + /* ================================================================== */ + /* 15. Backwards compatibility — the project flag */ + /* `useDeprecatedOrConditionPicking` (set automatically for */ + /* projects from before 5.6.269) restores the pre-fix Or behavior */ + /* of unconditionally overwriting the parent's picked list with */ + /* the union of branch contributions, including empty ones. */ + /* */ + /* The runtime flag is read at the Or's final copy step. With */ + /* it set, parent.A gets wiped to ∅ in the AllowedArea pattern */ + /* (the original bug); with it unset (the default), the bug fix */ + /* preserves parent.A. The flag does not affect OrDistributive, */ + /* which is a separate condition with its own semantics. */ + /* ================================================================== */ + describe('Or — useDeprecatedOrConditionPicking project flag', () => { + // Run the supplied events with the flag temporarily flipped on, then + // restore so the rest of the suite stays in default-mode. + const runWithDeprecatedFlag = (events) => { + const serializerElement = gd.Serializer.fromJSObject(events); + const runCompiledEvents = generateCompiledEventsFromSerializedEvents( + gd, + serializerElement, + { + parameterTypes: { ObjectA: 'object', ObjectB: 'object', ObjectC: 'object' }, + logCode: false, + } + ); + const { gdjs, runtimeScene } = makeMinimalGDJSMock(); + + const aInsts = [1, 2, 3].map((value) => { + const obj = runtimeScene.createObject('ObjectA'); + obj.getVariables().get('MyVariable').setNumber(value); + return obj; + }); + const aLists = new gdjs.Hashtable(); + aLists.put('ObjectA', aInsts.slice()); + + const bInsts = [1, 2, 3].map((value) => { + const obj = runtimeScene.createObject('ObjectB'); + obj.getVariables().get('MyVariable').setNumber(value); + return obj; + }); + const bLists = new gdjs.Hashtable(); + bLists.put('ObjectB', bInsts.slice()); + + const cInsts = [1, 2, 3].map((value) => { + const obj = runtimeScene.createObject('ObjectC'); + obj.getVariables().get('MyVariable').setNumber(value); + return obj; + }); + const cLists = new gdjs.Hashtable(); + cLists.put('ObjectC', cInsts.slice()); + + gdjs.useDeprecatedOrConditionPicking = true; + try { + runCompiledEvents(gdjs, runtimeScene, [aLists, bLists, cLists]); + } finally { + gdjs.useDeprecatedOrConditionPicking = false; + } + return { aInsts, bInsts, cInsts }; + }; + + // Same shape as test 2 above (AllowedArea preservation): outside + // narrows A to A2; the Or is true via a free branch; an X-ref + // branch is false. With the flag OFF (default), the fix preserves + // A2. With the flag ON (legacy), the Or wipes A → ∅ — reproducing + // the pre-5.6.269 bug for projects that depend on it. + const allowedAreaEvents = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + pickAByVar(2), + orOf(pickAByVar(999), freeCondition(true)), + ], + actions: [touchA], + events: [], + }, + ]; + + it('flag OFF (default): bug-fix path — outside-Or pick of A2 is preserved', () => { + const { aInsts } = + runEventsWithThreeObjectsThreeInstances(allowedAreaEvents); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + }); + + it('flag ON: pre-fix legacy path — outside-Or pick of A2 is wiped (action sees no A)', () => { + const { aInsts } = runWithDeprecatedFlag(allowedAreaEvents); + expect(touchedFlags(aInsts)).toEqual([0, 0, 0]); + }); + + // Door/Coin pattern is unchanged by the flag — the pre-fix Or + // already produced the right result here, so flipping the flag must + // not regress it. + const doorCoinEvents = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [orOf(pickAByVar(2), pickBByVar(999))], + actions: [touchA, touchB], + events: [], + }, + ]; + + it('flag OFF: Door/Coin only-A-true → only A2 touched', () => { + const { aInsts, bInsts } = + runEventsWithThreeObjectsThreeInstances(doorCoinEvents); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + + it('flag ON: Door/Coin only-A-true → still only A2 touched (no regression on this pattern)', () => { + const { aInsts, bInsts } = runWithDeprecatedFlag(doorCoinEvents); + expect(touchedFlags(aInsts)).toEqual([0, 1, 0]); + expect(touchedFlags(bInsts)).toEqual([0, 0, 0]); + }); + }); +}); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 5712f7deed46..26c70fa25558 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -597,6 +597,8 @@ export class Project extends EmscriptenObject { getUseDeprecatedZeroAsDefaultZOrder(): boolean; setUseDeprecatedZeroAsDefaultStringVariable(enable: boolean): void; getUseDeprecatedZeroAsDefaultStringVariable(): boolean; + setUseDeprecatedOrConditionPicking(enable: boolean): void; + getUseDeprecatedOrConditionPicking(): boolean; areEffectsHiddenInEditor(): boolean; setEffectsHiddenInEditor(enable: boolean): void; setLastCompilationDirectory(path: string): void; diff --git a/GDevelop.js/types/gdproject.js b/GDevelop.js/types/gdproject.js index eb689fade349..63f9af61dbcf 100644 --- a/GDevelop.js/types/gdproject.js +++ b/GDevelop.js/types/gdproject.js @@ -54,6 +54,8 @@ declare class gdProject { getUseDeprecatedZeroAsDefaultZOrder(): boolean; setUseDeprecatedZeroAsDefaultStringVariable(enable: boolean): void; getUseDeprecatedZeroAsDefaultStringVariable(): boolean; + setUseDeprecatedOrConditionPicking(enable: boolean): void; + getUseDeprecatedOrConditionPicking(): boolean; areEffectsHiddenInEditor(): boolean; setEffectsHiddenInEditor(enable: boolean): void; setLastCompilationDirectory(path: string): void;