The issue said a behavior that runs in the editor keeps the graph it started with, until it is switched off and on. Measured before building anything, that is no longer so. An edit runs from the next frame on. Since when, was not found out. - No code changes. A command raises the project event, EditorRun refreshes, and syncFromModel of the behavior system empties the graph cache. - Decided in the issue: the instance carries on and is not started over. Its variables and timers stand, and On start does not fire again. A restart would write what On start writes or spawns into the saved scene with every edit. - Starting over stays what it was: Run in editor off and on, or Play. - Module 43 of the e2e no longer switches the behavior off and on before it measures the changed layers of a ray cast. Tests: - Gate on melektaus-atx for this change alone: 212 files, 3264 tests (3260 + 4), exit 0. The gate right before this commit ran on the tree of this and the next commit together: 213 files, 3278 tests, exit 0. - e2e 18,20,43 on melektaus-atx: 57 checks, all pass. The check in 43 is the old one, it now holds without the workaround. - Four unit tests in editor-run.test.ts: a changed node setting, a node wired in and one cut off, a flow hung on On start that does not fire until the switch goes off and on, and an undo. - One counter-probe: without the emptied cache the wiring test fails. The setting test does not, settings change in place. - No full run and no smokes. The sum of e2e checks stays 1676. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
/**
|
||
* "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, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||
import {
|
||
AddNodeCommand,
|
||
ConnectCommand,
|
||
RemoveNodeCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../src/core/commands/graphCommands';
|
||
import { instanceChildId } from '../../src/core/model/instance';
|
||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { BehaviorAsset, ContainerAsset, VariablesAsset } 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 type { World } from '../../src/runtime/World';
|
||
import { EditorState } from '../../src/editor/state';
|
||
import { VARIABLES_PARAM } from '../../src/core/graph';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
makeVariables,
|
||
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);
|
||
/** Stands in for the game view being the focused tab; flipped by the keyboard tests. */
|
||
const keyboard = { focused: false, world: null as World | null };
|
||
const run = new EditorRun(state, {
|
||
focused: () => keyboard.focused,
|
||
onWorld: (world) => (keyboard.world = world),
|
||
});
|
||
return {
|
||
...f,
|
||
behavior,
|
||
box,
|
||
logic,
|
||
state,
|
||
run,
|
||
keyboard,
|
||
/** 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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The shape a real level has: the box and its behavior packed into a container asset and placed
|
||
* once, so the scene stores the container object alone and the content is resolved (`instance.ts`).
|
||
*/
|
||
function containerScene() {
|
||
const f = scene();
|
||
const create = new CreateContainerCommand(f.sceneId, [f.box]);
|
||
f.stack.execute(create);
|
||
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
||
const [tBox, tLogic] = asset().objects.map((t) => t.id) as [string, string];
|
||
const child = (templateId: string) => instanceChildId(create.objectId, templateId);
|
||
return {
|
||
...f,
|
||
containerId: create.assetId,
|
||
tBox,
|
||
tLogic,
|
||
child,
|
||
template: (id: string) => asset().objects.find((t) => t.id === id)!,
|
||
instanceEdits: () => f.sceneOf().objects[0]!.instanceEdits,
|
||
switchOn: () => f.state.execute(new SetOverrideCommand(f.sceneId, child(tLogic), 'runInEditor', true)),
|
||
boxX: () => f.object(child(tBox)).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(f.project, 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(f.project, 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: [], keyboard: false });
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('a behavior inside a placed container', () => {
|
||
it('is found although the scene only stores the container object', () => {
|
||
const f = containerScene();
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||
|
||
f.switchOn();
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||
});
|
||
|
||
it('drives the content, and the move is kept as an instance edit instead of baking it out', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.state.setSaveStatus('saved');
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.child(f.tLogic)]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
expect(f.state.saveStatus).toBe('dirty');
|
||
|
||
// Baking would spell the content out as stored objects and drop every instance edit, so what
|
||
// moved has to land on the instance – where the template still reaches the rest of it.
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(10);
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(20);
|
||
});
|
||
|
||
it('leaves the template alone, so a second instance of the same level stays put', () => {
|
||
const f = containerScene();
|
||
const second = place(f, f.containerId, { x: 500, y: 0 });
|
||
f.switchOn();
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
// The switch is an edit of the first instance only: the same box in the second one has neither
|
||
// the behavior running nor the movement.
|
||
expect(f.object(instanceChildId(second, f.tBox)).position.x).toBe(0);
|
||
expect(f.template(f.tBox).position.x).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the keyboard of an editor run', () => {
|
||
it('is the game view’s, and only while something is running there', () => {
|
||
const f = containerScene();
|
||
expect(f.run.debugInfo().keyboard).toBe(false);
|
||
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
// Running, but the editor still owns the keys: the game view is not the focused tab.
|
||
expect(f.run.debugInfo()).toMatchObject({ running: true, keyboard: false });
|
||
|
||
f.keyboard.focused = true;
|
||
expect(f.run.debugInfo().keyboard).toBe(true);
|
||
});
|
||
|
||
it('hands the world to the game view while it runs, so clicks can reach it', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).not.toBeNull();
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.child(f.tLogic), 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).toBeNull();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A Variables asset while the editor runs. This world is built on the saved model, so it is the
|
||
* place where a value that belongs to the run could leak into the document – and must not.
|
||
*/
|
||
describe('shared variables of an editor run', () => {
|
||
/** A Variables asset holding `score`, and a behavior writing 1 into it when it starts. */
|
||
function sharedScene() {
|
||
const f = scene();
|
||
const shared = makeVariables(f, 'Score', ['score']);
|
||
const writer = makeBehavior(f, 'Writer');
|
||
const edit = graphEditor(f, writer);
|
||
const start = edit.addNode('OnStart');
|
||
const one = edit.addNode('Add', { a: 1, b: 0 });
|
||
const write = edit.variables(['score'], { write: true, params: { [VARIABLES_PARAM]: shared.id } });
|
||
edit.wire([
|
||
[start, 'exec', write, 'exec'],
|
||
[one, 'result', write, 'score'],
|
||
]);
|
||
const object = place(f, writer.id, { x: 0, y: 0 });
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, object, 'runInEditor', true));
|
||
return {
|
||
...f,
|
||
sharedId: shared.id,
|
||
writerObject: object,
|
||
live: () => f.run.liveVariables(shared.id)?.score,
|
||
declared: () => (findAsset(f.project, shared.id) as VariablesAsset).props.score,
|
||
};
|
||
}
|
||
|
||
it('holds a value of its own, and leaves the document the one it started from', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(1);
|
||
for (let i = 0; i < 5; i++) f.frame(0.1);
|
||
expect(f.declared()).toBe(0);
|
||
});
|
||
|
||
it('takes over a value edited while it runs', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
f.state.execute(new SetAssetPropertyCommand(f.sharedId, 'score', 5));
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(5);
|
||
});
|
||
|
||
/** Without a run there is no live value, so the panels go back to showing the declaration. */
|
||
it('answers with nothing once the run has ended', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(1);
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.writerObject, 'runInEditor'));
|
||
f.frame(0.1);
|
||
expect(f.run.liveVariables(f.sharedId)).toBeNull();
|
||
});
|
||
});
|
||
|
||
/** The point of the switch is changing a graph and watching, so an edit must not wait for a restart (#216). */
|
||
describe('a graph edited while its behavior runs in the editor (#216)', () => {
|
||
function running() {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
const nodeOf = (type: string) => f.behavior.graph.nodes.find((n) => n.type === type)!;
|
||
return { ...f, nodeOf, boxY: () => f.object(f.box).position.y };
|
||
}
|
||
|
||
it('runs with a changed node setting from the next frame on', () => {
|
||
const f = running();
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
|
||
f.state.execute(new SetNodeParamCommand(f.behavior.id, f.nodeOf('Move').id, 'scale', 1000));
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(110);
|
||
});
|
||
|
||
it('runs a node wired in while it runs, and no longer one that was cut off', () => {
|
||
const f = running();
|
||
const down = new AddNodeCommand(f.behavior.id, 'Move', 0, 0, { dy: 1, scale: 50 });
|
||
f.state.execute(down);
|
||
const port = (node: { id: string }, key: string) => ({ node: node.id, port: key });
|
||
const [update, owner, move] = [f.nodeOf('OnUpdate'), f.nodeOf('Owner'), f.nodeOf('Move')];
|
||
f.state.execute(new ConnectCommand(f.behavior.id, port(move, 'exec'), port(down.node, 'exec')));
|
||
f.state.execute(new ConnectCommand(f.behavior.id, port(update, 'dt'), port(down.node, 'dt')));
|
||
f.state.execute(new ConnectCommand(f.behavior.id, port(owner, 'object'), port(down.node, 'object')));
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.boxY()).toBeCloseTo(5);
|
||
|
||
f.state.execute(new RemoveNodeCommand(f.behavior.id, move.id));
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.boxY()).toBeCloseTo(5);
|
||
});
|
||
|
||
/** Decided in #216: the instance carries on, so what `On start` writes is not written a second time. */
|
||
it('carries on rather than starting over: a flow hung on On start afterwards does not fire', () => {
|
||
const f = running();
|
||
const start = new AddNodeCommand(f.behavior.id, 'OnStart', 0, 0);
|
||
const down = new AddNodeCommand(f.behavior.id, 'Move', 0, 0, { dy: 1, scale: 50 });
|
||
f.state.execute(start);
|
||
f.state.execute(down);
|
||
const port = (node: { id: string }, key: string) => ({ node: node.id, port: key });
|
||
f.state.execute(new ConnectCommand(f.behavior.id, port(start.node, 'exec'), port(down.node, 'exec')));
|
||
f.state.execute(
|
||
new ConnectCommand(f.behavior.id, port(f.nodeOf('Owner'), 'object'), port(down.node, 'object')),
|
||
);
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.boxY()).toBe(0);
|
||
|
||
// Off and on is what starts it over: the world is built anew, and `On start` runs.
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
expect(f.boxY()).not.toBe(0);
|
||
});
|
||
|
||
it('takes an undo of the edit back the same way', () => {
|
||
const f = running();
|
||
f.state.execute(new SetNodeParamCommand(f.behavior.id, f.nodeOf('Move').id, 'scale', 1000));
|
||
f.state.undo();
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
});
|
||
});
|
||
|
||
describe('the game nodes in the editor run (#167)', () => {
|
||
it('ignores Pause game and says so: the scene keeps moving', () => {
|
||
const f = scene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseGame'), 'exec');
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.keyboard.world?.paused).toBe(false);
|
||
expect(f.keyboard.world?.log).toContain('Pause game is ignored in the editor run');
|
||
});
|
||
});
|