Files
feuerwerk/tests/editor/editor-run.test.ts
T
adminandClaude Opus 5 3427f84a55
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m48s
A game can pause itself and set its own time scale (#167)
Until now only the editor toolbar could pause a running game. A pause
menu inside the game had no way to stop the world while its own graph
kept running, and there was no slow motion.

- Five new nodes: Pause game, Resume game, Is game paused, Set time
  scale and Time scale. Pause and resume take effect on the next frame.
- A game pause stops world time, physics, animations, particles and
  shader time. Only behaviors with the new "Run while paused" switch
  keep running.
- Set time scale 0 freezes the world but graphs keep running, as
  agreed in the issue.
- Play and Stop reset the time scale to 1, Load scene keeps it. The
  pause belongs to the scene, so Load scene and Restart unpause.
- The toolbar pause stays separate and also freezes the menu. When the
  game pauses itself the status line reads "The game paused itself".
- While paused, collision events are not reported, sounds keep
  playing, and the editor run ignores both nodes with a log line.

Findings:
- The first status line was longer than any before and got cut with
  an ellipsis. It is shorter now.
- The e2e harness did not know the key "p". It does now.

Tests:
- ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952)
- npm run build: exit 0
- GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26)
- 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run
- 7 new e2e checks in block 18i, driven by real keys in the game
- Six counter-checks all bite: behavior filter, collision lock, world
  time, editor run, clock reset and the game clock in the browser
- Check total 1217 in README is computed (1210 + 7), CI measures it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:27:17 +02:00

333 lines
12 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 { 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');
});
});