diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 602fa9bca845..70bf714cf276 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -99,7 +99,10 @@ export const useRefreshLimits = ( // All requests are made in orchestrator mode, and sub-agents (explorer, edit) // are created server-side with the same tools version as the orchestrator. -export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v9'; +// v11: `read_events_source` (this editor implements it) and the explicit +// replace relations of `generate_events` (keep or replace sub-events). +// v10 is skipped. +export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v11'; /** * A pending request for the user to approve (or refuse) a project-modifying diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index d97b6efeb147..da7b4162ce39 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -18,6 +18,10 @@ import { eventsTextRenderingErrorText, type EventsTextRenderingError, } from '../EventsSheet/EventsTree/TextRenderer'; +import { + buildEventScriptSourceView, + renderEventSourceById, +} from '../EventsSheet/EventsTree/TextRenderer/EventScriptSourceView'; import { addMissingObjectBehaviors, addObjectUndeclaredVariables, @@ -153,6 +157,11 @@ export type EditorFunctionGenericOutput = {| variables?: Array, reminder?: string, animationNames?: string, + // EventScript source view (see `read_events_source`): + eventScript?: string, + selectedEventIds?: Array, + truncated?: boolean, + notes?: Array, generatedEventsErrorDiagnostics?: string, aiGeneratedEventId?: string, warnings?: string, @@ -205,6 +214,11 @@ export type EventBatch = {| placementTargetEventId: string | null, placementExpectedParentEventId: string | null, placementRationale: string | null, + // Anchor echo for replace placements (proof the replaced event was read): + expectedEventSource: string | null, + // The actual current source of the target event, that the backend + // compares the anchor against: + placementTargetEventSource: string | null, |}; export type EventsGenerationOptions = {| @@ -4729,6 +4743,110 @@ const readSceneEvents: EditorFunction = { modifiesProject: false, }; +const EVENTS_SOURCE_MAX_CHARS_DEFAULT = 12000; +const EVENTS_SOURCE_MAX_CHARS_MINIMUM = 2000; +const EVENTS_SOURCE_MAX_CHARS_LIMIT = 30000; + +/** + * Reads the events of a scene as EventScript source (the exact syntax + * accepted by the `event_script` field of events generation), with filters + * to keep the output small. + */ +const readEventsSource: EditorFunction = { + renderForEditor: ({ args, editorCallbacks }) => { + const scene_name = extractRequiredString(args, 'scene_name'); + + return { + text: ( + + Read events source in scene{' '} + + editorCallbacks.onOpenLayout(scene_name, { + openEventsEditor: true, + openSceneEditor: true, + focusWhenOpened: 'events', + }) + } + > + {scene_name} + + . + + ), + }; + }, + launchFunction: async ({ project, args }) => { + const scene_name = extractRequiredString(args, 'scene_name'); + + if (!project.hasLayoutNamed(scene_name)) { + return makeSceneNotFoundFailure(project, scene_name); + } + + const scene = project.getLayout(scene_name); + const eventIds = SafeExtractor.extractStringArrayProperty( + args, + 'event_ids' + ); + const searchText = SafeExtractor.extractStringProperty(args, 'search'); + const objectNames = SafeExtractor.extractStringArrayProperty( + args, + 'object_names' + ); + const subEventsDepth = SafeExtractor.extractNumberProperty( + args, + 'sub_events_depth' + ); + const maxCharsArgument = SafeExtractor.extractNumberProperty( + args, + 'max_chars' + ); + const maxChars = Math.max( + EVENTS_SOURCE_MAX_CHARS_MINIMUM, + Math.min( + EVENTS_SOURCE_MAX_CHARS_LIMIT, + maxCharsArgument || EVENTS_SOURCE_MAX_CHARS_DEFAULT + ) + ); + + const { + text, + selectedEventIds, + truncated, + notes, + renderingErrors, + } = buildEventScriptSourceView({ + eventsList: scene.getEvents(), + eventIds, + searchText, + objectNames, + subEventsDepth, + maxChars, + }); + + const output: EditorFunctionGenericOutput = { + success: true, + eventsForSceneNamed: scene_name, + // An empty `text` does NOT mean the scene has no events: a filter can + // match nothing on a populated sheet (the notes say which case it + // is). Only a truly empty sheet gets the "no events" text. + eventScript: + text || + (scene.getEvents().getEventsCount() === 0 ? noEventsInSceneText : ''), + selectedEventIds, + }; + if (truncated) output.truncated = true; + if (notes.length > 0) output.notes = notes; + if (renderingErrors.length > 0) { + // Surface partial failures so the cause is reported, not dropped. + output.eventsRenderingErrors = renderingErrors; + } + return output; + }, + modifiesProject: false, +}; + /** * Adds a new event to a scene's event sheet */ @@ -5048,6 +5166,44 @@ const addSceneEvents: EditorFunction = { const parsedEventBatches = eventBatches ? eventBatches.map(batch => { + const placementRelation = + SafeExtractor.extractStringProperty(batch, 'placement_relation') || + '(unspecified)'; + const placementTargetEventId = SafeExtractor.extractStringProperty( + batch, + 'placement_target_event_id' + ); + + // For replace placements, also send the CURRENT source of what is + // being replaced: the backend compares the + // `expected_event_source` anchor against it (proof it was read + // and hasn't changed). The source covers exactly what the + // placement destroys: the event alone when its sub-events are + // kept, the whole subtree when they are replaced too. + const isReplaceEntirePlacement = + placementRelation === 'replace_entire_event_and_sub_events'; + const isReplacePlacement = + placementRelation === + 'replace_event_but_keep_existing_sub_events' || + isReplaceEntirePlacement; + const renderedTargetEventSource = + isReplacePlacement && placementTargetEventId + ? renderEventSourceById({ + eventsList: currentSceneEvents, + eventIdOrGroupName: placementTargetEventId, + includeSubEvents: isReplaceEntirePlacement, + }) + : null; + // A subtree too big to be read in one call cannot serve as the + // proof-of-read reference either (the backend bounds the field): + // skip the check for it (like an editor without the capability) + // rather than failing the whole request. + const placementTargetEventSource = + renderedTargetEventSource && + renderedTargetEventSource.length <= EVENTS_SOURCE_MAX_CHARS_LIMIT + ? renderedTargetEventSource + : null; + return { eventsDescription: SafeExtractor.extractStringProperty( @@ -5058,15 +5214,8 @@ const addSceneEvents: EditorFunction = { batch, 'event_script' ), - placementRelation: - SafeExtractor.extractStringProperty( - batch, - 'placement_relation' - ) || '(unspecified)', - placementTargetEventId: SafeExtractor.extractStringProperty( - batch, - 'placement_target_event_id' - ), + placementRelation, + placementTargetEventId, placementExpectedParentEventId: SafeExtractor.extractStringProperty( batch, 'placement_expected_parent_event_id' @@ -5075,6 +5224,11 @@ const addSceneEvents: EditorFunction = { batch, 'placement_rationale' ), + expectedEventSource: SafeExtractor.extractStringProperty( + batch, + 'expected_event_source' + ), + placementTargetEventSource, }; }) : null; @@ -7740,6 +7894,7 @@ export const editorFunctions: { [string]: EditorFunction } = { put_2d_instances: put2dInstances, put_3d_instances: put3dInstances, read_scene_events: readSceneEvents, + read_events_source: readEventsSource, add_scene_events: addSceneEvents, create_scene: createScene, inspect_scene_properties_layers_effects: inspectScenePropertiesLayersEffects, diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json new file mode 100644 index 000000000000..8d41f622ac12 --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json @@ -0,0 +1,271 @@ +{ + "note": "Conformance fixtures shared between the backend EventScript serializer (events-script-serializer.js in GDevelop-services) and the editor-side renderer (EventScriptRenderer.js in the GDevelop repository). The SAME serialized events must render to the SAME EventScript on both sides. Keep this file byte-identical with its copy: newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json (GDevelop repository).", + "fixtures": [ + { + "name": "hidden parameters, trigger once and a sub-event", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { "value": "Timer" }, + "parameters": ["", "2", "\"SpawnTimer\""] + }, + { + "type": { "value": "BuiltinCommonInstructions::Once" }, + "parameters": [] + } + ], + "actions": [ + { + "type": { "value": "ResetTimer" }, + "parameters": ["", "\"SpawnTimer\""] + }, + { + "type": { "value": "CentreCamera" }, + "parameters": ["", "MySpriteObject", "", "", ""] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { "value": "PlatformBehavior::IsFalling" }, + "parameters": ["MySpriteObject", "PlatformerObject"] + } + ], + "actions": [ + { + "type": { "value": "Delete" }, + "parameters": ["MySpriteObject", ""] + } + ] + } + ] + } + ], + "expectedEventScript": [ + "if Timer(2, \"SpawnTimer\") and once:", + " ResetTimer(\"SpawnTimer\")", + " CentreCamera(MySpriteObject)", + " if PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject):", + " Delete(MySpriteObject)" + ] + }, + { + "name": "inverted conditions and or", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "PlatformBehavior::IsFalling", + "inverted": true + }, + "parameters": ["MySpriteObject", "PlatformerObject"] + }, + { + "type": { "value": "BuiltinCommonInstructions::Or" }, + "parameters": [], + "subInstructions": [ + { "type": { "value": "DepartScene" }, "parameters": [""] }, + { + "type": { "value": "Timer", "inverted": true }, + "parameters": ["", "2", "\"T\""] + } + ] + } + ], + "actions": [] + } + ], + "expectedEventScript": [ + "if not PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject) and Or(DepartScene(), not Timer(2, \"T\")):", + " pass" + ] + }, + { + "name": "comments, local variables, else, loops, group and disabled events", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Comment", + "comment": "Setup\nwith a second line" + }, + { + "type": "BuiltinCommonInstructions::Standard", + "variables": [ + { "name": "Count", "type": "number", "value": "3" }, + { "name": "Title", "type": "string", "value": "Hello \"world\"" } + ], + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::Else", + "conditions": [ + { "type": { "value": "DepartScene" }, "parameters": [""] } + ], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::ForEach", + "object": "MySpriteObject", + "conditions": [], + "actions": [ + { + "type": { "value": "Delete" }, + "parameters": ["MySpriteObject", ""] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Repeat", + "repeatExpression": "4 + 3", + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::While", + "whileConditions": [ + { "type": { "value": "DepartScene" }, "parameters": [""] } + ], + "conditions": [], + "actions": [{ "type": { "value": "Wait" }, "parameters": ["1"] }] + }, + { + "type": "BuiltinCommonInstructions::ForEachChildVariable", + "iterableVariableName": "Inventory", + "valueIteratorVariableName": "Item", + "keyIteratorVariableName": "", + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::Group", + "name": "My group", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { "value": "Delete" }, + "parameters": ["MySpriteObject", ""] + } + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "disabled": true, + "conditions": [], + "actions": [{ "type": { "value": "Wait" }, "parameters": ["1"] }] + } + ], + "expectedEventScript": [ + "comment \"Setup\\nwith a second line\"", + "always:", + " local number Count = 3", + " local string Title = \"Hello \\\"world\\\"\"", + "else if DepartScene():", + " pass", + "for each MySpriteObject:", + " Delete(MySpriteObject)", + "repeat 4 + 3 times:", + " pass", + "while DepartScene():", + " Wait(1)", + "for each child in Inventory value Item:", + " pass", + "group \"My group\":", + " always:", + " Delete(MySpriteObject)", + "disabled always:", + " Wait(1)" + ] + }, + { + "name": "degenerate loops (freshly created in the editor, fields still empty) stay parseable", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::While", + "whileConditions": [], + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::Repeat", + "repeatExpression": "", + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::ForEach", + "object": "", + "conditions": [], + "actions": [] + }, + { + "type": "BuiltinCommonInstructions::ForEachChildVariable", + "iterableVariableName": "", + "conditions": [], + "actions": [] + } + ], + "expectedEventScript": [ + "while :", + " pass", + "repeat 0 times:", + " pass", + "for each :", + " pass", + "for each child in :", + " pass" + ] + }, + { + "name": "empty group renders an explicit pass", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Group", + "name": "Empty group", + "events": [] + } + ], + "expectedEventScript": ["group \"Empty group\":", " pass"] + }, + { + "name": "unknown instruction and non-representable event", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { "value": "SomeUnknownExtension::Unknown" }, + "parameters": ["A", "", "B"] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": "runtimeScene.setBackgroundColor(255, 0, 0);" + } + ], + "expectedEventScript": [ + "always:", + " SomeUnknownExtension::Unknown(A, , B)", + "# (event of type \"BuiltinCommonInstructions::JsCode\" cannot be shown as EventScript)" + ] + } + ] +} diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js new file mode 100644 index 000000000000..d56516d93528 --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js @@ -0,0 +1,633 @@ +// @flow +// Renders events as EventScript source (the syntax accepted by the +// `event_script` field of events generation - see `events-script.js` in the +// GDevelop-services repository, which documents the grammar). +// +// This module mirrors the backend serializer (`events-script-serializer.js` +// in GDevelop-services): same function names, same rendering rules - only +// the inputs differ (gd objects + platform metadata here, events JSON + +// features summary there). Both are held together by the SHARED conformance +// fixtures (`EventScriptRenderer.fixtures.json`, byte-identical in both +// repositories): any rendering change must update the fixtures on both +// sides. See the README next to the backend parser for the full map of the +// EventScript ecosystem. +import { mapFor, mapVector } from '../../../Utils/MapFor'; + +const gd: libGDevelop = global.gd; + +// A failure to render an event/instruction as EventScript, with `path` +// locating the node (same paths as the events text renderer: `event-1.2`). +export type EventScriptRenderingError = {| + path: string, + message: string, +|}; + +const getErrorMessage = (error: mixed): string => + error && typeof error === 'object' && typeof error.message === 'string' + ? error.message + : String(error); + +const INDENT = ' '; + +/** + * Escape a raw parameter value so it stays on a single line (the EventScript + * grammar is line-based). Real newlines only legitimately appear inside + * string literals, where the `\n` escape is understood by the parser (and by + * the compilation, which turns it into `NewLine()`). + */ +const escapeParameterValue = (value: string): string => + value.replace(/\r?\n/g, '\\n'); + +/** + * Escape the content of a double-quoted EventScript string literal + * (used for `comment "..."`, `group "..."`, `link "..."`). + */ +const escapeStringLiteral = (value: string): string => + value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r?\n/g, '\\n'); + +/** + * Render the arguments of an instruction call, keeping only the "visible" + * parameters (code-only parameters are hidden slots filled by the engine: + * the EventScript compilation re-inserts them). Trailing empty optional + * parameters are dropped for brevity (the compilation refills them). + */ +const renderInstructionArguments = ( + instruction: gdInstruction, + metadata: gdInstructionMetadata +): Array => { + const instructionParametersCount = instruction.getParametersCount(); + const values: Array = []; + + if (gd.MetadataProvider.isBadInstructionMetadata(metadata)) { + // Unknown instruction: keep every parameter as-is (we cannot know which + // slots are code-only). + for (let index = 0; index < instructionParametersCount; index++) { + values.push( + escapeParameterValue(instruction.getParameter(index).getPlainString()) + ); + } + } else { + const metadataParametersCount = metadata.getParametersCount(); + for (let index = 0; index < metadataParametersCount; index++) { + const parameterMetadata = metadata.getParameter(index); + if (parameterMetadata.isCodeOnly()) continue; + + const value = + index < instructionParametersCount + ? instruction.getParameter(index).getPlainString() + : ''; + values.push(escapeParameterValue(value)); + } + // Extra parameters not declared by the metadata (can happen with stale + // projects): keep them rather than silently losing values. + for ( + let index = metadataParametersCount; + index < instructionParametersCount; + index++ + ) { + values.push( + escapeParameterValue(instruction.getParameter(index).getPlainString()) + ); + } + } + + // Trailing empty values (bare `` or quoted `""`) are refilled by the + // compilation: drop them for brevity. + while ( + values.length > 0 && + (values[values.length - 1].trim() === '' || + values[values.length - 1].trim() === '""') + ) { + values.pop(); + } + return values; +}; + +const ONCE_TYPE = 'BuiltinCommonInstructions::Once'; +const OR_TYPE = 'BuiltinCommonInstructions::Or'; +const AND_TYPE = 'BuiltinCommonInstructions::And'; +const NOT_TYPE = 'BuiltinCommonInstructions::Not'; + +/** + * Render one condition as an EventScript condition expression + * (`ConditionName(args)`, `once`, `not X`, `Or(...)`, `(a and b)`). + */ +const renderConditionExpression = ( + instruction: gdInstruction, + eventPath: string, + renderingErrors: Array +): string => { + const type = instruction.getType(); + + if (type === ONCE_TYPE) { + return instruction.isInverted() ? 'not once' : 'once'; + } + if (type === OR_TYPE || type === AND_TYPE || type === NOT_TYPE) { + const operands = mapFor(0, instruction.getSubInstructions().size(), i => + renderConditionExpression( + instruction.getSubInstructions().get(i), + eventPath, + renderingErrors + ) + ); + let rendered; + if (type === OR_TYPE) { + rendered = `Or(${operands.join(', ')})`; + } else if (type === AND_TYPE) { + rendered = + operands.length === 0 ? 'And()' : `(${operands.join(' and ')})`; + } else { + rendered = + operands.length === 0 + ? 'Not()' + : operands.length === 1 + ? `not ${operands[0]}` + : `not (${operands.join(' and ')})`; + } + return instruction.isInverted() ? `not ${rendered}` : rendered; + } + + const metadata = gd.MetadataProvider.getConditionMetadata( + gd.JsPlatform.get(), + type + ); + const args = renderInstructionArguments(instruction, metadata); + const call = `${type}(${args.join(', ')})`; + return instruction.isInverted() ? `not ${call}` : call; +}; + +/** + * Render a conditions list as a single EventScript condition expression + * (conditions of a list are an implicit AND). + */ +const renderConditionsListExpression = ( + conditionsList: gdInstructionsList, + eventPath: string, + renderingErrors: Array +): string => { + return mapFor(0, conditionsList.size(), i => + renderConditionExpression(conditionsList.get(i), eventPath, renderingErrors) + ).join(' and '); +}; + +const renderActionLine = ( + instruction: gdInstruction, + eventPath: string, + renderingErrors: Array +): string => { + const type = instruction.getType(); + const metadata = gd.MetadataProvider.getActionMetadata( + gd.JsPlatform.get(), + type + ); + const args = renderInstructionArguments(instruction, metadata); + const awaitPrefix = instruction.isAwaited() ? 'await ' : ''; + return `${awaitPrefix}${type}(${args.join(', ')})`; +}; + +// $FlowFixMe[recursive-definition] +const convertVariableToJsObject = (variable: gdVariable) => { + if (variable.getType() === gd.Variable.String) { + return variable.getString(); + } else if (variable.getType() === gd.Variable.Number) { + return variable.getValue(); + } else if (variable.getType() === gd.Variable.Boolean) { + return variable.getBool(); + } else if (variable.getType() === gd.Variable.Structure) { + const childrenNames = variable.getAllChildrenNames().toJSArray(); + const object = {}; + childrenNames.forEach(childName => { + // $FlowFixMe[prop-missing] + object[childName] = convertVariableToJsObject( + variable.getChild(childName) + ); + }); + return object; + } else if (variable.getType() === gd.Variable.Array) { + const children = variable.getAllChildrenArray(); + return mapVector(children, child => convertVariableToJsObject(child)); + } + + // Should not happen: + return variable.getValue(); +}; + +const renderLocalVariableLines = ( + variables: gdVariablesContainer +): Array => { + return mapFor(0, variables.count(), i => { + const variable = variables.getAt(i); + const variableName = variables.getNameAt(i); + const type = gd.Variable.typeAsString(variable.getType()); + const value = JSON.stringify(convertVariableToJsObject(variable)); + return `local ${type} ${variableName} = ${value}`; + }); +}; + +/** + * The header of an event and its actions list, in a form ready to be + * rendered as EventScript. `header` is without indentation, `disabled` + * prefix, trailing `:` and id annotation. Events without a body (comment, + * link) have `standaloneLine` instead. + */ +type EventScriptParts = {| + header?: string, + standaloneLine?: string, + actionsList?: gdInstructionsList, +|}; + +const buildEventScriptParts = ( + event: gdBaseEvent, + eventPath: string, + renderingErrors: Array +): EventScriptParts | null => { + const type = event.getType(); + + if (type === 'BuiltinCommonInstructions::Standard') { + const standardEvent = gd.asStandardEvent(event); + const conditions = renderConditionsListExpression( + standardEvent.getConditions(), + eventPath, + renderingErrors + ); + return { + header: conditions ? `if ${conditions}` : 'always', + actionsList: standardEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::Else') { + const elseEvent = gd.asElseEvent(event); + const conditions = renderConditionsListExpression( + elseEvent.getConditions(), + eventPath, + renderingErrors + ); + return { + header: conditions ? `else if ${conditions}` : 'else', + actionsList: elseEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::While') { + const whileEvent = gd.asWhileEvent(event); + const whileConditions = renderConditionsListExpression( + whileEvent.getWhileConditions(), + eventPath, + renderingErrors + ); + const conditions = renderConditionsListExpression( + whileEvent.getConditions(), + eventPath, + renderingErrors + ); + const indexClause = whileEvent.getLoopIndexVariableName() + ? ` index ${whileEvent.getLoopIndexVariableName()}` + : ''; + const ifClause = conditions ? ` if ${conditions}` : ''; + return { + header: `while ${whileConditions}${indexClause}${ifClause}`, + actionsList: whileEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::Repeat') { + const repeatEvent = gd.asRepeatEvent(event); + const conditions = renderConditionsListExpression( + repeatEvent.getConditions(), + eventPath, + renderingErrors + ); + const indexClause = repeatEvent.getLoopIndexVariableName() + ? ` index ${repeatEvent.getLoopIndexVariableName()}` + : ''; + const ifClause = conditions ? ` if ${conditions}` : ''; + // An empty repeat expression (a repeat event just created in the + // editor) renders as `repeat 0 times` so the output stays parseable. + return { + header: `repeat ${repeatEvent.getRepeatExpression().getPlainString() || + '0'} times${indexClause}${ifClause}`, + actionsList: repeatEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::ForEach') { + const forEachEvent = gd.asForEachEvent(event); + const conditions = renderConditionsListExpression( + forEachEvent.getConditions(), + eventPath, + renderingErrors + ); + const orderBy = forEachEvent.getOrderBy(); + const orderClause = orderBy + ? ` order by ${orderBy} ${ + forEachEvent.getOrder() === 'desc' ? 'desc' : 'asc' + }` + : ''; + const limitClause = forEachEvent.getLimit() + ? ` limit ${forEachEvent.getLimit()}` + : ''; + const indexClause = forEachEvent.getLoopIndexVariableName() + ? ` index ${forEachEvent.getLoopIndexVariableName()}` + : ''; + const ifClause = conditions ? ` if ${conditions}` : ''; + return { + header: `for each ${forEachEvent.getObjectToPick()}${orderClause}${limitClause}${indexClause}${ifClause}`, + actionsList: forEachEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::ForEachChildVariable') { + const forEachChildVariableEvent = gd.asForEachChildVariableEvent(event); + const conditions = renderConditionsListExpression( + forEachChildVariableEvent.getConditions(), + eventPath, + renderingErrors + ); + const valueClause = forEachChildVariableEvent.getValueIteratorVariableName() + ? ` value ${forEachChildVariableEvent.getValueIteratorVariableName()}` + : ''; + const keyClause = forEachChildVariableEvent.getKeyIteratorVariableName() + ? ` key ${forEachChildVariableEvent.getKeyIteratorVariableName()}` + : ''; + const indexClause = forEachChildVariableEvent.getLoopIndexVariableName() + ? ` index ${forEachChildVariableEvent.getLoopIndexVariableName()}` + : ''; + const ifClause = conditions ? ` if ${conditions}` : ''; + return { + header: `for each child in ${forEachChildVariableEvent.getIterableVariableName()}${valueClause}${keyClause}${indexClause}${ifClause}`, + actionsList: forEachChildVariableEvent.getActions(), + }; + } + if (type === 'BuiltinCommonInstructions::Group') { + const groupEvent = gd.asGroupEvent(event); + return { + header: `group "${escapeStringLiteral(groupEvent.getName())}"`, + }; + } + if (type === 'BuiltinCommonInstructions::Comment') { + const commentEvent = gd.asCommentEvent(event); + return { + standaloneLine: `comment "${escapeStringLiteral( + commentEvent.getComment() + )}"`, + }; + } + if (type === 'BuiltinCommonInstructions::Link') { + const linkEvent = gd.asLinkEvent(event); + return { + standaloneLine: `link "${escapeStringLiteral(linkEvent.getTarget())}"`, + }; + } + + return null; +}; + +const renderHeaderLineFromParts = ({ + event, + parts, + eventPath, + indent, +}: {| + event: gdBaseEvent, + parts: EventScriptParts | null, + eventPath: string, + indent: string, +|}): string => { + const disabledPrefix = event.isDisabled() ? 'disabled ' : ''; + const idAnnotation = ` # event-${eventPath}`; + + if (!parts) { + return `${indent}# (event of type "${event.getType()}" cannot be shown as EventScript)${idAnnotation}`; + } + if (parts.standaloneLine) { + return `${indent}${disabledPrefix}${parts.standaloneLine}${idAnnotation}`; + } + return `${indent}${disabledPrefix}${parts.header || + 'always'}:${idAnnotation}`; +}; + +/** + * Render the header line of an event (`if ...:`, `for each ...:`, + * `comment "..."`), without its body, annotated with its id. Used both by + * the full rendering and to show ancestors of filtered events. + */ +export const renderEventScriptHeaderLine = ({ + event, + eventPath, + indent, + renderingErrors, +}: {| + event: gdBaseEvent, + eventPath: string, + indent: string, + renderingErrors: Array, +|}): string => { + let parts = null; + try { + parts = buildEventScriptParts(event, eventPath, renderingErrors); + } catch (error) { + renderingErrors.push({ + path: `event-${eventPath}`, + message: getErrorMessage(error), + }); + } + return renderHeaderLineFromParts({ event, parts, eventPath, indent }); +}; + +/** + * Count the events and actions/conditions of a whole events list (used for + * the `# ...` markers describing collapsed sub-events). + */ +const countEventsAndInstructions = ( + eventsList: gdEventsList +): {| eventsCount: number, instructionsCount: number |} => { + let eventsCount = 0; + let instructionsCount = 0; + mapFor(0, eventsList.getEventsCount(), i => { + const event = eventsList.getEventAt(i); + eventsCount++; + try { + const renderingErrors: Array = []; + const parts = buildEventScriptParts(event, '', renderingErrors); + if (parts && parts.actionsList) { + instructionsCount += parts.actionsList.size(); + } + } catch (error) { + // Counting is best-effort only. + } + if (event.canHaveSubEvents()) { + const subCounts = countEventsAndInstructions(event.getSubEvents()); + eventsCount += subCounts.eventsCount; + instructionsCount += subCounts.instructionsCount; + } + }); + return { eventsCount, instructionsCount }; +}; + +/** + * Render an event (and its sub-events, up to `subEventsDepth` levels) as + * EventScript lines. Collapsed sub-events are replaced by a `# ...` marker + * telling how to fetch them (pass `showCollapsedSubEventsMarker: false` to + * omit the marker, for "own source only" renderings: the body then gets an + * explicit `pass` when the event has nothing else). + */ +export const renderEventAsEventScriptLines = ({ + event, + eventPath, + indent, + subEventsDepth, + showCollapsedSubEventsMarker, + renderingErrors, +}: {| + event: gdBaseEvent, + eventPath: string, + indent: string, + subEventsDepth: number, + showCollapsedSubEventsMarker?: boolean, + renderingErrors: Array, +|}): Array => { + const lines = []; + const bodyIndent = indent + INDENT; + + let parts = null; + try { + parts = buildEventScriptParts(event, eventPath, renderingErrors); + } catch (error) { + renderingErrors.push({ + path: `event-${eventPath}`, + message: getErrorMessage(error), + }); + } + lines.push(renderHeaderLineFromParts({ event, parts, eventPath, indent })); + + try { + if (event.canHaveVariables() && event.hasVariables()) { + renderLocalVariableLines(event.getVariables()).forEach(line => + lines.push(`${bodyIndent}${line}`) + ); + } + } catch (error) { + renderingErrors.push({ + path: `event-${eventPath}`, + message: getErrorMessage(error), + }); + } + + if (parts && parts.actionsList) { + const actionsList = parts.actionsList; + mapFor(0, actionsList.size(), i => { + try { + lines.push( + `${bodyIndent}${renderActionLine( + actionsList.get(i), + `event-${eventPath}`, + renderingErrors + )}` + ); + } catch (error) { + renderingErrors.push({ + path: `event-${eventPath} > action ${i}`, + message: getErrorMessage(error), + }); + lines.push(`${bodyIndent}# (this action could not be rendered)`); + } + }); + } + + if (event.canHaveSubEvents() && event.getSubEvents().getEventsCount() > 0) { + if (subEventsDepth > 0) { + lines.push( + ...renderEventsListAsEventScriptLines({ + eventsList: event.getSubEvents(), + parentPath: eventPath, + indent: bodyIndent, + subEventsDepth: subEventsDepth - 1, + renderingErrors, + }) + ); + } else if (showCollapsedSubEventsMarker !== false) { + const { eventsCount, instructionsCount } = countEventsAndInstructions( + event.getSubEvents() + ); + lines.push( + `${bodyIndent}# ... ${eventsCount} sub-event(s) (${instructionsCount} action(s)) not shown: read event_ids: ["event-${eventPath}"] to see them.` + ); + } + } + + // An empty body is always made explicit with `pass`, so the header never + // looks like it owns the next (non-indented) line. The parser ignores it. + if (parts && !parts.standaloneLine && lines.length === 1) { + lines.push(`${bodyIndent}pass`); + } + + return lines; +}; + +export const renderEventsListAsEventScriptLines = ({ + eventsList, + parentPath, + indent, + subEventsDepth, + renderingErrors, +}: {| + eventsList: gdEventsList, + parentPath: string, + indent: string, + subEventsDepth: number, + renderingErrors: Array, +|}): Array => { + const lines = []; + mapFor(0, eventsList.getEventsCount(), i => { + const eventPath = (parentPath ? parentPath + '.' : '') + i; + try { + lines.push( + ...renderEventAsEventScriptLines({ + event: eventsList.getEventAt(i), + eventPath, + indent, + subEventsDepth, + renderingErrors, + }) + ); + } catch (error) { + renderingErrors.push({ + path: `event-${eventPath}`, + message: getErrorMessage(error), + }); + lines.push( + `${indent}# (event event-${eventPath} could not be rendered: ${getErrorMessage( + error + )})` + ); + } + }); + return lines; +}; + +/** + * Render a whole events list as EventScript (the same syntax accepted by the + * `event_script` field of `generate_events`), with each event annotated with + * its id (`# event-1.2`) as a comment. Sub-events deeper than + * `subEventsDepth` levels are collapsed into a `# ...` marker. + */ +export const renderEventsAsEventScript = ({ + eventsList, + subEventsDepth, +}: {| + eventsList: gdEventsList, + subEventsDepth?: number, +|}): {| + text: string, + renderingErrors: Array, +|} => { + const renderingErrors: Array = []; + const lines = renderEventsListAsEventScriptLines({ + eventsList, + parentPath: '', + indent: '', + subEventsDepth: + subEventsDepth === undefined ? Number.MAX_SAFE_INTEGER : subEventsDepth, + renderingErrors, + }); + return { text: lines.join('\n'), renderingErrors }; +}; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js new file mode 100644 index 000000000000..c995e59e8ab4 --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js @@ -0,0 +1,227 @@ +// @flow +import { makeTestProject } from '../../../fixtures/TestProject'; +import { renderEventsAsEventScript } from './EventScriptRenderer'; +import { + makeEventsList, + sceneStartSerializedEvents, +} from './EventScriptTestHelpers'; + +const gd: libGDevelop = global.gd; + +// Conformance fixtures shared with the backend serializer +// (events-script-serializer.js in the GDevelop-services repository): both +// sides must render the same events to the same EventScript. Keep the +// fixtures file byte-identical in both repositories. +const serializerFixtures = require('./EventScriptRenderer.fixtures.json'); + +describe('EventScriptRenderer conformance fixtures', () => { + serializerFixtures.fixtures.forEach(fixture => { + it(`renders like the backend serializer: ${fixture.name}`, () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, fixture.serializedEvents); + const { text } = renderEventsAsEventScript({ eventsList }); + // The fixtures are id-agnostic (the backend serializer does not + // annotate): strip the `# event-N` annotations before comparing. + const withoutIdAnnotations = text + .split('\n') + .map(line => line.replace(/\s*# event-[\d.]+$/, '')) + .join('\n'); + expect(withoutIdAnnotations).toBe( + fixture.expectedEventScript.join('\n') + ); + } finally { + project.delete(); + } + }); + }); +}); + +describe('EventScriptRenderer', () => { + it('renders events as EventScript with event id annotations', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, sceneStartSerializedEvents); + + const { text, renderingErrors } = renderEventsAsEventScript({ + eventsList, + }); + + expect(renderingErrors).toEqual([]); + expect(text).toBe( + [ + 'if DepartScene(): # event-0', + // The code-only "currentScene" parameter of CentreCamera is + // dropped; the trailing empty parameters are trimmed. + ' CentreCamera(MySpriteObject)', + ' ChangeAnimation(MySpriteObject, =, 1)', + ' if PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject): # event-0.0', + ' Delete(MySpriteObject)', + ].join('\n') + ); + } finally { + project.delete(); + } + }); + + it('renders conditions combinations: inverted, trigger once, or', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + { + type: { value: 'PlatformBehavior::IsFalling', inverted: true }, + parameters: [ + 'GroupOfSpriteObjectsWithBehaviors', + 'PlatformerObject', + ], + }, + { + type: { value: 'BuiltinCommonInstructions::Or' }, + parameters: [], + subInstructions: [ + { + type: { value: 'DepartScene' }, + parameters: [''], + }, + { + type: { value: 'PlatformBehavior::IsFalling' }, + parameters: [ + 'GroupOfSpriteObjectsWithBehaviors', + 'PlatformerObject', + ], + }, + ], + }, + { + type: { value: 'BuiltinCommonInstructions::Once' }, + parameters: [], + }, + ], + actions: [], + }, + ]); + + const { text, renderingErrors } = renderEventsAsEventScript({ + eventsList, + }); + + expect(renderingErrors).toEqual([]); + expect(text).toBe( + [ + 'if not PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject) and Or(DepartScene(), PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject)) and once: # event-0', + ' pass', + ].join('\n') + ); + } finally { + project.delete(); + } + }); + + it('renders loop events, else events, local variables, comments and groups', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, [ + { + type: 'BuiltinCommonInstructions::Comment', + comment: 'Setup\nwith a second line', + }, + { + type: 'BuiltinCommonInstructions::Standard', + variables: [ + { name: 'Count', type: 'number', value: '3' }, + { name: 'Title', type: 'string', value: 'Hello "world"' }, + ], + conditions: [], + actions: [], + }, + { + type: 'BuiltinCommonInstructions::Else', + conditions: [{ type: { value: 'DepartScene' }, parameters: [''] }], + actions: [], + }, + { + type: 'BuiltinCommonInstructions::ForEach', + object: 'MySpriteObject', + conditions: [], + actions: [ + { type: { value: 'Delete' }, parameters: ['MySpriteObject', ''] }, + ], + }, + { + type: 'BuiltinCommonInstructions::Repeat', + repeatExpression: '4 + 3', + conditions: [], + actions: [], + }, + { + type: 'BuiltinCommonInstructions::Group', + name: 'My group', + events: [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [], + actions: [ + { + type: { value: 'Delete' }, + parameters: ['MySpriteObject', ''], + }, + ], + }, + ], + }, + ]); + + const { text, renderingErrors } = renderEventsAsEventScript({ + eventsList, + }); + + expect(renderingErrors).toEqual([]); + expect(text).toBe( + [ + 'comment "Setup\\nwith a second line" # event-0', + 'always: # event-1', + ' local number Count = 3', + ' local string Title = "Hello \\"world\\""', + 'else if DepartScene(): # event-2', + ' pass', + 'for each MySpriteObject: # event-3', + ' Delete(MySpriteObject)', + 'repeat 4 + 3 times: # event-4', + ' pass', + 'group "My group": # event-5', + ' always: # event-5.0', + ' Delete(MySpriteObject)', + ].join('\n') + ); + } finally { + project.delete(); + } + }); + + it('collapses sub-events deeper than the requested depth', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, sceneStartSerializedEvents); + + const { text, renderingErrors } = renderEventsAsEventScript({ + eventsList, + subEventsDepth: 0, + }); + + expect(renderingErrors).toEqual([]); + expect(text).toBe( + [ + 'if DepartScene(): # event-0', + ' CentreCamera(MySpriteObject)', + ' ChangeAnimation(MySpriteObject, =, 1)', + ' # ... 1 sub-event(s) (1 action(s)) not shown: read event_ids: ["event-0"] to see them.', + ].join('\n') + ); + } finally { + project.delete(); + } + }); +}); diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js new file mode 100644 index 000000000000..67e7e656a5e1 --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js @@ -0,0 +1,504 @@ +// @flow +import { mapFor } from '../../../Utils/MapFor'; +import { + renderEventAsEventScriptLines, + renderEventScriptHeaderLine, + type EventScriptRenderingError, +} from './EventScriptRenderer'; + +const gd: libGDevelop = global.gd; + +const INDENT = ' '; + +type EventNode = {| + path: string, + event: gdBaseEvent, +|}; + +/** + * The result of rendering a filtered "source view" of a scene's events as + * EventScript. `text` is the EventScript (with `# event-N` annotations, + * ancestors of selected events shown as header lines and skipped siblings + * shown as `# ...` markers). + */ +export type EventScriptSourceView = {| + text: string, + selectedEventIds: Array, + truncated: boolean, + notes: Array, + renderingErrors: Array, +|}; + +const indexEvents = ( + eventsList: gdEventsList, + parentPath: string, + nodesByPath: Map +) => { + mapFor(0, eventsList.getEventsCount(), i => { + const event = eventsList.getEventAt(i); + const path = (parentPath ? parentPath + '.' : '') + i; + nodesByPath.set(path, { path, event }); + if (event.canHaveSubEvents()) { + indexEvents(event.getSubEvents(), path, nodesByPath); + } + }); +}; + +const getParentPath = (path: string): string | null => { + const lastDotIndex = path.lastIndexOf('.'); + return lastDotIndex === -1 ? null : path.slice(0, lastDotIndex); +}; + +const isDescendantPath = (path: string, ancestorPath: string): boolean => + path.startsWith(ancestorPath + '.'); + +const compareDocumentOrder = (pathA: string, pathB: string): number => { + const partsA = pathA.split('.').map(Number); + const partsB = pathB.split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const a = partsA[i]; + const b = partsB[i]; + if (a === undefined) return -1; + if (b === undefined) return 1; + if (a !== b) return a - b; + } + return 0; +}; + +/** + * The event's own EventScript lines (header, local variables, actions - + * without its sub-events, no collapse marker), used to match the + * search/object filters and as the "own source" of an event. An event whose + * body is empty (even one with sub-events) gets an explicit `pass` line, + * like every other rendering. + */ +const renderEventOwnTextForMatching = ( + event: gdBaseEvent, + path: string +): string => { + const renderingErrors: Array = []; + return renderEventAsEventScriptLines({ + event, + eventPath: path, + indent: '', + subEventsDepth: 0, + showCollapsedSubEventsMarker: false, + renderingErrors, + }).join('\n'); +}; + +/** + * The current EventScript source of one event, resolved by id or group + * name: its own lines only (header, local variables, actions), or the whole + * subtree with `includeSubEvents`. Used as the reference the + * `expected_event_source` anchor of replace placements is compared against + * (own source for `replace_event_but_keep_existing_sub_events`, full + * subtree for `replace_entire_event_and_sub_events` - the anchor covers + * exactly what the placement destroys). Returns null when the event is not + * found. + */ +export const renderEventSourceById = ({ + eventsList, + eventIdOrGroupName, + includeSubEvents, +}: {| + eventsList: gdEventsList, + eventIdOrGroupName: string, + includeSubEvents: boolean, +|}): string | null => { + const nodesByPath: Map = new Map(); + indexEvents(eventsList, '', nodesByPath); + const paths = resolveEventIdOrGroupName(eventIdOrGroupName, nodesByPath); + const firstPath = paths[0]; + if (firstPath === undefined) return null; + const node = nodesByPath.get(firstPath); + if (!node) return null; + if (!includeSubEvents) { + return renderEventOwnTextForMatching(node.event, firstPath); + } + const renderingErrors: Array = []; + return renderEventAsEventScriptLines({ + event: node.event, + eventPath: firstPath, + indent: '', + subEventsDepth: Number.MAX_SAFE_INTEGER, + renderingErrors, + }).join('\n'); +}; + +const makeWordBoundaryRegex = (name: string): RegExp => { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[^A-Za-z0-9_])${escaped}($|[^A-Za-z0-9_])`); +}; + +/** + * Resolve an event id (`event-2.1`, `2.1`) or a group event name to event + * paths. Returns an empty array when nothing matches. + */ +const resolveEventIdOrGroupName = ( + eventIdOrGroupName: string, + nodesByPath: Map +): Array => { + const withoutPrefix = eventIdOrGroupName.startsWith('event-') + ? eventIdOrGroupName.slice('event-'.length) + : eventIdOrGroupName; + if (/^\d+(\.\d+)*$/.test(withoutPrefix) && nodesByPath.has(withoutPrefix)) { + return [withoutPrefix]; + } + + // Not a path: try to match the name of a group event (case-insensitive). + const lowerCaseName = eventIdOrGroupName.toLowerCase().trim(); + const matchingPaths: Array = []; + for (const [path, node] of nodesByPath) { + if (node.event.getType() === 'BuiltinCommonInstructions::Group') { + const groupEvent = gd.asGroupEvent(node.event); + if ( + groupEvent + .getName() + .toLowerCase() + .trim() === lowerCaseName + ) { + matchingPaths.push(path); + } + } + } + return matchingPaths; +}; + +const renderFilteredTree = ({ + eventsList, + parentPath, + indent, + selectedPathsSet, + ancestorPathsSet, + subEventsDepth, + renderingErrors, +}: {| + eventsList: gdEventsList, + parentPath: string, + indent: string, + selectedPathsSet: Set, + ancestorPathsSet: Set, + subEventsDepth: number, + renderingErrors: Array, +|}): Array => { + const lines: Array = []; + let skippedPaths: Array = []; + const flushSkipped = () => { + if (skippedPaths.length === 0) return; + const shownIds = skippedPaths + .slice(0, 3) + .map(path => `event-${path}`) + .join(', '); + const ellipsis = skippedPaths.length > 3 ? ', ...' : ''; + lines.push( + `${indent}# ... ${ + skippedPaths.length + } other event(s) here (${shownIds}${ellipsis})` + ); + skippedPaths = []; + }; + + mapFor(0, eventsList.getEventsCount(), i => { + const event = eventsList.getEventAt(i); + const path = (parentPath ? parentPath + '.' : '') + i; + if (selectedPathsSet.has(path)) { + flushSkipped(); + lines.push( + ...renderEventAsEventScriptLines({ + event, + eventPath: path, + indent, + subEventsDepth, + renderingErrors, + }) + ); + } else if (ancestorPathsSet.has(path)) { + flushSkipped(); + lines.push( + renderEventScriptHeaderLine({ + event, + eventPath: path, + indent, + renderingErrors, + }) + ); + // The ancestor is only shown for context: make explicit that its own + // body (actions, local variables) is not displayed, so this view is + // never mistaken for the full source of the ancestor. + const ancestorOwnBodyLines = renderEventOwnTextForMatching(event, path) + .split('\n') + .slice(1) + .filter(line => line.trim() !== 'pass'); + if (ancestorOwnBodyLines.length > 0) { + lines.push( + `${indent + INDENT}# ... ${ + ancestorOwnBodyLines.length + } line(s) of this parent event (shown only as context) not displayed: read event_ids: ["event-${path}"] to see them.` + ); + } + if (event.canHaveSubEvents()) { + lines.push( + ...renderFilteredTree({ + eventsList: event.getSubEvents(), + parentPath: path, + indent: indent + INDENT, + selectedPathsSet, + ancestorPathsSet, + subEventsDepth, + renderingErrors, + }) + ); + } + } else { + skippedPaths.push(path); + } + }); + flushSkipped(); + return lines; +}; + +/** + * Build a filtered EventScript "source view" of a scene's events. + * + * - With no filter: every top-level event, sub-events collapsed beyond + * `subEventsDepth` (default 1). + * - With `eventIds` (event ids or group names): those events, rendered in + * full by default, with their ancestors as header lines for context. + * - With `searchText`/`objectNames`: events whose own lines (header, + * actions, local variables) match, rendered in full by default. + * Both compose with `eventIds` (the search is restricted to the subtrees). + * + * The result is kept under `maxChars` by first reducing the sub-events + * depth, then dropping trailing selected events (with a note giving their + * ids so they can be fetched with another call). + */ +export const buildEventScriptSourceView = ({ + eventsList, + eventIds, + searchText, + objectNames, + subEventsDepth, + maxChars, +}: {| + eventsList: gdEventsList, + eventIds?: Array | null, + searchText?: string | null, + objectNames?: Array | null, + subEventsDepth?: number | null, + maxChars: number, +|}): EventScriptSourceView => { + const notes: Array = []; + const renderingErrors: Array = []; + + const nodesByPath: Map = new Map(); + indexEvents(eventsList, '', nodesByPath); + + const hasEventIds = !!eventIds && eventIds.length > 0; + const hasSearch = + (!!searchText && searchText.trim() !== '') || + (!!objectNames && objectNames.length > 0); + + // 1) Resolve the scope: subtrees given by `eventIds`, or the whole sheet. + let scopeRootPaths: Array = []; + if (hasEventIds && eventIds) { + for (const eventId of eventIds) { + const paths = resolveEventIdOrGroupName(eventId, nodesByPath); + if (paths.length === 0) { + notes.push( + `No event found for id or group name "${eventId}" (ids look like "event-2" or "event-2.1.0", as annotated in the source).` + ); + } + scopeRootPaths.push(...paths); + } + scopeRootPaths.sort(compareDocumentOrder); + // Remove scope roots nested inside another scope root. + scopeRootPaths = scopeRootPaths.filter( + (path, index) => + !scopeRootPaths.some( + (otherPath, otherIndex) => + otherIndex !== index && isDescendantPath(path, otherPath) + ) + ); + } + + // 2) Resolve the selection: filter matches, or the scope roots themselves. + let selectedPaths: Array = []; + if (hasSearch) { + const lowerCaseSearchText = searchText ? searchText.toLowerCase() : null; + const objectNameRegexes = (objectNames || []).map(makeWordBoundaryRegex); + const isInScope = (path: string) => + !hasEventIds || + scopeRootPaths.some( + rootPath => path === rootPath || isDescendantPath(path, rootPath) + ); + + for (const [path, node] of nodesByPath) { + if (!isInScope(path)) continue; + const ownText = renderEventOwnTextForMatching(node.event, path); + if ( + lowerCaseSearchText && + !ownText.toLowerCase().includes(lowerCaseSearchText) + ) { + continue; + } + if ( + objectNameRegexes.length > 0 && + !objectNameRegexes.some(regex => regex.test(ownText)) + ) { + continue; + } + selectedPaths.push(path); + } + selectedPaths.sort(compareDocumentOrder); + } else if (hasEventIds) { + selectedPaths = scopeRootPaths; + } else { + selectedPaths = [...nodesByPath.keys()] + .filter(path => !path.includes('.')) + .sort(compareDocumentOrder); + } + + // Remove selected paths nested inside another selected path: they are + // rendered as part of the ancestor's sub-events. + selectedPaths = selectedPaths.filter( + (path, index) => + !selectedPaths.some( + (otherPath, otherIndex) => + otherIndex !== index && isDescendantPath(path, otherPath) + ) + ); + + if (selectedPaths.length === 0) { + // Say exactly why nothing is returned: an empty sheet, ids that resolved + // to nothing (the sheet may well have events) or filters matching + // nothing are all different situations for the caller. + const emptyResultNote = + nodesByPath.size === 0 + ? 'The events sheet is empty.' + : hasSearch + ? 'No event matches the given filters (the events sheet is NOT empty).' + : 'None of the given `event_ids` matches an event (the events sheet is NOT empty).'; + return { + text: '', + selectedEventIds: [], + truncated: false, + notes: [...notes, emptyResultNote], + renderingErrors, + }; + } + + // An `else` event only makes sense right after its `if` (or another + // `else`): point it out when one is selected, so a filtered read is not + // mistaken for a standalone, resendable event. + for (const path of selectedPaths) { + const node = nodesByPath.get(path); + if (node && node.event.getType() === 'BuiltinCommonInstructions::Else') { + notes.push( + `event-${path} is an \`else\` event: it runs when the conditions of the event just before it (at the same level) were not met. Resending it alone is not valid EventScript - rework it together with its \`if\`, or keep it in place.` + ); + } + } + + // 3) Render, degrading gracefully to stay under `maxChars`: reduce the + // sub-events depth first, then drop trailing selected events. + const isWholeSheet = !hasEventIds && !hasSearch; + const requestedDepth = + subEventsDepth !== null && subEventsDepth !== undefined + ? subEventsDepth + : isWholeSheet + ? 1 + : Number.MAX_SAFE_INTEGER; + + const depthsToTry = [ + ...new Set([ + requestedDepth, + ...([4, 2, 1, 0].filter(depth => depth < requestedDepth): Array), + ]), + ]; + + const renderWithSelection = (paths: Array, depth: number): string => { + const selectedPathsSet = new Set(paths); + const ancestorPathsSet: Set = new Set(); + for (const path of paths) { + let parentPath = getParentPath(path); + while (parentPath !== null) { + ancestorPathsSet.add(parentPath); + parentPath = getParentPath(parentPath); + } + } + return renderFilteredTree({ + eventsList, + parentPath: '', + indent: '', + selectedPathsSet, + ancestorPathsSet, + subEventsDepth: depth, + renderingErrors, + }).join('\n'); + }; + + // `selectedEventIds` always describes what the returned text actually + // shows: it is recomputed when trailing events are dropped below. + let shownPaths = selectedPaths; + let text = ''; + let truncated = false; + let fitted = false; + for (const depth of depthsToTry) { + text = renderWithSelection(selectedPaths, depth); + if (text.length <= maxChars) { + if (depth < requestedDepth) { + notes.push( + 'Some sub-events were collapsed to keep the output small: read them with `event_ids` if needed.' + ); + } + fitted = true; + break; + } + } + + if (!fitted) { + // Even at depth 0 the output is too big: drop trailing selected events. + const keptPaths = [...selectedPaths]; + const droppedPaths: Array = []; + while (keptPaths.length > 1) { + const droppedPath = keptPaths.pop(); + if (droppedPath !== undefined) droppedPaths.unshift(droppedPath); + text = renderWithSelection(keptPaths, 0); + if (text.length <= maxChars) break; + } + if (text.length > maxChars) { + // A single event still overflowing: hard-truncate as a last resort. + text = text.slice(0, maxChars) + '\n# (output truncated)'; + } + if (droppedPaths.length > 0) { + const droppedIds = droppedPaths.map(path => `event-${path}`); + notes.push( + `Output too large: ${ + droppedIds.length + } selected event(s) not shown (${droppedIds.slice(0, 10).join(', ')}${ + droppedIds.length > 10 ? ', ...' : '' + }). Call again with \`event_ids\` (or a narrower filter) to read them.` + ); + } + shownPaths = keptPaths; + truncated = true; + } + + // Some events (like JavaScript code events) have no EventScript form and + // are shown as `#` comments: replacing a subtree containing one from this + // source would silently lose it - warn the reader. + if (text.includes('cannot be shown as EventScript')) { + notes.push( + 'This selection contains event(s) that cannot be expressed as EventScript (shown as `#` comments). Do not use `replace_entire_event_and_sub_events` on a subtree containing them (they would be lost): use `replace_event_but_keep_existing_sub_events`, or edit around them.' + ); + } + + return { + text, + selectedEventIds: shownPaths.map(path => `event-${path}`), + truncated, + notes, + renderingErrors, + }; +}; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.spec.js new file mode 100644 index 000000000000..050eac58546b --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.spec.js @@ -0,0 +1,368 @@ +// @flow +import { makeTestProject } from '../../../fixtures/TestProject'; +import { + buildEventScriptSourceView, + renderEventSourceById, +} from './EventScriptSourceView'; +import { + makeEventsList, + sceneStartSerializedEvents, +} from './EventScriptTestHelpers'; + +const gd: libGDevelop = global.gd; + +describe('EventScriptSourceView', () => { + const manyEventsSerialized = [ + ...sceneStartSerializedEvents, + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + { + type: { value: 'PlatformBehavior::IsFalling' }, + parameters: ['GroupOfSpriteObjectsWithBehaviors', 'PlatformerObject'], + }, + ], + actions: [ + { + type: { value: 'ChangeAnimation' }, + parameters: ['MySpriteObject', '=', '2'], + }, + ], + }, + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [], + actions: [ + { type: { value: 'Delete' }, parameters: ['MySpriteObject', ''] }, + ], + }, + ]; + + it('returns the whole sheet when no filter is given', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + maxChars: 10000, + }); + + expect(view.renderingErrors).toEqual([]); + expect(view.selectedEventIds).toEqual(['event-0', 'event-1', 'event-2']); + expect(view.truncated).toBe(false); + // A whole-sheet read includes one level of sub-events by default + // (`subEventsDepth` unset): the direct sub-event event-0.0 is shown. + expect(view.text).toContain('# event-0.0'); + expect(view.text).toContain('# event-2'); + } finally { + project.delete(); + } + }); + + it('returns a single event subtree with ancestors as headers', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + eventIds: ['event-0.0'], + maxChars: 10000, + }); + + expect(view.renderingErrors).toEqual([]); + expect(view.selectedEventIds).toEqual(['event-0.0']); + expect(view.text).toBe( + [ + // The ancestor is shown as a header line for context, with a + // marker making explicit that its own actions are not displayed. + 'if DepartScene(): # event-0', + ' # ... 2 line(s) of this parent event (shown only as context) not displayed: read event_ids: ["event-0"] to see them.', + ' if PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject): # event-0.0', + ' Delete(MySpriteObject)', + '# ... 2 other event(s) here (event-1, event-2)', + ].join('\n') + ); + } finally { + project.delete(); + } + }); + + it('filters events with a text search', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + searchText: 'changeanimation', + maxChars: 10000, + }); + + expect(view.renderingErrors).toEqual([]); + expect(view.selectedEventIds).toEqual(['event-0', 'event-1']); + expect(view.text).toContain('ChangeAnimation(MySpriteObject, =, 1)'); + expect(view.text).toContain('ChangeAnimation(MySpriteObject, =, 2)'); + expect(view.text).toContain('# ... 1 other event(s) here (event-2)'); + } finally { + project.delete(); + } + }); + + it('filters events referencing given objects, restricted to event_ids subtrees', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + eventIds: ['event-0'], + objectNames: ['GroupOfSpriteObjectsWithBehaviors'], + maxChars: 10000, + }); + + expect(view.renderingErrors).toEqual([]); + // event-1 also references the object but is outside the scope. + expect(view.selectedEventIds).toEqual(['event-0.0']); + } finally { + project.delete(); + } + }); + + it('renders the source of one event by id (own lines, or the full subtree)', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + expect( + renderEventSourceById({ + eventsList, + eventIdOrGroupName: 'event-0', + includeSubEvents: false, + }) + ).toBe( + [ + 'if DepartScene(): # event-0', + ' CentreCamera(MySpriteObject)', + ' ChangeAnimation(MySpriteObject, =, 1)', + ].join('\n') + ); + expect( + renderEventSourceById({ + eventsList, + eventIdOrGroupName: 'event-0', + includeSubEvents: true, + }) + ).toBe( + [ + 'if DepartScene(): # event-0', + ' CentreCamera(MySpriteObject)', + ' ChangeAnimation(MySpriteObject, =, 1)', + ' if PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject): # event-0.0', + ' Delete(MySpriteObject)', + ].join('\n') + ); + expect( + renderEventSourceById({ + eventsList, + eventIdOrGroupName: 'event-99', + includeSubEvents: false, + }) + ).toBe(null); + } finally { + project.delete(); + } + }); + + it('renders `pass` in the own source of an action-less event with sub-events', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [{ type: { value: 'DepartScene' }, parameters: [''] }], + actions: [], + events: [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [], + actions: [{ type: { value: 'Wait' }, parameters: ['1'] }], + }, + ], + }, + ]); + + // The own source has an empty body: it gets the explicit `pass`, + // exactly like a read of the same event without its sub-events. + expect( + renderEventSourceById({ + eventsList, + eventIdOrGroupName: 'event-0', + includeSubEvents: false, + }) + ).toBe(['if DepartScene(): # event-0', ' pass'].join('\n')); + } finally { + project.delete(); + } + }); + + it('reports unknown event ids without pretending the sheet is empty', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + eventIds: ['event-99'], + maxChars: 10000, + }); + + expect(view.selectedEventIds).toEqual([]); + expect(view.text).toBe(''); + expect(view.notes.join(' ')).toContain('event-99'); + expect(view.notes.join(' ')).toContain('NOT empty'); + expect(view.notes.join(' ')).not.toContain('sheet is empty.'); + } finally { + project.delete(); + } + }); + + it('distinguishes an empty sheet from filters matching nothing', () => { + const { project } = makeTestProject(gd); + try { + const emptyEventsList = makeEventsList(project, []); + const emptyView = buildEventScriptSourceView({ + eventsList: emptyEventsList, + maxChars: 10000, + }); + expect(emptyView.notes).toEqual(['The events sheet is empty.']); + + const eventsList = makeEventsList(project, manyEventsSerialized); + const noMatchView = buildEventScriptSourceView({ + eventsList, + searchText: 'nothing matches this', + maxChars: 10000, + }); + expect(noMatchView.text).toBe(''); + expect(noMatchView.notes.join(' ')).toContain( + 'No event matches the given filters' + ); + expect(noMatchView.notes.join(' ')).toContain('NOT empty'); + } finally { + project.delete(); + } + }); + + it('reports only the shown events in selectedEventIds when trailing events are dropped', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventScriptSourceView({ + eventsList, + // Small enough to force dropping trailing events, big enough to + // keep at least the first one. + maxChars: 130, + }); + + expect(view.truncated).toBe(true); + expect(view.selectedEventIds.length).toBeLessThan(3); + expect(view.selectedEventIds[0]).toBe('event-0'); + // The dropped events are listed in the notes instead. + expect(view.notes.join(' ')).toContain('not shown'); + } finally { + project.delete(); + } + }); + + it('notes that a selected `else` event binds to the `if` above it', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [{ type: { value: 'DepartScene' }, parameters: [''] }], + actions: [{ type: { value: 'Wait' }, parameters: ['1'] }], + }, + { + type: 'BuiltinCommonInstructions::Else', + conditions: [], + actions: [{ type: { value: 'Wait' }, parameters: ['2'] }], + }, + ]); + + const view = buildEventScriptSourceView({ + eventsList, + eventIds: ['event-1'], + maxChars: 10000, + }); + + expect(view.selectedEventIds).toEqual(['event-1']); + expect(view.notes.join(' ')).toContain('`else` event'); + } finally { + project.delete(); + } + }); + + it('marks events without an EventScript form and warns about replacing them', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [], + actions: [ + { type: { value: 'Delete' }, parameters: ['MySpriteObject', ''] }, + ], + events: [ + { + type: 'BuiltinCommonInstructions::JsCode', + inlineCode: 'runtimeScene.setBackgroundColor(255, 0, 0);', + }, + ], + }, + ]); + + const view = buildEventScriptSourceView({ + eventsList, + eventIds: ['event-0'], + maxChars: 10000, + }); + + expect(view.text).toContain( + '# (event of type "BuiltinCommonInstructions::JsCode" cannot be shown as EventScript) # event-0.0' + ); + expect(view.notes.join(' ')).toContain( + 'cannot be expressed as EventScript' + ); + } finally { + project.delete(); + } + }); + + it('degrades to a smaller depth, then drops events, to fit the budget', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const smallView = buildEventScriptSourceView({ + eventsList, + maxChars: 260, + }); + // The sub-event of event-0 is collapsed to fit. + expect(smallView.text).toContain('not shown: read event_ids:'); + expect(smallView.text.length).toBeLessThanOrEqual(260); + + const tinyView = buildEventScriptSourceView({ + eventsList, + maxChars: 150, + }); + expect(tinyView.truncated).toBe(true); + expect(tinyView.notes.join(' ')).toContain('not shown'); + expect(tinyView.text.length).toBeLessThanOrEqual(200); + } finally { + project.delete(); + } + }); +}); diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptTestHelpers.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptTestHelpers.js new file mode 100644 index 000000000000..39cbc7f6e52e --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptTestHelpers.js @@ -0,0 +1,59 @@ +// @flow +import { unserializeFromJSObject } from '../../../Utils/Serializer'; + +const gd: libGDevelop = global.gd; + +/** + * Build a gd.EventsList from serialized events, for tests (the caller is + * responsible for deleting the project it belongs to). + */ +export const makeEventsList = ( + project: gdProject, + serializedEvents: Array +): gdEventsList => { + const eventsList = new gd.EventsList(); + unserializeFromJSObject( + eventsList, + serializedEvents, + 'unserializeFrom', + project + ); + return eventsList; +}; + +// A scene start event with several unrelated actions and a sub-event: the +// shape whose faithful rendering matters most when an AI agent reads an +// event before editing it. +export const sceneStartSerializedEvents = [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [{ type: { value: 'DepartScene' }, parameters: [''] }], + actions: [ + { + type: { value: 'CentreCamera' }, + parameters: ['', 'MySpriteObject', '', '""'], + }, + { + type: { value: 'ChangeAnimation' }, + parameters: ['MySpriteObject', '=', '1'], + }, + ], + events: [ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [ + { + type: { value: 'PlatformBehavior::IsFalling' }, + parameters: [ + 'GroupOfSpriteObjectsWithBehaviors', + 'PlatformerObject', + ], + }, + ], + actions: [ + { type: { value: 'Delete' }, parameters: ['MySpriteObject', ''] }, + ], + }, + ], + }, +]; diff --git a/newIDE/app/src/Utils/GDevelopServices/Generation.js b/newIDE/app/src/Utils/GDevelopServices/Generation.js index 0818de28ff79..507201fddd7c 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Generation.js +++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js @@ -204,6 +204,13 @@ export type AiGeneratedEventBatch = { placementTargetEventId: string | null, placementExpectedParentEventId: string | null, placementRationale: string | null, + // Anchor echo for replace placements: the caller's copy of the current + // source of the event being replaced (proof it was read). + expectedEventSource: string | null, + // The actual current source of the target event (its own lines, without + // sub-events), computed by the editor, that the backend compares the + // anchor against. + placementTargetEventSource: string | null, }; export type AiGeneratedEvent = {