game-maker / suite (push) Failing after 8m59s
This reverses the design of the previous commit. `PropertyDef.scope` is gone without replacement: a behavior variable is an object property again, without exception, and sharing is something you point at rather than something you declare twice. A **Variables asset** is declarations and nothing else -- no graph, no position, never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that costs no line). A `Variables` node names one through its new `variablesAsset` setting, empty meaning the behavior's own variables as before, and then reads and writes one value per key for the whole run. Its property list follows the chosen source, which was the actual request: a value another graph declared is now offerable, where before every participant had to declare the name itself. That deletes more than it adds (-72 lines net): `globalConflicts` and its run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both object-inspector filters and the "shared by the whole run" note on the placed object. A typo is no longer a silent second variable -- naming an asset is picking from a list. And two Variables assets may carry the same key without meeting, because the asset is the key, not the name. The one shape it copies is the one that already existed: `Set state` picks its machine the same way. Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the declarations and never written back, so playing in the editor cannot change the project -- which is the whole reason a counter must not be an object property (`World.syncToModel` would `commit()` it, and the coin counter would stand at 7 for good). Full symmetry across that gap, without mirroring anything. Two named wires: `Inspector.liveVariables` shows the values as they stand while a run is on and falls back to the declarations when it ends, and `adoptDeclarations()` takes a key into the run *only where the document changed it since the last sync*. Without that condition any unrelated project edit would snap a running counter back to its starting value; a counter-test bends exactly that and falls over. Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches nodes without a `read`, and the variable node has one. The wire was missing. The state machine `Scope` row is now an ordinary `enum` row through `createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and began ~34px left of every value above it; the explanations beside `Local` and `Global` are gone from the menu, the sentence below the row keeps saying it. Machine scope is otherwise untouched -- `StateMachineAsset.scope`, `SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59 stays closed as built and `Scope` as a type now carries machines alone. Six counter-proofs, each biting at its own guard. One of them did not bite at first: the suggestion list was computed twice, in `nodeSection` and `portSection`, so only the browser test covered the request itself. It is one named function now, `variableChoices`, with a unit test of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 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 { 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();
|
||
});
|
||
});
|