From 87fc895d345c4475e8d07b354c164571f9d1378b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:58:17 +0000 Subject: [PATCH 1/9] Add read_scene_events_source: read scene events as EventScript source Add an EventScript serializer (the inverse of the backend parser) and a filtered 'source view' so AI agents can read existing events in the exact syntax accepted by the event_script field of generate_events: - EventsScriptRenderer.js: renders events (all event types, condition trees with and/or/not/once, local variables, loop clauses) as EventScript, each event annotated with its id (# event-2.1). Code-only parameters and trailing empty values are dropped (the compilation refills them). - EventsScriptSourceView.js: filters (event_ids or group names, text search, object names - they compose), ancestors of selected events shown as header lines, skipped siblings as # ... markers, and a max_chars budget honored by collapsing sub-events first, then dropping trailing events (with a note listing their ids). - New read_scene_events_source editor function exposing this to AI agents, so an event can be fetched verbatim, edited and resent in full instead of being rebuilt from a summary (which could silently drop actions or sub-events when the agent only has a partial view). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/EditorFunctions/index.js | 106 +++ .../TextRenderer/EventsScriptRenderer.js | 610 ++++++++++++++++++ .../TextRenderer/EventsScriptRenderer.spec.js | 399 ++++++++++++ .../TextRenderer/EventsScriptSourceView.js | 421 ++++++++++++ 4 files changed, 1536 insertions(+) create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index d97b6efeb147..277506355152 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -18,6 +18,7 @@ import { eventsTextRenderingErrorText, type EventsTextRenderingError, } from '../EventsSheet/EventsTree/TextRenderer'; +import { buildEventsScriptSourceView } from '../EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView'; import { addMissingObjectBehaviors, addObjectUndeclaredVariables, @@ -153,6 +154,11 @@ export type EditorFunctionGenericOutput = {| variables?: Array, reminder?: string, animationNames?: string, + // EventScript source view (see `read_scene_events_source`): + eventsScript?: string, + selectedEventIds?: Array, + truncated?: boolean, + notes?: Array, generatedEventsErrorDiagnostics?: string, aiGeneratedEventId?: string, warnings?: string, @@ -4729,6 +4735,105 @@ 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 readSceneEventsSource: 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, + } = buildEventsScriptSourceView({ + eventsList: scene.getEvents(), + eventIds, + searchText, + objectNames, + subEventsDepth, + maxChars, + }); + + const output: EditorFunctionGenericOutput = { + success: true, + eventsForSceneNamed: scene_name, + eventsScript: text || 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 */ @@ -7740,6 +7845,7 @@ export const editorFunctions: { [string]: EditorFunction } = { put_2d_instances: put2dInstances, put_3d_instances: put3dInstances, read_scene_events: readSceneEvents, + read_scene_events_source: readSceneEventsSource, add_scene_events: addSceneEvents, create_scene: createScene, inspect_scene_properties_layers_effects: inspectScenePropertiesLayersEffects, diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js new file mode 100644 index 000000000000..7a62de938ce3 --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js @@ -0,0 +1,610 @@ +// @flow +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 EventsScriptRenderingError = {| + 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}` : ''; + return { + header: `repeat ${repeatEvent + .getRepeatExpression() + .getPlainString()} 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 renderEventsScriptHeaderLine = ({ + 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. + */ +export const renderEventAsEventsScriptLines = ({ + event, + eventPath, + indent, + subEventsDepth, + renderingErrors, +}: {| + event: gdBaseEvent, + eventPath: string, + indent: string, + subEventsDepth: number, + 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( + ...renderEventsListAsEventsScriptLines({ + eventsList: event.getSubEvents(), + parentPath: eventPath, + indent: bodyIndent, + subEventsDepth: subEventsDepth - 1, + renderingErrors, + }) + ); + } else { + 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.` + ); + } + } + + return lines; +}; + +export const renderEventsListAsEventsScriptLines = ({ + 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( + ...renderEventAsEventsScriptLines({ + 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 renderEventsAsEventsScript = ({ + eventsList, + subEventsDepth, +}: {| + eventsList: gdEventsList, + subEventsDepth?: number, +|}): {| + text: string, + renderingErrors: Array, +|} => { + const renderingErrors: Array = []; + const lines = renderEventsListAsEventsScriptLines({ + 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/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js new file mode 100644 index 000000000000..2f8da26f234e --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js @@ -0,0 +1,399 @@ +// @flow +import { makeTestProject } from '../../../fixtures/TestProject'; +import { unserializeFromJSObject } from '../../../Utils/Serializer'; +import { renderEventsAsEventsScript } from './EventsScriptRenderer'; +import { buildEventsScriptSourceView } from './EventsScriptSourceView'; + +const gd: libGDevelop = global.gd; + +const makeEventsList = (project: gdProject, serializedEvents: Array) => { + const eventsList = new gd.EventsList(); + unserializeFromJSObject( + eventsList, + serializedEvents, + 'unserializeFrom', + project + ); + return eventsList; +}; + +// A scene start event mirroring the shape that caused a "near miss" in +// production: several unrelated actions and a sub-event, of which an AI +// agent only knows a part. +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', ''] }, + ], + }, + ], + }, +]; + +describe('EventsScriptRenderer', () => { + it('renders events as EventScript with event id annotations', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, sceneStartSerializedEvents); + + const { text, renderingErrors } = renderEventsAsEventsScript({ + 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 } = renderEventsAsEventsScript({ + eventsList, + }); + + expect(renderingErrors).toEqual([]); + expect(text).toBe( + 'if not PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject) and Or(DepartScene(), PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject)) and once: # event-0' + ); + } 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 } = renderEventsAsEventsScript({ + 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', + 'for each MySpriteObject: # event-3', + ' Delete(MySpriteObject)', + 'repeat 4 + 3 times: # event-4', + '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 } = renderEventsAsEventsScript({ + 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(); + } + }); +}); + +describe('EventsScriptSourceView', () => { + 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 = buildEventsScriptSourceView({ + eventsList, + maxChars: 10000, + }); + + expect(view.renderingErrors).toEqual([]); + expect(view.selectedEventIds).toEqual(['event-0', 'event-1', 'event-2']); + expect(view.truncated).toBe(false); + // Default whole-sheet depth is 1: event-0.0 is included. + 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 = buildEventsScriptSourceView({ + 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. + 'if DepartScene(): # event-0', + ' 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 = buildEventsScriptSourceView({ + 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 = buildEventsScriptSourceView({ + 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('reports unknown event ids', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + const view = buildEventsScriptSourceView({ + eventsList, + eventIds: ['event-99'], + maxChars: 10000, + }); + + expect(view.selectedEventIds).toEqual([]); + expect(view.notes.join(' ')).toContain('event-99'); + } 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 = buildEventsScriptSourceView({ + 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 = buildEventsScriptSourceView({ + 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/EventsScriptSourceView.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js new file mode 100644 index 000000000000..b648e164aadb --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js @@ -0,0 +1,421 @@ +// @flow +import { mapFor } from '../../../Utils/MapFor'; +import { + renderEventAsEventsScriptLines, + renderEventsScriptHeaderLine, + type EventsScriptRenderingError, +} from './EventsScriptRenderer'; + +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 EventsScriptSourceView = {| + 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), used to match the search/object filters. + */ +const renderEventOwnTextForMatching = ( + event: gdBaseEvent, + path: string +): string => { + const renderingErrors: Array = []; + return renderEventAsEventsScriptLines({ + event, + eventPath: path, + indent: '', + subEventsDepth: 0, + renderingErrors, + }) + .filter(line => !line.trim().startsWith('# ...')) + .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( + ...renderEventAsEventsScriptLines({ + event, + eventPath: path, + indent, + subEventsDepth, + renderingErrors, + }) + ); + } else if (ancestorPathsSet.has(path)) { + flushSkipped(); + lines.push( + renderEventsScriptHeaderLine({ + event, + eventPath: path, + indent, + renderingErrors, + }) + ); + 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 buildEventsScriptSourceView = ({ + eventsList, + eventIds, + searchText, + objectNames, + subEventsDepth, + maxChars, +}: {| + eventsList: gdEventsList, + eventIds?: Array | null, + searchText?: string | null, + objectNames?: Array | null, + subEventsDepth?: number | null, + maxChars: number, +|}): EventsScriptSourceView => { + 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) + ) + ); + + const selectedEventIds = selectedPaths.map(path => `event-${path}`); + if (selectedPaths.length === 0) { + return { + text: '', + selectedEventIds, + truncated: false, + notes: [ + ...notes, + hasSearch + ? 'No event matches the given filters.' + : 'The events sheet is empty.', + ], + renderingErrors, + }; + } + + // 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'); + }; + + 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.` + ); + } + truncated = true; + } + + return { + text, + selectedEventIds, + truncated, + notes, + renderingErrors, + }; +}; From f841935263cdbb4eddab4475b32c1f39ed36d7a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 20:28:49 +0000 Subject: [PATCH 2/9] Bump AI tools version to v9 and warn about events with no EventScript form v9 signals to the backend that this editor implements the read_scene_events_source function and can apply the two explicit replace relations of generate_events. Editors in production that send v8 keep the previous toolsets unchanged. The events source view also adds a note when the selection contains events that cannot be expressed as EventScript (like JavaScript code events, shown as # comments), warning against replacing a subtree containing them (they would be silently lost). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/AiGeneration/Utils.js | 2 ++ .../TextRenderer/EventsScriptRenderer.spec.js | 36 +++++++++++++++++++ .../TextRenderer/EventsScriptSourceView.js | 9 +++++ 3 files changed, 47 insertions(+) diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 602fa9bca845..9bb439c8b502 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -99,6 +99,8 @@ 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. +// v9: `read_scene_events_source` (this editor implements it) and the explicit +// replace relations of `generate_events` (keep or replace sub-events). export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v9'; /** diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js index 2f8da26f234e..f386b81a1062 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js @@ -372,6 +372,42 @@ describe('EventsScriptSourceView', () => { } }); + 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 = buildEventsScriptSourceView({ + 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 { diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js index b648e164aadb..ae38a76da94b 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js @@ -411,6 +411,15 @@ export const buildEventsScriptSourceView = ({ 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, From 14b10da768c35e9d6e5000e154b4455728813613 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 21:12:35 +0000 Subject: [PATCH 3/9] Send the current source of replaced events for the expected_event_source anchor For replace placements of generate_events, the editor now computes the current EventScript source of the target event (its own lines, without sub-events) and sends it along the batch (placementTargetEventSource), next to the caller-provided expected_event_source anchor. The backend compares both (leniently) and rejects a replace whose anchor is missing or stale - proving the event was read and hasn't changed before being overwritten. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/EditorFunctions/index.js | 50 +++++++++++++++---- .../TextRenderer/EventsScriptRenderer.spec.js | 33 +++++++++++- .../TextRenderer/EventsScriptSourceView.js | 23 +++++++++ .../src/Utils/GDevelopServices/Generation.js | 7 +++ 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 277506355152..57cc3a25d99d 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -18,7 +18,10 @@ import { eventsTextRenderingErrorText, type EventsTextRenderingError, } from '../EventsSheet/EventsTree/TextRenderer'; -import { buildEventsScriptSourceView } from '../EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView'; +import { + buildEventsScriptSourceView, + renderEventOwnSourceById, +} from '../EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView'; import { addMissingObjectBehaviors, addObjectUndeclaredVariables, @@ -211,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 = {| @@ -5153,6 +5161,30 @@ 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 the + // event being replaced: the backend compares the + // `expected_event_source` anchor against it (proof the event was + // read and hasn't changed). + const isReplacePlacement = + placementRelation === + 'replace_event_but_keep_existing_sub_events' || + placementRelation === 'replace_entire_event_and_sub_events'; + const placementTargetEventSource = + isReplacePlacement && placementTargetEventId + ? renderEventOwnSourceById({ + eventsList: currentSceneEvents, + eventIdOrGroupName: placementTargetEventId, + }) + : null; + return { eventsDescription: SafeExtractor.extractStringProperty( @@ -5163,15 +5195,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' @@ -5180,6 +5205,11 @@ const addSceneEvents: EditorFunction = { batch, 'placement_rationale' ), + expectedEventSource: SafeExtractor.extractStringProperty( + batch, + 'expected_event_source' + ), + placementTargetEventSource, }; }) : null; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js index f386b81a1062..5751a84a369c 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js @@ -2,7 +2,10 @@ import { makeTestProject } from '../../../fixtures/TestProject'; import { unserializeFromJSObject } from '../../../Utils/Serializer'; import { renderEventsAsEventsScript } from './EventsScriptRenderer'; -import { buildEventsScriptSourceView } from './EventsScriptSourceView'; +import { + buildEventsScriptSourceView, + renderEventOwnSourceById, +} from './EventsScriptSourceView'; const gd: libGDevelop = global.gd; @@ -354,6 +357,34 @@ describe('EventsScriptSourceView', () => { } }); + it('renders the own source of one event (without sub-events) by id', () => { + const { project } = makeTestProject(gd); + try { + const eventsList = makeEventsList(project, manyEventsSerialized); + + expect( + renderEventOwnSourceById({ + eventsList, + eventIdOrGroupName: 'event-0', + }) + ).toBe( + [ + 'if DepartScene(): # event-0', + ' CentreCamera(MySpriteObject)', + ' ChangeAnimation(MySpriteObject, =, 1)', + ].join('\n') + ); + expect( + renderEventOwnSourceById({ + eventsList, + eventIdOrGroupName: 'event-99', + }) + ).toBe(null); + } finally { + project.delete(); + } + }); + it('reports unknown event ids', () => { const { project } = makeTestProject(gd); try { diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js index ae38a76da94b..198265eaaeab 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js @@ -85,6 +85,29 @@ const renderEventOwnTextForMatching = ( .join('\n'); }; +/** + * The current EventScript source of one event (its own lines: header, local + * variables, actions - without its sub-events), resolved by id or group + * name. Used as the reference the `expected_event_source` anchor of replace + * placements is compared against. Returns null when the event is not found. + */ +export const renderEventOwnSourceById = ({ + eventsList, + eventIdOrGroupName, +}: {| + eventsList: gdEventsList, + eventIdOrGroupName: string, +|}): 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; + return renderEventOwnTextForMatching(node.event, firstPath); +}; + const makeWordBoundaryRegex = (name: string): RegExp => { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(^|[^A-Za-z0-9_])${escaped}($|[^A-Za-z0-9_])`); 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 = { From 008135a5cb81969ebcdb94b4fb292abef71aad1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:39:35 +0000 Subject: [PATCH 4/9] Hold the EventScript renderer and the backend serializer together with shared fixtures The renderer now runs the same conformance fixtures as the backend serializer (events-script-serializer.js in GDevelop-services): the same serialized events must render to the exact same EventScript on both sides. The fixtures file (EventsScriptRenderer.fixtures.json) is kept byte-identical with its copy in GDevelop-services - update both in the same change when the rendering evolves. Also cross-reference the backend counterpart (aligned function names and rendering rules) and the EventScript ecosystem README from the header. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- .../EventsScriptRenderer.fixtures.json | 215 ++++++++++++++++++ .../TextRenderer/EventsScriptRenderer.js | 12 + .../TextRenderer/EventsScriptRenderer.spec.js | 29 +++ 3 files changed, 256 insertions(+) create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json new file mode 100644 index 000000000000..8da09c8b42ef --- /dev/null +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json @@ -0,0 +1,215 @@ +{ + "note": "Conformance fixtures shared between the backend EventScript serializer (events-script-serializer.js in GDevelop-services) and the editor-side renderer (EventsScriptRenderer.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/EventsScriptRenderer.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", ""] + } + ] + } + ] + } + ], + "expectedEventsScript": [ + "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": [] + } + ], + "expectedEventsScript": [ + "if not PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject) and Or(DepartScene(), not Timer(2, \"T\")):" + ] + }, + { + "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"] }] + } + ], + "expectedEventsScript": [ + "comment \"Setup\\nwith a second line\"", + "always:", + " local number Count = 3", + " local string Title = \"Hello \\\"world\\\"\"", + "else if DepartScene():", + "for each MySpriteObject:", + " Delete(MySpriteObject)", + "repeat 4 + 3 times:", + "while DepartScene():", + " Wait(1)", + "for each child in Inventory value Item:", + "group \"My group\":", + " always:", + " Delete(MySpriteObject)", + "disabled always:", + " Wait(1)" + ] + }, + { + "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);" + } + ], + "expectedEventsScript": [ + "always:", + " SomeUnknownExtension::Unknown(A, , B)", + "# (event of type \"BuiltinCommonInstructions::JsCode\" cannot be shown as EventScript)" + ] + } + ] +} diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js index 7a62de938ce3..04a64db8698f 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js @@ -1,4 +1,16 @@ // @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 (`EventsScriptRenderer.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; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js index 5751a84a369c..df4191b21e1a 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js @@ -9,6 +9,12 @@ import { 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('./EventsScriptRenderer.fixtures.json'); + const makeEventsList = (project: gdProject, serializedEvents: Array) => { const eventsList = new gd.EventsList(); unserializeFromJSObject( @@ -57,6 +63,29 @@ const sceneStartSerializedEvents = [ }, ]; +describe('EventsScriptRenderer 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 } = renderEventsAsEventsScript({ 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.expectedEventsScript.join('\n') + ); + } finally { + project.delete(); + } + }); + }); +}); + describe('EventsScriptRenderer', () => { it('renders events as EventScript with event id annotations', () => { const { project } = makeTestProject(gd); From d9639c68bd67018406d47dc34029b75288622f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 13:11:33 +0000 Subject: [PATCH 5/9] Rename read_scene_events_source to read_events_source Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/AiGeneration/Utils.js | 2 +- newIDE/app/src/EditorFunctions/index.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 9bb439c8b502..84e83a0e7e97 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -99,7 +99,7 @@ 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. -// v9: `read_scene_events_source` (this editor implements it) and the explicit +// v9: `read_events_source` (this editor implements it) and the explicit // replace relations of `generate_events` (keep or replace sub-events). export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v9'; diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 57cc3a25d99d..1cb27171d688 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -157,7 +157,7 @@ export type EditorFunctionGenericOutput = {| variables?: Array, reminder?: string, animationNames?: string, - // EventScript source view (see `read_scene_events_source`): + // EventScript source view (see `read_events_source`): eventsScript?: string, selectedEventIds?: Array, truncated?: boolean, @@ -4752,7 +4752,7 @@ const EVENTS_SOURCE_MAX_CHARS_LIMIT = 30000; * accepted by the `event_script` field of events generation), with filters * to keep the output small. */ -const readSceneEventsSource: EditorFunction = { +const readEventsSource: EditorFunction = { renderForEditor: ({ args, editorCallbacks }) => { const scene_name = extractRequiredString(args, 'scene_name'); @@ -7875,7 +7875,7 @@ export const editorFunctions: { [string]: EditorFunction } = { put_2d_instances: put2dInstances, put_3d_instances: put3dInstances, read_scene_events: readSceneEvents, - read_scene_events_source: readSceneEventsSource, + read_events_source: readEventsSource, add_scene_events: addSceneEvents, create_scene: createScene, inspect_scene_properties_layers_effects: inspectScenePropertiesLayersEffects, From 42aabd04415a509d5eb8bceaaf86f91dadde2ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 13:47:19 +0000 Subject: [PATCH 6/9] Bump the AI orchestrator tools version to v11 and emit pass for empty bodies The events-editing capabilities (read_events_source, explicit replace relations with the expected_event_source anchor) now ship as tools v11 backend-side (v9 stays as production knows it, v10 is skipped): the editor declares v11 accordingly. The EventsScript renderer now always emits a `pass` body line for an event whose body renders empty, so a lone header (like `else if X:`) never looks like it owns the next non-indented line. The backend parser accepts and ignores `pass`, and its lenient comparisons drop it. Shared conformance fixtures updated (kept byte-identical with the backend copy). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/AiGeneration/Utils.js | 5 +++-- .../EventsScriptRenderer.fixtures.json | 18 +++++++++++++++++- .../TextRenderer/EventsScriptRenderer.js | 6 ++++++ .../TextRenderer/EventsScriptRenderer.spec.js | 7 ++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 84e83a0e7e97..70bf714cf276 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -99,9 +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. -// v9: `read_events_source` (this editor implements it) and the explicit +// v11: `read_events_source` (this editor implements it) and the explicit // replace relations of `generate_events` (keep or replace sub-events). -export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v9'; +// 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/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json index 8da09c8b42ef..0404b33a86b9 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json @@ -84,7 +84,8 @@ } ], "expectedEventsScript": [ - "if not PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject) and Or(DepartScene(), not Timer(2, \"T\")):" + "if not PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject) and Or(DepartScene(), not Timer(2, \"T\")):", + " pass" ] }, { @@ -173,12 +174,15 @@ " 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)", @@ -186,6 +190,18 @@ " Wait(1)" ] }, + { + "name": "empty group renders an explicit pass", + "parsesBack": true, + "serializedEvents": [ + { + "type": "BuiltinCommonInstructions::Group", + "name": "Empty group", + "events": [] + } + ], + "expectedEventsScript": ["group \"Empty group\":", " pass"] + }, { "name": "unknown instruction and non-representable event", "parsesBack": true, diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js index 04a64db8698f..a58110c08aeb 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js @@ -549,6 +549,12 @@ export const renderEventAsEventsScriptLines = ({ } } + // 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; }; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js index df4191b21e1a..b8ff8f2cd920 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js @@ -159,7 +159,10 @@ describe('EventsScriptRenderer', () => { expect(renderingErrors).toEqual([]); expect(text).toBe( - 'if not PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject) and Or(DepartScene(), PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject)) and once: # event-0' + [ + 'if not PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject) and Or(DepartScene(), PlatformBehavior::IsFalling(GroupOfSpriteObjectsWithBehaviors, PlatformerObject)) and once: # event-0', + ' pass', + ].join('\n') ); } finally { project.delete(); @@ -232,9 +235,11 @@ describe('EventsScriptRenderer', () => { ' 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)', From 7fc223aa28203bf7b1ab5b14c1054ade4111c8de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:57:30 +0000 Subject: [PATCH 7/9] Fix read_events_source edge cases and rename EventsScript to EventScript The language is named EventScript: the renderer and source view files, functions (renderEventsAsEventScript, buildEventScriptSourceView...) and the read_events_source output field (eventScript) are renamed accordingly. read_events_source no longer misleads on empty results: a filter matching nothing or unresolved event_ids returns an explicit "the events sheet is NOT empty" note instead of "This scene has no events." (kept only for a truly empty sheet). selectedEventIds now reflects only the events actually shown when trailing events are dropped for size. Ancestor headers shown for context get a `# ...` marker when their own actions are hidden, and a selected `else` event gets a note explaining it binds to the `if` above. The anchor source sent with replace placements now covers exactly what the placement destroys: the event alone for replace_event_but_keep_existing_sub_events, the full subtree for replace_entire_event_and_sub_events - so a concurrently edited sub-event invalidates a whole-subtree replacement. The own source of an action-less event with sub-events now carries the explicit `pass` too. A freshly created repeat event (empty expression) renders as `repeat 0 times:` so every read source stays parseable; shared fixtures updated (byte-identical with the backend copy). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/EditorFunctions/index.js | 34 +-- ...json => EventScriptRenderer.fixtures.json} | 52 ++++- ...riptRenderer.js => EventScriptRenderer.js} | 53 ++--- ...er.spec.js => EventScriptRenderer.spec.js} | 194 +++++++++++++++--- ...SourceView.js => EventScriptSourceView.js} | 117 ++++++++--- 5 files changed, 344 insertions(+), 106 deletions(-) rename newIDE/app/src/EventsSheet/EventsTree/TextRenderer/{EventsScriptRenderer.fixtures.json => EventScriptRenderer.fixtures.json} (81%) rename newIDE/app/src/EventsSheet/EventsTree/TextRenderer/{EventsScriptRenderer.js => EventScriptRenderer.js} (92%) rename newIDE/app/src/EventsSheet/EventsTree/TextRenderer/{EventsScriptRenderer.spec.js => EventScriptRenderer.spec.js} (68%) rename newIDE/app/src/EventsSheet/EventsTree/TextRenderer/{EventsScriptSourceView.js => EventScriptSourceView.js} (75%) diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 1cb27171d688..3eb664da1a12 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -19,9 +19,9 @@ import { type EventsTextRenderingError, } from '../EventsSheet/EventsTree/TextRenderer'; import { - buildEventsScriptSourceView, - renderEventOwnSourceById, -} from '../EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView'; + buildEventScriptSourceView, + renderEventSourceById, +} from '../EventsSheet/EventsTree/TextRenderer/EventScriptSourceView'; import { addMissingObjectBehaviors, addObjectUndeclaredVariables, @@ -158,7 +158,7 @@ export type EditorFunctionGenericOutput = {| reminder?: string, animationNames?: string, // EventScript source view (see `read_events_source`): - eventsScript?: string, + eventScript?: string, selectedEventIds?: Array, truncated?: boolean, notes?: Array, @@ -4816,7 +4816,7 @@ const readEventsSource: EditorFunction = { truncated, notes, renderingErrors, - } = buildEventsScriptSourceView({ + } = buildEventScriptSourceView({ eventsList: scene.getEvents(), eventIds, searchText, @@ -4828,7 +4828,12 @@ const readEventsSource: EditorFunction = { const output: EditorFunctionGenericOutput = { success: true, eventsForSceneNamed: scene_name, - eventsScript: text || noEventsInSceneText, + // 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; @@ -5169,19 +5174,24 @@ const addSceneEvents: EditorFunction = { 'placement_target_event_id' ); - // For replace placements, also send the CURRENT source of the - // event being replaced: the backend compares the - // `expected_event_source` anchor against it (proof the event was - // read and hasn't changed). + // 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' || - placementRelation === 'replace_entire_event_and_sub_events'; + isReplaceEntirePlacement; const placementTargetEventSource = isReplacePlacement && placementTargetEventId - ? renderEventOwnSourceById({ + ? renderEventSourceById({ eventsList: currentSceneEvents, eventIdOrGroupName: placementTargetEventId, + includeSubEvents: isReplaceEntirePlacement, }) : null; diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json similarity index 81% rename from newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json rename to newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json index 0404b33a86b9..8d41f622ac12 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.fixtures.json +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.fixtures.json @@ -1,5 +1,5 @@ { - "note": "Conformance fixtures shared between the backend EventScript serializer (events-script-serializer.js in GDevelop-services) and the editor-side renderer (EventsScriptRenderer.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/EventsScriptRenderer.fixtures.json (GDevelop repository).", + "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", @@ -46,7 +46,7 @@ ] } ], - "expectedEventsScript": [ + "expectedEventScript": [ "if Timer(2, \"SpawnTimer\") and once:", " ResetTimer(\"SpawnTimer\")", " CentreCamera(MySpriteObject)", @@ -83,7 +83,7 @@ "actions": [] } ], - "expectedEventsScript": [ + "expectedEventScript": [ "if not PlatformBehavior::IsFalling(MySpriteObject, PlatformerObject) and Or(DepartScene(), not Timer(2, \"T\")):", " pass" ] @@ -168,7 +168,7 @@ "actions": [{ "type": { "value": "Wait" }, "parameters": ["1"] }] } ], - "expectedEventsScript": [ + "expectedEventScript": [ "comment \"Setup\\nwith a second line\"", "always:", " local number Count = 3", @@ -190,6 +190,46 @@ " 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, @@ -200,7 +240,7 @@ "events": [] } ], - "expectedEventsScript": ["group \"Empty group\":", " pass"] + "expectedEventScript": ["group \"Empty group\":", " pass"] }, { "name": "unknown instruction and non-representable event", @@ -221,7 +261,7 @@ "inlineCode": "runtimeScene.setBackgroundColor(255, 0, 0);" } ], - "expectedEventsScript": [ + "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/EventsScriptRenderer.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js similarity index 92% rename from newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js rename to newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js index a58110c08aeb..d56516d93528 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.js @@ -7,7 +7,7 @@ // 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 (`EventsScriptRenderer.fixtures.json`, byte-identical in both +// 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. @@ -17,7 +17,7 @@ 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 EventsScriptRenderingError = {| +export type EventScriptRenderingError = {| path: string, message: string, |}; @@ -118,7 +118,7 @@ const NOT_TYPE = 'BuiltinCommonInstructions::Not'; const renderConditionExpression = ( instruction: gdInstruction, eventPath: string, - renderingErrors: Array + renderingErrors: Array ): string => { const type = instruction.getType(); @@ -166,7 +166,7 @@ const renderConditionExpression = ( const renderConditionsListExpression = ( conditionsList: gdInstructionsList, eventPath: string, - renderingErrors: Array + renderingErrors: Array ): string => { return mapFor(0, conditionsList.size(), i => renderConditionExpression(conditionsList.get(i), eventPath, renderingErrors) @@ -176,7 +176,7 @@ const renderConditionsListExpression = ( const renderActionLine = ( instruction: gdInstruction, eventPath: string, - renderingErrors: Array + renderingErrors: Array ): string => { const type = instruction.getType(); const metadata = gd.MetadataProvider.getActionMetadata( @@ -242,7 +242,7 @@ type EventScriptParts = {| const buildEventScriptParts = ( event: gdBaseEvent, eventPath: string, - renderingErrors: Array + renderingErrors: Array ): EventScriptParts | null => { const type = event.getType(); @@ -302,10 +302,11 @@ const buildEventScriptParts = ( ? ` 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()} times${indexClause}${ifClause}`, + header: `repeat ${repeatEvent.getRepeatExpression().getPlainString() || + '0'} times${indexClause}${ifClause}`, actionsList: repeatEvent.getActions(), }; } @@ -409,7 +410,7 @@ const renderHeaderLineFromParts = ({ * `comment "..."`), without its body, annotated with its id. Used both by * the full rendering and to show ancestors of filtered events. */ -export const renderEventsScriptHeaderLine = ({ +export const renderEventScriptHeaderLine = ({ event, eventPath, indent, @@ -418,7 +419,7 @@ export const renderEventsScriptHeaderLine = ({ event: gdBaseEvent, eventPath: string, indent: string, - renderingErrors: Array, + renderingErrors: Array, |}): string => { let parts = null; try { @@ -445,7 +446,7 @@ const countEventsAndInstructions = ( const event = eventsList.getEventAt(i); eventsCount++; try { - const renderingErrors: Array = []; + const renderingErrors: Array = []; const parts = buildEventScriptParts(event, '', renderingErrors); if (parts && parts.actionsList) { instructionsCount += parts.actionsList.size(); @@ -465,20 +466,24 @@ const countEventsAndInstructions = ( /** * 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. + * 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 renderEventAsEventsScriptLines = ({ +export const renderEventAsEventScriptLines = ({ event, eventPath, indent, subEventsDepth, + showCollapsedSubEventsMarker, renderingErrors, }: {| event: gdBaseEvent, eventPath: string, indent: string, subEventsDepth: number, - renderingErrors: Array, + showCollapsedSubEventsMarker?: boolean, + renderingErrors: Array, |}): Array => { const lines = []; const bodyIndent = indent + INDENT; @@ -531,7 +536,7 @@ export const renderEventAsEventsScriptLines = ({ if (event.canHaveSubEvents() && event.getSubEvents().getEventsCount() > 0) { if (subEventsDepth > 0) { lines.push( - ...renderEventsListAsEventsScriptLines({ + ...renderEventsListAsEventScriptLines({ eventsList: event.getSubEvents(), parentPath: eventPath, indent: bodyIndent, @@ -539,7 +544,7 @@ export const renderEventAsEventsScriptLines = ({ renderingErrors, }) ); - } else { + } else if (showCollapsedSubEventsMarker !== false) { const { eventsCount, instructionsCount } = countEventsAndInstructions( event.getSubEvents() ); @@ -558,7 +563,7 @@ export const renderEventAsEventsScriptLines = ({ return lines; }; -export const renderEventsListAsEventsScriptLines = ({ +export const renderEventsListAsEventScriptLines = ({ eventsList, parentPath, indent, @@ -569,14 +574,14 @@ export const renderEventsListAsEventsScriptLines = ({ parentPath: string, indent: string, subEventsDepth: number, - renderingErrors: Array, + renderingErrors: Array, |}): Array => { const lines = []; mapFor(0, eventsList.getEventsCount(), i => { const eventPath = (parentPath ? parentPath + '.' : '') + i; try { lines.push( - ...renderEventAsEventsScriptLines({ + ...renderEventAsEventScriptLines({ event: eventsList.getEventAt(i), eventPath, indent, @@ -605,7 +610,7 @@ export const renderEventsListAsEventsScriptLines = ({ * its id (`# event-1.2`) as a comment. Sub-events deeper than * `subEventsDepth` levels are collapsed into a `# ...` marker. */ -export const renderEventsAsEventsScript = ({ +export const renderEventsAsEventScript = ({ eventsList, subEventsDepth, }: {| @@ -613,10 +618,10 @@ export const renderEventsAsEventsScript = ({ subEventsDepth?: number, |}): {| text: string, - renderingErrors: Array, + renderingErrors: Array, |} => { - const renderingErrors: Array = []; - const lines = renderEventsListAsEventsScriptLines({ + const renderingErrors: Array = []; + const lines = renderEventsListAsEventScriptLines({ eventsList, parentPath: '', indent: '', diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js similarity index 68% rename from newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js rename to newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js index b8ff8f2cd920..cb6a145d3ada 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js @@ -1,11 +1,11 @@ // @flow import { makeTestProject } from '../../../fixtures/TestProject'; import { unserializeFromJSObject } from '../../../Utils/Serializer'; -import { renderEventsAsEventsScript } from './EventsScriptRenderer'; +import { renderEventsAsEventScript } from './EventScriptRenderer'; import { - buildEventsScriptSourceView, - renderEventOwnSourceById, -} from './EventsScriptSourceView'; + buildEventScriptSourceView, + renderEventSourceById, +} from './EventScriptSourceView'; const gd: libGDevelop = global.gd; @@ -13,7 +13,7 @@ const gd: libGDevelop = global.gd; // (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('./EventsScriptRenderer.fixtures.json'); +const serializerFixtures = require('./EventScriptRenderer.fixtures.json'); const makeEventsList = (project: gdProject, serializedEvents: Array) => { const eventsList = new gd.EventsList(); @@ -26,9 +26,9 @@ const makeEventsList = (project: gdProject, serializedEvents: Array) => { return eventsList; }; -// A scene start event mirroring the shape that caused a "near miss" in -// production: several unrelated actions and a sub-event, of which an AI -// agent only knows a part. +// 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. const sceneStartSerializedEvents = [ { type: 'BuiltinCommonInstructions::Standard', @@ -63,13 +63,13 @@ const sceneStartSerializedEvents = [ }, ]; -describe('EventsScriptRenderer conformance fixtures', () => { +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 } = renderEventsAsEventsScript({ eventsList }); + 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 @@ -77,7 +77,7 @@ describe('EventsScriptRenderer conformance fixtures', () => { .map(line => line.replace(/\s*# event-[\d.]+$/, '')) .join('\n'); expect(withoutIdAnnotations).toBe( - fixture.expectedEventsScript.join('\n') + fixture.expectedEventScript.join('\n') ); } finally { project.delete(); @@ -86,13 +86,13 @@ describe('EventsScriptRenderer conformance fixtures', () => { }); }); -describe('EventsScriptRenderer', () => { +describe('EventScriptRenderer', () => { it('renders events as EventScript with event id annotations', () => { const { project } = makeTestProject(gd); try { const eventsList = makeEventsList(project, sceneStartSerializedEvents); - const { text, renderingErrors } = renderEventsAsEventsScript({ + const { text, renderingErrors } = renderEventsAsEventScript({ eventsList, }); @@ -153,7 +153,7 @@ describe('EventsScriptRenderer', () => { }, ]); - const { text, renderingErrors } = renderEventsAsEventsScript({ + const { text, renderingErrors } = renderEventsAsEventScript({ eventsList, }); @@ -223,7 +223,7 @@ describe('EventsScriptRenderer', () => { }, ]); - const { text, renderingErrors } = renderEventsAsEventsScript({ + const { text, renderingErrors } = renderEventsAsEventScript({ eventsList, }); @@ -255,7 +255,7 @@ describe('EventsScriptRenderer', () => { try { const eventsList = makeEventsList(project, sceneStartSerializedEvents); - const { text, renderingErrors } = renderEventsAsEventsScript({ + const { text, renderingErrors } = renderEventsAsEventScript({ eventsList, subEventsDepth: 0, }); @@ -275,7 +275,7 @@ describe('EventsScriptRenderer', () => { }); }); -describe('EventsScriptSourceView', () => { +describe('EventScriptSourceView', () => { const manyEventsSerialized = [ ...sceneStartSerializedEvents, { @@ -307,7 +307,7 @@ describe('EventsScriptSourceView', () => { try { const eventsList = makeEventsList(project, manyEventsSerialized); - const view = buildEventsScriptSourceView({ + const view = buildEventScriptSourceView({ eventsList, maxChars: 10000, }); @@ -315,7 +315,8 @@ describe('EventsScriptSourceView', () => { expect(view.renderingErrors).toEqual([]); expect(view.selectedEventIds).toEqual(['event-0', 'event-1', 'event-2']); expect(view.truncated).toBe(false); - // Default whole-sheet depth is 1: event-0.0 is included. + // 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 { @@ -328,7 +329,7 @@ describe('EventsScriptSourceView', () => { try { const eventsList = makeEventsList(project, manyEventsSerialized); - const view = buildEventsScriptSourceView({ + const view = buildEventScriptSourceView({ eventsList, eventIds: ['event-0.0'], maxChars: 10000, @@ -338,8 +339,10 @@ describe('EventsScriptSourceView', () => { expect(view.selectedEventIds).toEqual(['event-0.0']); expect(view.text).toBe( [ - // The ancestor is shown as a header line for context. + // 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)', @@ -355,7 +358,7 @@ describe('EventsScriptSourceView', () => { try { const eventsList = makeEventsList(project, manyEventsSerialized); - const view = buildEventsScriptSourceView({ + const view = buildEventScriptSourceView({ eventsList, searchText: 'changeanimation', maxChars: 10000, @@ -376,7 +379,7 @@ describe('EventsScriptSourceView', () => { try { const eventsList = makeEventsList(project, manyEventsSerialized); - const view = buildEventsScriptSourceView({ + const view = buildEventScriptSourceView({ eventsList, eventIds: ['event-0'], objectNames: ['GroupOfSpriteObjectsWithBehaviors'], @@ -391,15 +394,16 @@ describe('EventsScriptSourceView', () => { } }); - it('renders the own source of one event (without sub-events) by id', () => { + 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( - renderEventOwnSourceById({ + renderEventSourceById({ eventsList, eventIdOrGroupName: 'event-0', + includeSubEvents: false, }) ).toBe( [ @@ -409,9 +413,25 @@ describe('EventsScriptSourceView', () => { ].join('\n') ); expect( - renderEventOwnSourceById({ + 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 { @@ -419,19 +439,131 @@ describe('EventsScriptSourceView', () => { } }); - it('reports unknown event ids', () => { + 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 = buildEventsScriptSourceView({ + 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(); } @@ -456,7 +588,7 @@ describe('EventsScriptSourceView', () => { }, ]); - const view = buildEventsScriptSourceView({ + const view = buildEventScriptSourceView({ eventsList, eventIds: ['event-0'], maxChars: 10000, @@ -478,7 +610,7 @@ describe('EventsScriptSourceView', () => { try { const eventsList = makeEventsList(project, manyEventsSerialized); - const smallView = buildEventsScriptSourceView({ + const smallView = buildEventScriptSourceView({ eventsList, maxChars: 260, }); @@ -486,7 +618,7 @@ describe('EventsScriptSourceView', () => { expect(smallView.text).toContain('not shown: read event_ids:'); expect(smallView.text.length).toBeLessThanOrEqual(260); - const tinyView = buildEventsScriptSourceView({ + const tinyView = buildEventScriptSourceView({ eventsList, maxChars: 150, }); diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js similarity index 75% rename from newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js rename to newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js index 198265eaaeab..67e7e656a5e1 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventsScriptSourceView.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.js @@ -1,10 +1,10 @@ // @flow import { mapFor } from '../../../Utils/MapFor'; import { - renderEventAsEventsScriptLines, - renderEventsScriptHeaderLine, - type EventsScriptRenderingError, -} from './EventsScriptRenderer'; + renderEventAsEventScriptLines, + renderEventScriptHeaderLine, + type EventScriptRenderingError, +} from './EventScriptRenderer'; const gd: libGDevelop = global.gd; @@ -21,12 +21,12 @@ type EventNode = {| * ancestors of selected events shown as header lines and skipped siblings * shown as `# ...` markers). */ -export type EventsScriptSourceView = {| +export type EventScriptSourceView = {| text: string, selectedEventIds: Array, truncated: boolean, notes: Array, - renderingErrors: Array, + renderingErrors: Array, |}; const indexEvents = ( @@ -67,36 +67,44 @@ const compareDocumentOrder = (pathA: string, pathB: string): number => { /** * The event's own EventScript lines (header, local variables, actions - - * without its sub-events), used to match the search/object filters. + * 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 renderEventAsEventsScriptLines({ + const renderingErrors: Array = []; + return renderEventAsEventScriptLines({ event, eventPath: path, indent: '', subEventsDepth: 0, + showCollapsedSubEventsMarker: false, renderingErrors, - }) - .filter(line => !line.trim().startsWith('# ...')) - .join('\n'); + }).join('\n'); }; /** - * The current EventScript source of one event (its own lines: header, local - * variables, actions - without its sub-events), resolved by id or group - * name. Used as the reference the `expected_event_source` anchor of replace - * placements is compared against. Returns null when the event is not found. + * 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 renderEventOwnSourceById = ({ +export const renderEventSourceById = ({ eventsList, eventIdOrGroupName, + includeSubEvents, }: {| eventsList: gdEventsList, eventIdOrGroupName: string, + includeSubEvents: boolean, |}): string | null => { const nodesByPath: Map = new Map(); indexEvents(eventsList, '', nodesByPath); @@ -105,7 +113,17 @@ export const renderEventOwnSourceById = ({ if (firstPath === undefined) return null; const node = nodesByPath.get(firstPath); if (!node) return null; - return renderEventOwnTextForMatching(node.event, firstPath); + 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 => { @@ -162,7 +180,7 @@ const renderFilteredTree = ({ selectedPathsSet: Set, ancestorPathsSet: Set, subEventsDepth: number, - renderingErrors: Array, + renderingErrors: Array, |}): Array => { const lines: Array = []; let skippedPaths: Array = []; @@ -187,7 +205,7 @@ const renderFilteredTree = ({ if (selectedPathsSet.has(path)) { flushSkipped(); lines.push( - ...renderEventAsEventsScriptLines({ + ...renderEventAsEventScriptLines({ event, eventPath: path, indent, @@ -198,13 +216,27 @@ const renderFilteredTree = ({ } else if (ancestorPathsSet.has(path)) { flushSkipped(); lines.push( - renderEventsScriptHeaderLine({ + 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({ @@ -241,7 +273,7 @@ const renderFilteredTree = ({ * depth, then dropping trailing selected events (with a note giving their * ids so they can be fetched with another call). */ -export const buildEventsScriptSourceView = ({ +export const buildEventScriptSourceView = ({ eventsList, eventIds, searchText, @@ -255,9 +287,9 @@ export const buildEventsScriptSourceView = ({ objectNames?: Array | null, subEventsDepth?: number | null, maxChars: number, -|}): EventsScriptSourceView => { +|}): EventScriptSourceView => { const notes: Array = []; - const renderingErrors: Array = []; + const renderingErrors: Array = []; const nodesByPath: Map = new Map(); indexEvents(eventsList, '', nodesByPath); @@ -337,22 +369,37 @@ export const buildEventsScriptSourceView = ({ ) ); - const selectedEventIds = selectedPaths.map(path => `event-${path}`); 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, + selectedEventIds: [], truncated: false, - notes: [ - ...notes, - hasSearch - ? 'No event matches the given filters.' - : 'The events sheet is empty.', - ], + 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; @@ -391,6 +438,9 @@ export const buildEventsScriptSourceView = ({ }).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; @@ -431,6 +481,7 @@ export const buildEventsScriptSourceView = ({ }). Call again with \`event_ids\` (or a narrower filter) to read them.` ); } + shownPaths = keptPaths; truncated = true; } @@ -445,7 +496,7 @@ export const buildEventsScriptSourceView = ({ return { text, - selectedEventIds, + selectedEventIds: shownPaths.map(path => `event-${path}`), truncated, notes, renderingErrors, From 050b5bec25b82ecb2b435ecadd3224e14200c00d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:13:43 +0000 Subject: [PATCH 8/9] Skip the replace anchor check for subtrees too big to be read in one call The proof-of-read reference sent with replace placements was rendered without a size bound, while the backend bounds the field: a subtree bigger than the read_events_source limit would have failed the whole request. Past that limit the reference is now omitted, so the replace proceeds without the check (like an editor without the capability) instead of failing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- newIDE/app/src/EditorFunctions/index.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 3eb664da1a12..da7b4162ce39 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -5186,7 +5186,7 @@ const addSceneEvents: EditorFunction = { placementRelation === 'replace_event_but_keep_existing_sub_events' || isReplaceEntirePlacement; - const placementTargetEventSource = + const renderedTargetEventSource = isReplacePlacement && placementTargetEventId ? renderEventSourceById({ eventsList: currentSceneEvents, @@ -5194,6 +5194,15 @@ const addSceneEvents: EditorFunction = { 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: From c008cef659d710d10f39a50ce6a829a2e09f8624 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:55:54 +0000 Subject: [PATCH 9/9] Move the EventScript source view tests to their own spec file The buildEventScriptSourceView and renderEventSourceById tests move from EventScriptRenderer.spec.js to a dedicated EventScriptSourceView.spec.js, matching the module they test. The shared test helpers (makeEventsList and the scene start serialized events used by both suites) move to EventScriptTestHelpers.js. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012CzTSCY3P7LgWu19yQj7bx --- .../TextRenderer/EventScriptRenderer.spec.js | 411 +----------------- .../EventScriptSourceView.spec.js | 368 ++++++++++++++++ .../TextRenderer/EventScriptTestHelpers.js | 59 +++ 3 files changed, 430 insertions(+), 408 deletions(-) create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptSourceView.spec.js create mode 100644 newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptTestHelpers.js diff --git a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js index cb6a145d3ada..c995e59e8ab4 100644 --- a/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js +++ b/newIDE/app/src/EventsSheet/EventsTree/TextRenderer/EventScriptRenderer.spec.js @@ -1,11 +1,10 @@ // @flow import { makeTestProject } from '../../../fixtures/TestProject'; -import { unserializeFromJSObject } from '../../../Utils/Serializer'; import { renderEventsAsEventScript } from './EventScriptRenderer'; import { - buildEventScriptSourceView, - renderEventSourceById, -} from './EventScriptSourceView'; + makeEventsList, + sceneStartSerializedEvents, +} from './EventScriptTestHelpers'; const gd: libGDevelop = global.gd; @@ -15,54 +14,6 @@ const gd: libGDevelop = global.gd; // fixtures file byte-identical in both repositories. const serializerFixtures = require('./EventScriptRenderer.fixtures.json'); -const makeEventsList = (project: gdProject, serializedEvents: Array) => { - 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. -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', ''] }, - ], - }, - ], - }, -]; - describe('EventScriptRenderer conformance fixtures', () => { serializerFixtures.fixtures.forEach(fixture => { it(`renders like the backend serializer: ${fixture.name}`, () => { @@ -274,359 +225,3 @@ describe('EventScriptRenderer', () => { } }); }); - -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/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', ''] }, + ], + }, + ], + }, +];