/** * "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 { 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(); }); }); 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'); }); });