Files
feuerwerk/tests/editor/editor-run.test.ts
T
adminandClaude Opus 5.5 af9b202100
game-maker / gate (push) Successful in 3m39s
game-maker / suite (push) Failing after 19m26s
Keep the game's music when Play ends a run in the editor
A behavior with "Run in editor" kept its world until the frame after
Play. By then the game's On start had already started its pattern or
song, and ending the editor run called stopAll() on the sound library.
The game came up silent. This is what turned the game blocks of e2e
64 and 65 red in suites 542 and 550.

- EditorRun.modeChanged() ends the run the moment the mode leaves
  edit. enterPlay() announces the mode before the game is built, so
  the editor run's sounds stop first and the game's start after.
- The Editor wires the mode event to it instead of invalidate().

Tests:
- e2e 64 starts its game with an idle "Run in editor" behavior in
  the scene. Without the fix it fails like CI did, "game": [] and
  2 FAILED. With it: ALL PASSED, 30 checks (stein2).
- Unit editor-run: 21 green, 1 new.
- Typecheck, ESLint, Prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:24 +02:00

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* "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('ends the moment playing starts, before the game’s On start, not a frame later', () => {
const f = scene();
f.state.on('mode', () => f.run.modeChanged());
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(true);
// The game is built right after `enterPlay` in the same click; a run ended only by the next
// frame stopped every sound, the music its On start had just begun with them.
f.state.enterPlay();
expect(f.run.debugInfo().running).toBe(false);
expect(f.keyboard.world).toBeNull();
f.state.exitPlay();
f.frame(0.1);
expect(f.run.debugInfo().running).toBe(true);
});
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');
});
});