Files
GDevelop/newIDE/app/src/EditorFunctions/ApplyEventsChanges.js

539 lines
17 KiB
JavaScript

// @flow
import { unserializeFromJSObject } from '../Utils/Serializer';
import {
type AiGeneratedEventChange,
type AiGeneratedEventUndeclaredVariable,
} from '../Utils/GDevelopServices/Generation';
import { mapFor } from '../Utils/MapFor';
const gd: libGDevelop = global.gd;
/**
* Parses an event path string (e.g., "event-0.1.2") into an array of 0-based indices (e.g., [0, 1, 2]).
* Throws an error for invalid formats or non-positive indices.
*/
const parseEventPath = (pathString: string): Array<number> => {
const originalPathString = pathString;
if (!pathString.startsWith('event-')) {
// Fallback for paths that might not have the "event-" prefix, like "1.2.3"
// This is a lenient parsing, primary expectation is "event-" prefix.
const partsNoPrefix = pathString.split('.');
if (
partsNoPrefix.length > 0 &&
partsNoPrefix.every(s => s !== '' && !isNaN(parseInt(s, 10)))
) {
console.warn(
`Event path string "${originalPathString}" does not start with "event-". Parsed as direct indices.`
);
} else {
throw new Error(
`Invalid event path string format: "${originalPathString}". Expected "event-X.Y.Z" or "X.Y.Z".`
);
}
} else {
pathString = pathString.substring('event-'.length);
}
const parts = pathString.split('.');
if (
parts.length === 0 ||
parts.some(s => s === '' || isNaN(parseInt(s, 10)))
) {
throw new Error(
`Invalid event path string content: "${originalPathString}". Ensure numbers are separated by dots.`
);
}
return parts.map(s => {
const num = parseInt(s, 10);
if (num < 0) {
throw new Error(
`Event path indices must be positive in string "${originalPathString}", but found ${num}.`
);
}
return num;
});
};
/**
* Navigates an event tree to find the parent EventsList and the 0-based index
* for an event targeted by the given path.
*/
const getParentListAndIndex = (
rootEventsList: gdEventsList,
path: Array<number>,
operationTypeForErrorMessage: 'access' | 'insertion'
): { parentList: gdEventsList, eventIndexInParentList: number } => {
if (path.length === 0) {
throw new Error('Path cannot be empty for getParentListAndIndex.');
}
let currentList = rootEventsList;
const pathForErrorMessage = path.join('.');
for (let i = 0; i < path.length - 1; i++) {
const eventIndex = path[i];
if (eventIndex < 0 || eventIndex >= currentList.getEventsCount()) {
throw new Error(
`Invalid event path: index ${eventIndex} out of bounds at depth ${i +
1} (max: ${currentList.getEventsCount() -
1}). Path: ${pathForErrorMessage}`
);
}
const event = currentList.getEventAt(eventIndex);
if (!event.canHaveSubEvents()) {
throw new Error(
`Event at path segment ${i +
1} (index ${eventIndex}) cannot have sub-events. Path: ${pathForErrorMessage}`
);
}
currentList = event.getSubEvents();
}
const finalIndex = path[path.length - 1];
if (finalIndex < 0) {
throw new Error(
`Invalid event path: final index ${finalIndex} is negative. Path: ${pathForErrorMessage}.`
);
}
// For insertion, index can be equal to count (to append). For access, it must be less than count.
if (
operationTypeForErrorMessage === 'insertion' &&
finalIndex > currentList.getEventsCount()
) {
throw new Error(
`Invalid event path for insertion: final index ${finalIndex} is out of bounds. Max allowed for insertion: ${currentList.getEventsCount()}. Path: ${pathForErrorMessage}`
);
} else if (
operationTypeForErrorMessage === 'access' &&
finalIndex >= currentList.getEventsCount()
) {
throw new Error(
`Invalid event path for access: final index ${finalIndex} is out of bounds. Max allowed for access: ${currentList.getEventsCount() -
1}. Path: ${pathForErrorMessage}`
);
}
return { parentList: currentList, eventIndexInParentList: finalIndex };
};
/**
* Retrieves an event at a specific path from a root EventsList.
*/
const getEventByPath = (
rootEventsList: gdEventsList,
path: Array<number>
): gdBaseEvent => {
const { parentList, eventIndexInParentList } = getParentListAndIndex(
rootEventsList,
path,
'access'
);
// Bounds check already done by getParentListAndIndex for 'access'
return parentList.getEventAt(eventIndexInParentList);
};
type EventOperationType = 'delete' | 'insert' | 'insertAsSub';
type EventOperation = {|
type: EventOperationType,
path: Array<number>,
eventsToInsert?: gdEventsList,
|};
const comparePathsReverseLexicographically = (
p1: Array<number>,
p2: Array<number>
): number => {
const maxLength = Math.max(p1.length, p2.length);
for (let i = 0; i < maxLength; i++) {
const val1 = i < p1.length ? p1[i] : -1;
const val2 = i < p2.length ? p2[i] : -1;
if (val1 > val2) return -1;
if (val1 < val2) return 1;
}
return 0;
};
export const applyEventsChanges = (
project: gdProject,
sceneEvents: gdEventsList,
eventOperationsInput: Array<AiGeneratedEventChange>,
aiGeneratedEventId: string
): void => {
const operations: Array<EventOperation> = [];
eventOperationsInput.forEach(change => {
const { operationName, operationTargetEvent, generatedEvents } = change;
let parsedPath: Array<number> | null = null;
let localEventsToInsert: gdEventsList | null = null;
try {
if (operationTargetEvent) {
parsedPath = parseEventPath(operationTargetEvent);
} else if (operationName !== 'insert_at_end') {
// Path is generally required, except for 'insert_at_end'.
console.warn(
`Skipping operation "${operationName}" due to missing operationTargetEvent path.`
);
return;
}
if (generatedEvents && operationName !== 'delete_event') {
const eventsListContent = JSON.parse(generatedEvents);
localEventsToInsert = new gd.EventsList();
unserializeFromJSObject(
localEventsToInsert,
eventsListContent,
'unserializeFrom',
project
);
if (localEventsToInsert.isEmpty()) {
console.warn(
`Generated events for operation "${operationName}" (path: ${operationTargetEvent ||
'N/A'}) are empty. Insertion might not add any events.`
);
}
mapFor(0, localEventsToInsert.getEventsCount(), i => {
if (!localEventsToInsert) return;
const event = localEventsToInsert.getEventAt(i);
event.setAiGeneratedEventId(aiGeneratedEventId);
});
}
switch (operationName) {
case 'insert_and_replace_event':
if (!parsedPath) {
console.warn(
`Skipping "insert_and_replace_event" due to missing or invalid path.`
);
if (localEventsToInsert) localEventsToInsert.delete();
return;
}
operations.push({ type: 'delete', path: parsedPath });
operations.push({
type: 'insert',
path: parsedPath,
eventsToInsert: localEventsToInsert || undefined,
});
// localEventsToInsert is now "owned" by the 'insert' operation,
// it should not be deleted here in the switch case.
break;
case 'insert_before_event':
if (!parsedPath) {
console.warn(
`Skipping "insert_before_event" due to missing or invalid path.`
);
if (localEventsToInsert) localEventsToInsert.delete();
return;
}
operations.push({
type: 'insert',
path: parsedPath,
eventsToInsert: localEventsToInsert || undefined,
});
break;
case 'insert_as_sub_event':
if (!parsedPath) {
console.warn(
`Skipping "insert_as_sub_event" due to missing or invalid path.`
);
if (localEventsToInsert) localEventsToInsert.delete();
return;
}
operations.push({
type: 'insertAsSub',
path: parsedPath, // This path is to the parent event
eventsToInsert: localEventsToInsert || undefined,
});
break;
case 'delete_event':
if (!parsedPath) {
console.warn(
`Skipping "delete_event" due to missing or invalid path.`
);
// No localEventsToInsert expected or created for delete_event.
return;
}
// Ensure no events were accidentally parsed for delete.
if (localEventsToInsert) {
console.warn(
'Internal warning: localEventsToInsert was populated for a "delete_event". Cleaning up.'
);
localEventsToInsert.delete();
}
operations.push({ type: 'delete', path: parsedPath });
break;
case 'insert_at_end':
// Path for insert_at_end is synthetic, representing the end of the root list.
operations.push({
type: 'insert',
path: [sceneEvents.getEventsCount()],
eventsToInsert: localEventsToInsert || undefined,
});
break;
default:
console.warn(
`Unknown operationName: "${operationName}". Skipping operation.`
);
// Clean up localEventsToInsert if it was created for an unknown operation
if (localEventsToInsert) localEventsToInsert.delete();
}
} catch (e) {
console.warn(
`Error processing event change (operation: "${operationName}", path: "${operationTargetEvent ||
'N/A'}"): ${e.message}. Skipping this change.`
);
// Ensure cleanup if parsing/unserialization failed mid-way
if (localEventsToInsert) {
localEventsToInsert.delete();
}
}
});
operations.sort((opA, opB) => {
const pathComparison = comparePathsReverseLexicographically(
opA.path,
opB.path
);
if (pathComparison !== 0) return pathComparison;
if (opA.type === 'delete' && opB.type !== 'delete') return -1;
if (opA.type !== 'delete' && opB.type === 'delete') return 1;
return 0;
});
operations.forEach(op => {
const pathForLog = op.path.join('.');
try {
if (op.type === 'delete') {
const { parentList, eventIndexInParentList } = getParentListAndIndex(
sceneEvents,
op.path,
'access' // Deleting an existing event, so 'access'
);
// Check already done by getParentListAndIndex for 'access'
parentList.removeEventAt(eventIndexInParentList);
} else if (op.type === 'insert') {
const {
parentList,
eventIndexInParentList: insertionIndex,
} = getParentListAndIndex(
sceneEvents,
op.path,
'insertion' // Path is for insertion point
);
// Check already done by getParentListAndIndex for 'insertion'
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
parentList.insertEvents(
op.eventsToInsert,
0,
op.eventsToInsert.getEventsCount(),
insertionIndex
);
} else {
console.warn(
`Insert operation for path [${pathForLog}] skipped: no events to insert or events list is empty.`
);
}
} else if (op.type === 'insertAsSub') {
// op.path is the path to the PARENT event
const parentEvent = getEventByPath(sceneEvents, op.path);
if (!parentEvent.canHaveSubEvents()) {
console.warn(
`Cannot insert sub-events: Event at path [${pathForLog}] does not support sub-events. Skipping.`
);
return;
}
const subEventsList = parentEvent.getSubEvents();
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
subEventsList.insertEvents(
op.eventsToInsert,
0,
op.eventsToInsert.getEventsCount(),
subEventsList.getEventsCount() // Insert at the end of sub-events
);
} else {
console.warn(
`InsertAsSub operation for parent path [${pathForLog}] skipped: no events to insert or events list is empty.`
);
}
}
} catch (error) {
console.error(
`Error applying event operation type ${
op.type
} for path [${pathForLog}]:`,
error
);
} finally {
// Clean up the gd.EventsList associated with this operation, if any.
if (op.eventsToInsert) {
op.eventsToInsert.delete();
}
}
});
};
export const addUndeclaredVariables = ({
project,
scene,
undeclaredVariables,
}: {|
project: gdProject,
scene: gdLayout,
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|}) => {
undeclaredVariables.forEach(variable => {
const { name, type, requiredScope } = variable;
let newVariable = null;
if (requiredScope === 'global') {
if (!project.getVariables().has(name)) {
newVariable = project.getVariables().insertNew(name, 0);
}
} else if (requiredScope === 'scene' || requiredScope === 'none') {
if (!scene.getVariables().has(name)) {
newVariable = scene.getVariables().insertNew(name, 0);
}
} else {
console.warn(
`Unknown requiredScope for undeclared variable: ${name}. Skipping.`
);
}
if (newVariable && type) {
const lowerCaseType = type.toLowerCase();
newVariable.castTo(
lowerCaseType === 'string'
? 'String'
: lowerCaseType === 'boolean'
? 'Boolean'
: lowerCaseType === 'array'
? 'Array'
: lowerCaseType === 'structure'
? 'Structure'
: 'Number'
);
}
});
};
export const addObjectUndeclaredVariables = ({
project,
scene,
objectName,
undeclaredVariables,
}: {|
project: gdProject,
scene: gdLayout,
objectName: string,
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|}) => {
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
project,
scene
);
const setupVariable = (variable: gdVariable, type: string | null) => {
if (!type) {
return;
}
const lowerCaseType = type.toLowerCase();
variable.castTo(
lowerCaseType === 'string'
? 'String'
: lowerCaseType === 'boolean'
? 'Boolean'
: lowerCaseType === 'array'
? 'Array'
: lowerCaseType === 'structure'
? 'Structure'
: 'Number'
);
};
const addVariableForObjectsOfGroup = (
group: gdObjectGroup,
undeclaredVariable: AiGeneratedEventUndeclaredVariable
) => {
const groupVariablesContainer = gd.ObjectVariableHelper.mergeVariableContainers(
projectScopedContainers.getObjectsContainersList(),
group
);
const originalSerializedVariables = new gd.SerializerElement();
groupVariablesContainer.serializeTo(originalSerializedVariables);
const variable = groupVariablesContainer.insertNew(
undeclaredVariable.name,
0
);
setupVariable(variable, undeclaredVariable.type);
const changeset = gd.WholeProjectRefactorer.computeChangesetForVariablesContainer(
originalSerializedVariables,
groupVariablesContainer
);
originalSerializedVariables.delete();
gd.WholeProjectRefactorer.applyRefactoringForGroupVariablesContainer(
project,
project.getObjects(),
scene.getObjects(),
scene.getInitialInstances(),
groupVariablesContainer,
group,
changeset,
originalSerializedVariables
);
};
undeclaredVariables.forEach(undeclaredVariable => {
if (
projectScopedContainers
.getObjectsContainersList()
.hasObjectOrGroupWithVariableNamed(objectName, undeclaredVariable.name)
) {
// Variable already exists, no need to add it.
return;
}
if (scene.getObjects().hasObjectNamed(objectName)) {
const object = scene.getObjects().getObject(objectName);
const variable = object
.getVariables()
.insertNew(undeclaredVariable.name, 0);
setupVariable(variable, undeclaredVariable.type);
} else if (
scene
.getObjects()
.getObjectGroups()
.has(objectName)
) {
const group = scene
.getObjects()
.getObjectGroups()
.get(objectName);
addVariableForObjectsOfGroup(group, undeclaredVariable);
} else if (project.getObjects().hasObjectNamed(objectName)) {
const object = project.getObjects().getObject(objectName);
const variable = object
.getVariables()
.insertNew(undeclaredVariable.name, 0);
setupVariable(variable, undeclaredVariable.type);
}
if (
project
.getObjects()
.getObjectGroups()
.has(objectName)
) {
const group = project
.getObjects()
.getObjectGroups()
.get(objectName);
addVariableForObjectsOfGroup(group, undeclaredVariable);
}
});
};