diff --git a/README.md b/README.md
index 7ce5ba25..7096888f 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Live: · Widget playground: = {
],
},
],
- // Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
- behavior: [],
+ // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
+ behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
collider: [
{
key: 'shape',
@@ -133,9 +133,11 @@ export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
-/** The type schema, or for behaviors their declared variables. */
+/** The type schema, for behaviors followed by their declared variables. */
export function schemaForAsset(asset: Asset): PropertySchema {
- return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
+ return asset.type === 'behavior'
+ ? [...FULL_SCHEMAS.behavior, ...asset.variables]
+ : FULL_SCHEMAS[asset.type];
}
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
diff --git a/src/core/properties/deviation.ts b/src/core/properties/deviation.ts
index e8bcbc12..2abb2794 100644
--- a/src/core/properties/deviation.ts
+++ b/src/core/properties/deviation.ts
@@ -3,7 +3,7 @@ import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy';
import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance';
import { findAsset, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
-import { COMMON_PROPS, type PropertySchema, schemaForAsset } from './PropertySchema';
+import { type PropertySchema, schemaFor, schemaForAsset } from './PropertySchema';
import { deviatesFromAsset, isModified } from './resolve';
/**
@@ -11,7 +11,7 @@ import { deviatesFromAsset, isModified } from './resolve';
* default is declared on the variable itself, so the asset value cannot differ from it.
*/
export function markedAssetSchema(asset: Asset): PropertySchema {
- return asset.type === 'behavior' ? COMMON_PROPS : schemaForAsset(asset);
+ return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(asset);
}
/** Whether any asset property the inspector would mark gold differs from its default. */
diff --git a/src/core/properties/editorRun.ts b/src/core/properties/editorRun.ts
new file mode 100644
index 00000000..37aed0d7
--- /dev/null
+++ b/src/core/properties/editorRun.ts
@@ -0,0 +1,22 @@
+/**
+ * The `runInEditor` switch of a behavior (see `ASSET_SCHEMAS.behavior`). A behavior with it on
+ * keeps running while the project is *not* playing, and then it edits the saved model: what it
+ * moves or writes is a real change that is saved and is not on the undo stack. That is what makes
+ * the switch useful (a behavior can build or arrange content) and why it is off by default.
+ */
+import type { ObjectTree } from '../model/hierarchy';
+import type { PropertyBag } from '../model/Properties';
+import { findAsset, type Project } from '../model/Project';
+import { resolveSceneObjectProps } from './resolve';
+
+export function runsInEditor(props: PropertyBag): boolean {
+ return props.runInEditor === true;
+}
+
+/** Whether a holder contains any behavior asking to run outside play mode. */
+export function hasEditorRun(project: Project, holder: ObjectTree): boolean {
+ return holder.objects.some((object) => {
+ const asset = findAsset(project, object.assetId);
+ return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object));
+ });
+}
diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts
index 17775379..550102a0 100644
--- a/src/editor/Editor.ts
+++ b/src/editor/Editor.ts
@@ -7,6 +7,7 @@ import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
+import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { AssetPanel } from './panels/AssetPanel';
@@ -47,6 +48,7 @@ export class Editor implements PlayHost {
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
private readonly play: PlaySession;
+ private readonly editorRun: EditorRun;
private readonly projects: ProjectSession;
private readonly leftColumn: VStack;
private readonly inspector: Inspector;
@@ -76,6 +78,7 @@ export class Editor implements PlayHost {
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
+ this.editorRun = new EditorRun(state);
this.projects = new ProjectSession(state, store, () => {
if (this.play.isPlaying) this.play.stop();
});
@@ -164,7 +167,12 @@ export class Editor implements PlayHost {
)
this.showTab('container');
});
- state.on('project', () => this.play.syncFromModel());
+ state.on('project', () => {
+ this.play.syncFromModel();
+ this.editorRun.invalidate();
+ });
+ state.on('load', () => this.editorRun.invalidate());
+ state.on('mode', () => this.editorRun.invalidate());
}
showTab(tab: CentreTab): void {
@@ -204,6 +212,7 @@ export class Editor implements PlayHost {
private tick(dt: number): void {
this.measureFps(dt);
this.play.tick(dt);
+ this.editorRun.tick(dt);
}
/** Refreshes the toolbar readout twice a second. */
@@ -221,4 +230,9 @@ export class Editor implements PlayHost {
worldSnapshot(): WorldSnapshot | null {
return this.play.snapshot();
}
+
+ /** What is running without Play, for the check scripts. */
+ editorRunInfo(): { running: boolean; time: number; behaviors: string[] } {
+ return this.editorRun.debugInfo();
+ }
}
diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts
new file mode 100644
index 00000000..e11fadbe
--- /dev/null
+++ b/src/editor/EditorRun.ts
@@ -0,0 +1,113 @@
+/**
+ * Keeps the behaviors switched to "run in editor" running while the project is *not* playing.
+ *
+ * The one thing that makes this different from Play: the world is built from the saved model, not
+ * from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on
+ * the undo stack. The keyboard and the mouse stay with the editor, so input nodes never fire here.
+ */
+import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
+import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
+import { CollisionSystem } from '../runtime/systems/CollisionSystem';
+import { Input } from '../runtime/systems/Input';
+import { type ModelWrite, World } from '../runtime/World';
+import type { EditorState } from './state';
+
+export class EditorRun {
+ private world: World | null = null;
+ private behaviors: BehaviorSystem | null = null;
+ /** Never attached: the editor owns the keyboard, so `Is key down` is always false in here. */
+ private readonly input = new Input();
+ /** The model changed, so *whether* anything runs is open again, not just the world's contents. */
+ private stale = true;
+ /** Set while our own write travels through the listeners, so it does not count as a change. */
+ private writing = false;
+
+ constructor(private readonly state: EditorState) {}
+
+ /** The model changed: a command, a load, or leaving play mode. */
+ invalidate(): void {
+ if (!this.writing) this.stale = true;
+ }
+
+ tick(dt: number): void {
+ if (this.state.mode !== 'edit') {
+ // Playing runs every behavior anyway – and on the runtime copy, which this world is not on.
+ this.stop();
+ this.stale = true;
+ return;
+ }
+ if (this.stale) {
+ this.stale = false;
+ this.refresh();
+ }
+ const world = this.world;
+ if (!world) return;
+ world.tick(Math.min(dt, 0.1));
+ if (this.control(world)) return;
+ this.commit(world.syncToModel());
+ }
+
+ /** Facts for the check scripts: object ids of the behaviors currently running without Play. */
+ debugInfo(): { running: boolean; time: number; behaviors: string[] } {
+ return {
+ running: this.world !== null,
+ time: this.world?.time ?? 0,
+ behaviors: this.behaviors?.runningIds() ?? [],
+ };
+ }
+
+ /** Builds, keeps or drops the world, depending on what the scene now asks for. */
+ private refresh(): void {
+ const scene = this.state.activeScene;
+ if (!scene || !hasEditorRun(this.state.project, scene)) {
+ this.stop();
+ return;
+ }
+ const world = this.world;
+ if (world && world.project === this.state.project && world.sceneId === scene.id) {
+ world.syncFromModel();
+ return;
+ }
+ this.stop();
+ const project = this.state.project;
+ // Baking a container's content into the scene would be an edit nobody asked for, so it is off
+ // here – which is why a behavior inside a placed container does not run outside play mode.
+ const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
+ const collisions = new CollisionSystem();
+ this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
+ fresh.addSystem(collisions);
+ fresh.addSystem(this.behaviors);
+ this.world = fresh;
+ // `On start` already ran while the system started, and it may have failed.
+ this.control(fresh);
+ }
+
+ /** True when the run ended: a behavior failed, stopped the game, or asked to start over. */
+ private control(world: World): boolean {
+ if (world.restartRequested) {
+ this.stop();
+ this.stale = true;
+ return true;
+ }
+ if (!world.stopRequested) return false;
+ const error = world.log.find((line) => line.startsWith('Error in behavior'));
+ this.stop();
+ this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
+ return true;
+ }
+
+ private commit(write: ModelWrite): void {
+ if (!write.values && !write.structure) return;
+ this.writing = true;
+ this.state.runtimeEdit();
+ // Objects appeared or vanished: the panels have to rebuild, not just re-read their values.
+ if (write.structure) this.state.preview();
+ this.writing = false;
+ }
+
+ private stop(): void {
+ this.world?.destroy();
+ this.world = null;
+ this.behaviors = null;
+ }
+}
diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts
index d5fa451a..ce76fa38 100644
--- a/src/editor/PlaySession.ts
+++ b/src/editor/PlaySession.ts
@@ -115,7 +115,7 @@ export class PlaySession {
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
- if (world.syncToModel()) this.state.preview();
+ if (world.syncToModel().structure) this.state.preview();
this.state.runtimeTick();
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
}
diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts
index f8495a72..9d0741a1 100644
--- a/src/editor/debugHook.ts
+++ b/src/editor/debugHook.ts
@@ -50,6 +50,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
walk(app.stage);
return found;
};
+ /**
+ * Centre of the control of an inspector row, addressed by its label. A row aligns its label to a
+ * *minimum* width, so a label wider than the column pushes its control right – which is why a
+ * fixed offset from the label's own text is the wrong way to click one.
+ */
+ const fieldOf = (label: string): { x: number; y: number } | null => {
+ let found: Text | null = null;
+ const walk = (node: Container): void => {
+ if (found !== null || !node.visible) return;
+ if (node instanceof Text && node.text === label) {
+ found = node;
+ return;
+ }
+ for (const child of node.children) walk(child);
+ };
+ walk(app.stage);
+ // TypeScript does not track the assignment inside `walk`, hence the cast.
+ let owner: Container | null = (found as Text | null)?.parent ?? null;
+ while (owner && !(owner instanceof Widget)) owner = owner.parent;
+ const row = owner?.parent;
+ if (!row || !owner) return null;
+ const control = row.children[row.children.indexOf(owner) + 1];
+ return control instanceof Widget ? centreOf(control) : null;
+ };
/**
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
* Menus hide filtered-out entries, so this is also what a search box currently leaves.
@@ -134,6 +158,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
+ inspectorField: fieldOf,
textColor,
overlayTexts,
overlayRect,
@@ -188,6 +213,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
dropPreview: () => containerEditor.dropInfo(),
},
world: () => editor.worldSnapshot(),
+ /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
+ editorRun: () => editor.editorRunInfo(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index f6e01339..ccbb1f8d 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -33,6 +33,7 @@ import {
COMMON_PROPS,
isCommonProp,
type PropertyDef,
+ schemaFor,
schemaForAsset,
} from '../../core/properties/PropertySchema';
import {
@@ -167,8 +168,13 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
}
// The master switch, right where it is looked for: above everything type-specific.
- for (const def of COMMON_PROPS)
- propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
+ const resolved = resolveSceneObjectProps(asset, object);
+ for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved);
+ // A behavior's own switches sit with it rather than in the section below: that one lists what
+ // the behavior *declares*, and `Run in editor` is not something it declared.
+ if (asset.type === 'behavior')
+ for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key)))
+ propertyRow(host, holderId, objectId, asset, def, resolved);
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
@@ -230,10 +236,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
});
}
- // Asset properties (or behavior variables) with overrides; the common switch is shown above.
- const schema = schemaForAsset(asset);
+ // Asset properties (or behavior variables) with overrides; the switches are shown above.
const values = resolveSceneObjectProps(asset, object);
- const own = schema.filter((def) => !isCommonProp(def.key));
+ const own =
+ asset.type === 'behavior'
+ ? asset.variables
+ : schemaForAsset(asset).filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
diff --git a/src/editor/state.ts b/src/editor/state.ts
index 39436aca..0ec90194 100644
--- a/src/editor/state.ts
+++ b/src/editor/state.ts
@@ -201,6 +201,15 @@ export class EditorState {
this.emit('runtime');
}
+ /**
+ * A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change
+ * is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone.
+ */
+ runtimeEdit(): void {
+ this.setSaveStatus('dirty');
+ this.emit('runtime');
+ }
+
private markDirty(): void {
this.validateSelection();
// Play-mode edits live in the runtime copy and are never saved.
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index 4ba59449..3dd052bc 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -13,6 +13,21 @@ import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity';
import type { System } from './systems/System';
+export interface WorldOptions {
+ /**
+ * Whether a placed container's content may be written into the scene (see `syncFromModel`).
+ * Baking is a real, irreversible edit, so the editor run switches it off – at the price of
+ * behaviors inside a placed container not running there.
+ */
+ bakeContainers?: boolean;
+}
+
+/** What `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */
+export interface ModelWrite {
+ structure: boolean;
+ values: boolean;
+}
+
/**
* The running game: entities instantiated from one scene plus the systems ticking them.
*
@@ -51,12 +66,13 @@ export class World {
private constructor(
readonly project: Project,
readonly sceneId: string,
+ private readonly bakeContainers: boolean,
) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
- static instantiate(project: Project, sceneId: string): World {
+ static instantiate(project: Project, sceneId: string, options: WorldOptions = {}): World {
if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`);
- const world = new World(project, sceneId);
+ const world = new World(project, sceneId, options.bakeContainers ?? true);
world.syncFromModel();
return world;
}
@@ -80,7 +96,7 @@ export class World {
// Entities are built from stored objects, and a behavior may move or destroy one of them –
// which content resolved from a container template could not remember. So it is written out
// once, into the play-mode copy of the model that is thrown away afterwards.
- if (bakeInstances(this.project, scene)) this.structureChanged = true;
+ if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true;
const background = scene.props.backgroundColor;
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
const keep = new Set();
@@ -163,12 +179,12 @@ export class World {
/**
* Writes the entities' state into the scene: positions, property values that differ from
* what the model resolves (stored as overrides, so the inspector marks them) and removed
- * entities (their objects are dropped). Returns true when the object list changed – by a
- * destroyed entity here or by a `spawn` since the last call.
+ * entities (their objects are dropped).
*/
- syncToModel(): boolean {
+ syncToModel(): ModelWrite {
const scene = findScene(this.project, this.sceneId);
- if (!scene) return false;
+ if (!scene) return { structure: false, values: false };
+ let values = false;
const spawned = this.structureChanged;
this.structureChanged = false;
const alive = new Set(this.entities.map((e) => e.objectId));
@@ -183,13 +199,18 @@ export class World {
const asset = object && findAsset(this.project, entity.assetId);
if (!object || !asset) continue;
const p = object.position;
- if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
+ if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) {
object.position = { x: entity.x, y: entity.y, z: entity.z };
+ values = true;
+ }
const base = resolveSceneObjectProps(asset, object);
for (const [key, value] of Object.entries(entity.props))
- if (key in base && base[key] !== value) object.overrides[key] = value;
+ if (key in base && base[key] !== value) {
+ object.overrides[key] = value;
+ values = true;
+ }
}
- return spawned || scene.objects.length !== before;
+ return { structure: spawned || scene.objects.length !== before, values };
}
// --- simulation ------------------------------------------------------------------------------
diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts
index f23bdc50..dac43b67 100644
--- a/src/runtime/systems/BehaviorSystem.ts
+++ b/src/runtime/systems/BehaviorSystem.ts
@@ -41,6 +41,11 @@ export class BehaviorSystem implements System {
private readonly input: Input,
/** Source of this frame's collision events; null = no collision detection. */
private readonly collisions: CollisionSystem | null = null,
+ /**
+ * Which behaviors this system is responsible for. Playing runs them all; the editor run
+ * takes only those marked "run in editor" (see `properties/editorRun.ts`).
+ */
+ private readonly shouldRun: (entity: Entity) => boolean = () => true,
) {}
start(world: World): void {
@@ -66,6 +71,7 @@ export class BehaviorSystem implements System {
if (
entity.type !== 'behavior' ||
!isHandleEnabled(entity) ||
+ !this.shouldRun(entity) ||
this.instances.some((i) => i.entity === entity)
)
continue;
@@ -89,6 +95,7 @@ export class BehaviorSystem implements System {
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
+ if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running
this.run(
instance,
(i, ctx) => {
@@ -141,6 +148,11 @@ export class BehaviorSystem implements System {
fire('OnCollisionExit', this.collisions.exits);
}
+ /** Object ids of the behaviors this system is currently running. */
+ runningIds(): string[] {
+ return this.instances.map((i) => i.entity.objectId);
+ }
+
/** Live highlight data for the graph editor: merged over all instances of the asset. */
traceFor(assetId: string): Trace {
const merged: Trace = { edges: new Map(), nodes: new Map() };
diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts
index 396d61a7..c5524ac2 100644
--- a/tests/core/container.test.ts
+++ b/tests/core/container.test.ts
@@ -21,6 +21,7 @@ import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hie
import { instanceChildId } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
+import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
@@ -202,10 +203,24 @@ describe('placing a container', () => {
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
- expect(world.syncToModel()).toBe(true);
+ expect(world.syncToModel().structure).toBe(true);
// Play bakes the placed container into stored objects, and the spawn adds a second set.
expect(f.sceneOf().objects).toHaveLength(8);
});
+
+ it('leaves the instance alone when baking is off, so the editor run cannot spend it', () => {
+ const f = templateSetup();
+ f.stack.execute(new SetOverrideCommand(f.sceneId, f.child(f.tA), 'width', 12));
+ expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]?.overrides.width).toBe(12);
+
+ // Baking would write the content out as plain objects and drop the edits – a real, silent
+ // change to the saved project, which is why the editor run instantiates without it.
+ const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
+ world.tick(1 / 60);
+ expect(world.syncToModel().structure).toBe(false);
+ expect(f.sceneOf().objects).toHaveLength(1);
+ expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined();
+ });
});
describe('editing a container template', () => {
diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts
index 5962d11d..b9814717 100644
--- a/tests/core/enabled.test.ts
+++ b/tests/core/enabled.test.ts
@@ -38,7 +38,7 @@ function wallAndMover() {
describe('the enabled switch', () => {
it('is a common property of every placeable type, defaults to on and overrides per object', () => {
expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true });
- expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']);
+ expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor']);
expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
const f = makeSpriteScene();
diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts
index 5bfa4a65..744855af 100644
--- a/tests/core/graph.test.ts
+++ b/tests/core/graph.test.ts
@@ -307,7 +307,7 @@ describe('variable commands', () => {
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
- expect(behavior.props).toEqual({ enabled: true, velocity: 0 });
+ expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 });
expect(object(logic).overrides).toEqual({ velocity: 5 });
expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity');
stack.undo();
diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts
index f4d0826e..24cad15a 100644
--- a/tests/core/world-sync.test.ts
+++ b/tests/core/world-sync.test.ts
@@ -58,7 +58,9 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
entityA.x = 123;
entityA.props.width = 999; // what a Set property node would do
entityA.props.height = 100; // unchanged → no override
- expect(world.syncToModel()).toBe(false);
+ // No object appeared or vanished, but a position and an override were written – which is
+ // what tells the editor run that it has something to save.
+ expect(world.syncToModel()).toEqual({ structure: false, values: true });
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
expect(object(a).overrides).toEqual({ width: 999 });
@@ -74,7 +76,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
// Destroy (node) removes the entity; the sync drops its object – a structural change.
world.remove(world.findByObjectId(b)!);
- expect(world.syncToModel()).toBe(true);
+ expect(world.syncToModel().structure).toBe(true);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
});
@@ -97,8 +99,8 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
expect(object.position).toEqual({ x: 10, y: 20, z: 1 });
// The spawn is a structural change the panels must see – reported once.
- expect(world.syncToModel()).toBe(true);
- expect(world.syncToModel()).toBe(false);
+ expect(world.syncToModel()).toEqual({ structure: true, values: true });
+ expect(world.syncToModel()).toEqual({ structure: false, values: false });
// A full sync from the model keeps the spawned entity (identity included).
world.syncFromModel();
expect(world.findByObjectId(child.objectId)).toBe(child);
@@ -106,7 +108,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
// Destroying the spawned entity drops its object again.
world.remove(child);
- expect(world.syncToModel()).toBe(true);
+ expect(world.syncToModel().structure).toBe(true);
expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false);
// Unknown or unplaceable assets spawn nothing.
diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts
new file mode 100644
index 00000000..81402d63
--- /dev/null
+++ b/tests/editor/editor-run.test.ts
@@ -0,0 +1,148 @@
+/**
+ * "Run in editor": a behavior that keeps running while the project is not playing. Unlike play
+ * mode this one edits the saved model, so these tests watch what lands in the scene itself.
+ */
+import { describe, expect, it } from 'vitest';
+import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
+import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
+import type { BehaviorAsset } from '../../src/core/model/Asset';
+import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
+import { schemaFor } from '../../src/core/properties/PropertySchema';
+import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
+import { findAsset } from '../../src/core/model/Project';
+import { EditorRun } from '../../src/editor/EditorRun';
+import { EditorState } from '../../src/editor/state';
+import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
+
+/** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
+function drift(f: SceneFixture, behavior: BehaviorAsset): void {
+ const edit = graphEditor(f, behavior);
+ const update = edit.addNode('OnUpdate');
+ const owner = edit.addNode('Owner');
+ const move = edit.addNode('Move', { dx: 1, scale: 100 });
+ edit.wire([
+ [update, 'exec', move, 'exec'],
+ [update, 'dt', move, 'dt'],
+ [owner, 'object', move, 'object'],
+ ]);
+}
+
+/** A box with a drifting behavior below it, in a state the editor run can be pointed at. */
+function scene() {
+ const f = makeSpriteScene('Box');
+ const behavior = makeBehavior(f, 'Drift');
+ drift(f, behavior);
+ const box = place(f, f.spriteId, { x: 0, y: 0 });
+ const logic = place(f, behavior.id, { x: 0, y: 0 }, box);
+
+ const state = new EditorState();
+ state.loadProject(f.project, 1);
+ const run = new EditorRun(state);
+ return {
+ ...f,
+ behavior,
+ box,
+ logic,
+ state,
+ run,
+ /** One frame, after telling the run that the model may have changed (the Editor's wiring). */
+ frame(dt = 1 / 60) {
+ run.invalidate();
+ run.tick(dt);
+ },
+ boxX: () => f.object(box).position.x,
+ };
+}
+
+describe('the run-in-editor switch', () => {
+ it('is a behavior property, off unless a scene object asks for it', () => {
+ const f = scene();
+ expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor');
+ const asset = findAsset(f.project, f.behavior.id)!;
+
+ expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(false);
+ expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
+
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+ expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(true);
+ expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
+ });
+
+ it('leaves everything alone until it is switched on', () => {
+ const f = scene();
+ for (let i = 0; i < 10; i++) f.frame();
+ expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [] });
+ expect(f.boxX()).toBe(0);
+ });
+
+ it('drives the object in the saved model once it is on, and marks the project for saving', () => {
+ const f = scene();
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+ // The command itself dirtied the project; the question is whether the *run* does too.
+ f.state.setSaveStatus('saved');
+
+ f.frame(0.1);
+ expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
+ expect(f.boxX()).toBeCloseTo(10);
+ // Not a copy, not a preview: the stored scene object moved, and that has to be saved.
+ expect(f.sceneOf().objects.find((o) => o.id === f.box)!.position.x).toBeCloseTo(10);
+ expect(f.state.saveStatus).toBe('dirty');
+
+ f.frame(0.1);
+ expect(f.boxX()).toBeCloseTo(20);
+ });
+
+ it('runs only the behaviors that ask for it', () => {
+ const f = scene();
+ const second = makeBehavior(f, 'Idle');
+ drift(f, second);
+ place(f, second.id, { x: 0, y: 0 }, f.box);
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+
+ f.frame(0.1);
+ // Both behaviors own the same box: were the second one running too, it would drift twice as far.
+ expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
+ expect(f.boxX()).toBeCloseTo(10);
+ });
+
+ it('stops the moment the switch is taken back', () => {
+ const f = scene();
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+ f.frame(0.1);
+ expect(f.boxX()).toBeCloseTo(10);
+
+ f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
+ f.frame(0.1);
+ f.frame(0.1);
+ expect(f.run.debugInfo().running).toBe(false);
+ expect(f.boxX()).toBeCloseTo(10);
+ });
+
+ it('holds still while the project is playing – that world runs every behavior already', () => {
+ const f = scene();
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+ f.state.enterPlay();
+
+ f.frame(0.1);
+ expect(f.run.debugInfo().running).toBe(false);
+
+ // The play copy carries the same ids, so the saved model must be the one left untouched.
+ f.state.exitPlay();
+ expect(f.boxX()).toBe(0);
+ });
+
+ it('does not run a behavior whose scene is not the loaded one', () => {
+ const f = scene();
+ f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
+ f.frame(0.1);
+ expect(f.run.debugInfo().running).toBe(true);
+
+ const other = new CreateAssetCommand('scene', 'Level 2');
+ f.state.execute(other);
+ f.project.activeSceneId = other.asset.id;
+ f.frame(0.1);
+ f.frame(0.1);
+ expect(f.run.debugInfo().running).toBe(false);
+ expect(f.boxX()).toBeCloseTo(10);
+ });
+});
diff --git a/todo-notes/done/execute-behavior-in-editor-mode.md b/todo-notes/done/execute-behavior-in-editor-mode.md
new file mode 100644
index 00000000..09b07bcf
--- /dev/null
+++ b/todo-notes/done/execute-behavior-in-editor-mode.md
@@ -0,0 +1,92 @@
+ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch):
+- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer)
+- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode)
+
+---
+
+**Umgesetzt (2026-09-09).** Der Schalter heisst **`Run in editor`** und steht im Inspector eines
+Behavior-Objekts direkt unter `Enabled`. Ist er an, laeuft das Behavior weiter, waehrend das
+Projekt *nicht* spielt (`src/editor/EditorRun.ts`, vom Editor neben der Play-Session getickt).
+
+## Die eine Entscheidung, die der Schalter erzwingt
+
+Play arbeitet auf einer **Wegwerf-Kopie** — deshalb ist Spielen fuer das gespeicherte Projekt
+gefahrlos. Ausserhalb von Play gibt es diese Kopie nicht, und damit stand die Frage im Raum, was
+ein laufendes Behavior ueberhaupt anfassen darf. Entschieden wurde (Rueckfrage an den User) fuer
+die **Unity-Semantik** (`ExecuteInEditMode`):
+
+> Was das Behavior bewegt oder schreibt, ist eine **echte Aenderung am gespeicherten Modell**.
+> Sie wird autosaved, die Panels zeigen sie — und **Undo holt sie nicht zurueck**.
+
+Das macht den Schalter maechtig (ein Behavior kann Inhalt bauen oder anordnen) und ist genau der
+Grund, warum er **standardmaessig aus** ist. Der E2E-Check haelt beide Haelften fest: der Server
+liefert die verschobene Position aus, und nach zwei Undos ist der Schalter weg, die Bewegung nicht.
+
+## Was der Editor-Lauf bewusst *nicht* bekommt
+
+| | |
+|---|---|
+| **Eingaben** | Tastatur und Maus bleiben beim Editor, sonst wuerde Tippen in einem Textfeld Behaviors ausloesen. `On key …`, `On click` und `Is key down` feuern hier nie; `On start`, `On update`, `On timer` und Kollisionen schon. |
+| **Container-Inhalt** | `syncFromModel` schreibt den Inhalt platzierter Container als normale Objekte aus (*baking*) und loescht dabei deren `instanceEdits` — im Editor waere das eine stille, irreversible Aenderung. Der Lauf instanziiert deshalb mit `bakeContainers: false`. **Preis:** ein Behavior *innerhalb* eines platzierten Containers laeuft ausserhalb von Play nicht. |
+| **ein zweites Leben** | Nur die Behaviors der **geladenen** Szene laufen, und nur im Edit-Modus. Play stoppt den Editor-Lauf — dort laeuft ohnehin jedes Behavior. Eine Exception im Graphen oder ein `Stop game`-Node beendet den Lauf und schreibt den Grund in die Notice-Zeile, statt ihn 60× pro Sekunde zu wiederholen. |
+
+Der zweite Punkt der Notiz faellt damit von selbst heraus: die Welt wird aus der aktiven Szene
+gebaut, ein Behavior einer anderen Szene hat gar keine Entity. Beim Szenenwechsel wird der Lauf
+neu aufgebaut oder verworfen.
+
+## Kein Save-Sturm
+
+Ein Behavior, das jeden Frame etwas verschiebt, macht das Modell jeden Frame schmutzig — 60×
+pro Sekunde speichern waere die naheliegende Katastrophe. Zwei Dinge verhindern das:
+
+- `syncToModel()` meldet jetzt, **ob** ueberhaupt geschrieben wurde (`{structure, values}` statt
+ eines blossen `boolean`). Ein Behavior, das nur liest oder wartet, macht gar nichts schmutzig.
+- `setSaveStatus('dirty')` emittiert nur bei **Wechsel**, der Autosave-Timer wird also nicht jeden
+ Frame neu gestellt. Gemessen: **2 Speicherungen in 2 Sekunden**, nicht ~120.
+
+## Ein Darstellungsfehler, den erst der Screenshot gezeigt hat
+
+`schemaForAsset` liefert fuer ein Behavior `COMMON_PROPS + Typschema + Variablen`. Der
+Object-Inspector zeigte alles ausser `enabled` unter der Ueberschrift **VARIABLES** — der neue
+Schalter landete also dort, wo nur steht, was das Behavior *deklariert*, und die Notiz „The
+behavior declares no variables." verschwand fuer immer. Beides ist repariert: die Typschema-Zeilen
+eines Behaviors stehen jetzt bei `Enabled`, die Sektion darunter fuehrt wieder nur die Variablen.
+Aufgefallen ist es nur, weil ich mir den Screenshot angesehen habe — die Checks waren zufrieden.
+
+## Ein Messgeraet statt eines geratenen Offsets
+
+Der erste Anlauf klickte die Checkbox ueber `locate('Enabled').x + 55`. Das trifft nur Zeilen,
+deren Label in die 64-px-Spalte passt — `Run in editor` ist breiter und schiebt seine Checkbox
+11 px nach rechts, also ging der Klick daneben und fuenf Checks fielen um. Neu ist deshalb
+`__gm.inspectorField(label)`: es liefert die Bildschirmmitte des **Controls** einer Inspector-
+Zeile, gefunden ueber sein Label. Damit ist jeder kuenftige Inspector-Check unabhaengig von der
+Labelbreite.
+
+## Belegt
+
+Acht Unit-Tests (`tests/editor/editor-run.test.ts`, plus einer in `container.test.ts` fuer das
+Nicht-Backen). Gegengeprueft: dreht man den Filter auf „alle Behaviors" und die Szenen-Bedingung
+weg, fallen **vier von sieben** um; mit `bakeContainers: true` faellt der Container-Test.
+Dazu zehn E2E-Checks in Modul 17:
+
+```
+PASS a placed behavior does not run outside play mode until it is asked to
+ – {"running":false,"time":0,"behaviors":[]} x=0
+PASS the inspector offers the switch on a behavior object, with Enabled and above the variables
+ – {"enabledBox":{"x":1070,"y":221},"runBox":{"x":1081,"y":253},"variables":{…,"y":509.5}}
+PASS switching it on is an ordinary override on the object – {"runInEditor":true}
+PASS the behavior runs although Play was never pressed
+ – {"running":true,"time":0.46,"behaviors":["er-logic"]} mode=edit
+PASS the owner drifts on, and the moved object is the stored one – x 48.33 → 108.32
+PASS what the behavior moved is saved, not just shown – stored x=308.31 (started at 0)
+PASS saving stays debounced while it runs, instead of once per frame – 2 saves in 2 s
+PASS taking the switch back stops the run and the object stands still – x=329.98
+PASS undo takes back the switch, never the movement it caused – overrides={} x=351.65, started at 0
+PASS the step leaves nothing of its own behind
+```
+
+Der Schritt baut sich seine eigene Kiste samt Behavior (`On update → Move(owner)`) statt den
+Mover der Fixture zu nehmen — der laeuft ueber die Pfeiltasten, und die sieht der Editor-Lauf
+absichtlich nie. Am Ende raeumt er alles wieder weg.
+
+Screenshot `36-editor-run`.
diff --git a/todo-notes/execute-behavior-in-editor-mode.md b/todo-notes/execute-behavior-in-editor-mode.md
deleted file mode 100644
index 0ae8b003..00000000
--- a/todo-notes/execute-behavior-in-editor-mode.md
+++ /dev/null
@@ -1,3 +0,0 @@
-ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch):
-- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer)
-- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode)
diff --git a/tools/e2e/17-editor-run.mjs b/tools/e2e/17-editor-run.mjs
new file mode 100644
index 00000000..740b3927
--- /dev/null
+++ b/tools/e2e/17-editor-run.mjs
@@ -0,0 +1,180 @@
+import { edge, node } from '../lib/builders.mjs';
+
+/**
+ * "Run in editor": a behavior that keeps running while the project is not playing, and whose
+ * work lands in the saved project.
+ *
+ * The step brings its own box and its own behavior instead of using the fixture's Mover: that one
+ * moves along the arrow keys, and the editor run deliberately never sees the keyboard. Everything
+ * it adds is removed again at the end.
+ */
+
+const DRIFT_PER_SECOND = 100;
+
+/** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */
+const driftGraph = {
+ nodes: [
+ node('d-update', 'OnUpdate', 0, 0),
+ node('d-owner', 'Owner', 0, 120),
+ node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
+ ],
+ edges: [
+ edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'),
+ edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'),
+ edge('d-e3', 'd-owner', 'object', 'd-move', 'object'),
+ ],
+};
+
+export async function editorRun(t) {
+ const { check, sleep, evaluate, json, shot, click, mutate, undo, object, api, projectId } = t;
+
+ // 18a. A box with a drifting behavior below it. Written into the model directly: what is under
+ // test is the switch, not another way of building a graph.
+ await mutate(`
+ const p = __gm.state.project;
+ const scene = __gm.state.activeScene;
+ const proto = scene.objects[0];
+ const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
+ Object.assign(asset, {
+ id: 'drifter',
+ name: 'Drifter',
+ props: { enabled: true, runInEditor: false },
+ variables: [],
+ graph: ${JSON.stringify(driftGraph)},
+ });
+ p.assets.push(asset);
+ const make = (id, assetId, parentId, y) => {
+ const o = structuredClone(proto);
+ Object.assign(o, {
+ id,
+ assetId,
+ name: id === 'er-box' ? 'Drift box' : 'Drifter',
+ position: { x: 0, y, z: 0 },
+ parentId,
+ overrides: {},
+ instanceEdits: {},
+ });
+ return o;
+ };
+ scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
+ `);
+ await sleep(400);
+
+ const boxX = async () => (await object('er-box')).position.x;
+ const startX = await boxX();
+ await sleep(500);
+ const idle = await json('__gm.editorRun()');
+ check(
+ 'a placed behavior does not run outside play mode until it is asked to',
+ idle.running === false && (await boxX()) === startX,
+ `${JSON.stringify(idle)} x=${startX}`,
+ );
+
+ // 18b. The switch itself, through the inspector. It belongs with Enabled and above the
+ // VARIABLES section: it is a switch of the behavior, not something the behavior declares.
+ await evaluate(
+ `__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
+ );
+ await sleep(200);
+ const enabledBox = await json(`__gm.inspectorField('Enabled')`);
+ const runBox = await json(`__gm.inspectorField('Run in editor')`);
+ const variables = await json(`__gm.locate('VARIABLES')`);
+ check(
+ 'the inspector offers the switch on a behavior object, with Enabled and above the variables',
+ Boolean(runBox) &&
+ Boolean(enabledBox) &&
+ Boolean(variables) &&
+ runBox.y > enabledBox.y &&
+ runBox.y < variables.y,
+ JSON.stringify({ enabledBox, runBox, variables }),
+ );
+ await click(runBox.x, runBox.y);
+ await sleep(300);
+
+ const logic = await object('er-logic');
+ check(
+ 'switching it on is an ordinary override on the object',
+ logic.overrides.runInEditor === true,
+ JSON.stringify(logic.overrides),
+ );
+
+ const running = await json('__gm.editorRun()');
+ const mode = await evaluate('__gm.state.mode');
+ check(
+ 'the behavior runs although Play was never pressed',
+ running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit',
+ `${JSON.stringify(running)} mode=${mode}`,
+ );
+
+ // 18c. It really drives its owner, and it is the stored scene object that moves – no runtime
+ // copy is involved here, which is the whole difference to playing.
+ const before = await boxX();
+ await sleep(600);
+ const after = await boxX();
+ check(
+ 'the owner drifts on, and the moved object is the stored one',
+ after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit',
+ `x ${before} → ${after}`,
+ );
+ await shot('36-editor-run');
+
+ // 18d. The point of this semantic: the change is a real edit, so it reaches the server.
+ const first = await api(`projects/${projectId}`);
+ await sleep(2000);
+ const second = await api(`projects/${projectId}`);
+ const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box')
+ .position.x;
+ check(
+ 'what the behavior moved is saved, not just shown',
+ savedX > startX,
+ `stored x=${savedX} (started at ${startX})`,
+ );
+ // A behavior writing every frame must not turn into a save per frame. That it saves at all is
+ // the check above; this one is only about the ceiling – 60 frames a second would be ~120.
+ const saves = second.revision - first.revision;
+ check(
+ 'saving stays debounced while it runs, instead of once per frame',
+ saves <= 6,
+ `${saves} saves in 2 s`,
+ );
+
+ // 18e. Taking the switch back stops it again.
+ await click(runBox.x, runBox.y);
+ await sleep(300);
+ const stoppedAt = await boxX();
+ await sleep(500);
+ const stopped = await json('__gm.editorRun()');
+ check(
+ 'taking the switch back stops the run and the object stands still',
+ stopped.running === false && (await boxX()) === stoppedAt,
+ `${JSON.stringify(stopped)} x=${stoppedAt}`,
+ );
+
+ // 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
+ // while it was on. That is the price of editing the model directly, better stated here than
+ // discovered later. Note the first undo takes back the *switching off*, so the behavior
+ // briefly runs again between the two: undo rewinds the switch, never the clock.
+ await undo();
+ await undo();
+ await sleep(300);
+ const afterUndo = await object('er-box');
+ check(
+ 'undo takes back the switch, never the movement it caused',
+ (await object('er-logic')).overrides.runInEditor === undefined &&
+ afterUndo.position.x >= stoppedAt &&
+ afterUndo.position.x > startX,
+ `overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
+ );
+
+ await mutate(`
+ const p = __gm.state.project;
+ const scene = __gm.state.activeScene;
+ scene.objects = scene.objects.filter((o) => o.id !== 'er-box' && o.id !== 'er-logic');
+ p.assets = p.assets.filter((a) => a.id !== 'drifter');
+ `);
+ await sleep(300);
+ check(
+ 'the step leaves nothing of its own behind',
+ (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0,
+ );
+}
diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs
index 168fa57f..770d9f31 100644
--- a/tools/e2e/index.mjs
+++ b/tools/e2e/index.mjs
@@ -19,6 +19,7 @@ import { frames } from './13-frames.mjs';
import { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
+import { editorRun } from './17-editor-run.mjs';
export const STEPS = [
hierarchy,
@@ -37,4 +38,5 @@ export const STEPS = [
overflow,
aspect,
containerTab,
+ editorRun,
];