Structs and lists were capped at two fixed levels (list of structs, struct with a list field). Anything deeper was dropped silently on load, in node settings and when written to a variable. The cap came from an early design choice, not from a technical need, so it is lifted completely instead of adding a third fixed level. - One kind list NESTED_KINDS replaces ListItemKind and StructFieldKind. The flat special cases in listItems.ts are gone, every value goes through one recursive path. - A struct inside a struct is declared, like a top-level struct. Values coming in over an edge are brought into that shape. Struct items in lists still take their fields from the value. - New valuePath.ts addresses any nested spot by keys and indices. ListSlot.field is now ListSlot.path. - Field commands (add, rename, remove) take the path to the struct. They change declaration, own value and all overrides in one undo step, as before on the top level. - Inspector: "+ Field" offers Struct and List on every level. A nested struct is a header row with its own "+ Field", its fields are indented one step further. - List tab: a list item shows "n items" and opens one level deeper. The title shows the path, e.g. "Listy · nums · [3]". - Pack in Pack now writes a struct into a struct field instead of text. Objects are kept at any depth and stripped at every exit. - A list edit in a struct field without an existing object override now starts from the shown value instead of an empty struct. - Old projects load unchanged. Save, load and save again gives the same result. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii, 21d and 22f updated) - 20 new unit tests in nested-values.test.ts and nested-list-slot.test.ts, three tests that pinned the old cap turned around - 6 counter-probes, each fails 1 to 8 tests, originals restored and checked with cmp - Not covered: a nested struct inside a struct item in the list tab has no e2e click Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
/**
|
||
* Objects in variables, struct fields and list items: a spawned object written into one is there on
|
||
* the next frame, a destroyed one reads as nothing, and no handle ever leaves the run – not into the
|
||
* scene, the file, a save or what the editor is shown.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddPortsCommand } from '../../src/core/commands/portCommands';
|
||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import {
|
||
asString,
|
||
resumedVariables,
|
||
type SaveArea,
|
||
type SavedValues,
|
||
VARIABLES_PARAM,
|
||
writeTyped,
|
||
} from '../../src/core/graph';
|
||
import { bagWithoutObjects, liveValue } from '../../src/core/graph/liveObjects';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties';
|
||
import { findScene } from '../../src/core/model/Project';
|
||
import { isKindWith, NESTED_KINDS, VARIABLE_KINDS } from '../../src/core/properties/kinds';
|
||
import { makeItem } from '../../src/core/properties/listItems';
|
||
import { serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
fakeEntity,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
makeVariables,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
/** Spawns a Crate on start into `spawned`, and logs what `spawned` holds on every update. */
|
||
function spawner(f: SceneFixture): BehaviorAsset {
|
||
const behavior = makeBehavior(f, 'Spawner');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'spawned', 'object'));
|
||
const edit = graphEditor(f, behavior);
|
||
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||
const set = edit.variables(['spawned'], { write: true });
|
||
const get = edit.variables(['spawned']);
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
|
||
[spawn, 'exec', set, 'exec'],
|
||
[spawn, 'object', set, 'spawned'],
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'spawned', log, 'message'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
return behavior;
|
||
}
|
||
|
||
/** Spawns a Crate on start into the field `target` of the struct `held`; logs that field on every update. */
|
||
function structHolder(f: SceneFixture): BehaviorAsset {
|
||
const behavior = makeBehavior(f, 'Holder');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'target', kind: 'object' }));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'count', kind: 'number' }));
|
||
const edit = graphEditor(f, behavior);
|
||
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||
const pack = edit.addNode('PackStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, pack.id, [{ key: 'target', kind: 'object' }]));
|
||
const set = edit.variables(['held'], { write: true });
|
||
const get = edit.variables(['held']);
|
||
const expand = edit.addNode('ExpandStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'target', kind: 'object' }]));
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
|
||
[spawn, 'exec', set, 'exec'],
|
||
[spawn, 'object', pack, 'target'],
|
||
[pack, 'value', set, 'held'],
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'held', expand, 'value'],
|
||
[expand, 'target', log, 'message'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
return behavior;
|
||
}
|
||
|
||
/** Spawns a Crate on start into the list `crates`; logs its first item on every update. */
|
||
function listHolder(f: SceneFixture): BehaviorAsset {
|
||
const behavior = makeBehavior(f, 'Keeper');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'crates', 'list'));
|
||
const edit = graphEditor(f, behavior);
|
||
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||
const one = edit.addNode('Fill', { count: 1 });
|
||
const set = edit.variables(['crates'], { write: true });
|
||
const get = edit.variables(['crates']);
|
||
const first = edit.addNode('ItemAt', { index: 0 });
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
|
||
[spawn, 'exec', set, 'exec'],
|
||
[spawn, 'object', one, 'value'],
|
||
[one, 'result', set, 'crates'],
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'crates', first, 'list'],
|
||
[first, 'item', log, 'message'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
return behavior;
|
||
}
|
||
|
||
const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) =>
|
||
run.world.entities.filter((e) => e.assetId === f.spriteId);
|
||
|
||
const instanceOf = (run: ReturnType<typeof startWorld>, behavior: BehaviorAsset) =>
|
||
run.world.entities.find((e) => e.assetId === behavior.id)!;
|
||
|
||
const placedOf = (f: SceneFixture, behavior: BehaviorAsset) =>
|
||
findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!;
|
||
|
||
describe('the object kind', () => {
|
||
it('can be a variable, a struct field and a list item', () => {
|
||
expect(VARIABLE_KINDS).toContain('object');
|
||
expect(NESTED_KINDS).toContain('object');
|
||
expect(isKindWith('tagged', 'object')).toBe(true);
|
||
});
|
||
|
||
it('is stored as the empty object wherever it sits, whatever the file says', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = makeBehavior(f, 'Holder');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'who', kind: 'object' }));
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'crowd', 'list'));
|
||
behavior.props.crowd = [makeItem('object', 'Crate'), 3];
|
||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
expect(loaded.variables.find((v) => v.key === 'target')).toMatchObject({
|
||
kind: 'object',
|
||
default: NO_OBJECT,
|
||
});
|
||
expect(loaded.props).toMatchObject({
|
||
target: NO_OBJECT,
|
||
held: { who: NO_OBJECT },
|
||
crowd: [NO_OBJECT, 3],
|
||
});
|
||
expect(NO_OBJECT).toEqual({ $object: '' });
|
||
});
|
||
});
|
||
|
||
describe('an object held by a running game', () => {
|
||
it('stays in a variable on the next frames', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
spawner(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
for (let i = 0; i < 3; i++) run.frame();
|
||
expect(crates(f, run)).toHaveLength(1);
|
||
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
|
||
});
|
||
|
||
it('stays in a struct field on the next frames, beside the other fields', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = structHolder(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
for (let i = 0; i < 3; i++) run.frame();
|
||
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
|
||
const held = instanceOf(run, behavior).props.held as unknown as Record<string, unknown>;
|
||
expect(held.target).toBe(crates(f, run)[0]);
|
||
expect(held.count).toBe(0);
|
||
});
|
||
|
||
it('stays in a list on the next frames', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = listHolder(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
for (let i = 0; i < 3; i++) run.frame();
|
||
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
|
||
expect(instanceOf(run, behavior).props.crates).toEqual([crates(f, run)[0]]);
|
||
});
|
||
|
||
it.each([
|
||
['a variable', spawner],
|
||
['a struct field', structHolder],
|
||
['a list item', listHolder],
|
||
])('reads as nothing from %s once the object is destroyed', (_where, build) => {
|
||
const f = makeSpriteScene('Crate');
|
||
build(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.world.remove(crates(f, run)[0]!);
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('');
|
||
});
|
||
|
||
it('tells "gone" by Equals against an empty input', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = spawner(f);
|
||
const edit = graphEditor(f, behavior);
|
||
const get = edit.variables(['spawned']);
|
||
const gone = edit.addNode('Equals');
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'spawned', gone, 'a'],
|
||
[gone, 'result', log, 'message'],
|
||
]);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
const alive = run.world.log.at(-1);
|
||
run.world.remove(crates(f, run)[0]!);
|
||
run.frame();
|
||
expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']);
|
||
});
|
||
|
||
it('is read and written on another behavior through Properties', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const holder = makeBehavior(f, 'Holder');
|
||
f.stack.execute(new AddVariableCommand(holder.id, 'target', 'object'));
|
||
const writer = makeBehavior(f, 'Writer');
|
||
const edit = graphEditor(f, writer);
|
||
const find = edit.addNode('ObjectByName', { name: 'Holder' });
|
||
const crate = edit.addNode('ObjectByName', { name: 'Crate' });
|
||
const set = edit.properties([{ key: 'target', kind: 'object', write: true }], {
|
||
objectType: 'behavior',
|
||
objectBehavior: holder.id,
|
||
});
|
||
const get = edit.properties([{ key: 'target', kind: 'object' }], {
|
||
objectType: 'behavior',
|
||
objectBehavior: holder.id,
|
||
});
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', set, 'exec'],
|
||
[find, 'object', set, 'object'],
|
||
[crate, 'object', set, 'target'],
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[find, 'object', get, 'object'],
|
||
[get, 'target', log, 'message'],
|
||
]);
|
||
place(f, holder.id, { x: 0, y: 0 });
|
||
place(f, writer.id, { x: 0, y: 0 });
|
||
place(f, f.spriteId, { x: 0, y: 0 });
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('Crate');
|
||
});
|
||
});
|
||
|
||
describe('no object leaves the run', () => {
|
||
it.each([
|
||
['a variable', spawner, 'spawned', undefined],
|
||
['a struct field', structHolder, 'held', undefined],
|
||
// One item more than declared is a real difference – the scene learns the item, not the object.
|
||
['a list item', listHolder, 'crates', [NO_OBJECT]],
|
||
])('writes nothing of %s into the scene or the file', (_where, build, key, override) => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = build(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.frame();
|
||
run.world.syncToModel();
|
||
expect(asString(instanceOf(run, behavior).props[key] as never)).toContain('Crate');
|
||
expect(placedOf(f, behavior).overrides[key]).toEqual(override);
|
||
expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow();
|
||
});
|
||
|
||
it('keeps the other fields of a struct flowing into the scene, with the object emptied', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = structHolder(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
const live = instanceOf(run, behavior).props;
|
||
live.held = { ...(live.held as object), count: 5 };
|
||
run.world.syncToModel();
|
||
expect(placedOf(f, behavior).overrides.held).toEqual({ target: NO_OBJECT, count: 5 });
|
||
});
|
||
|
||
it('keeps what a run holds when the model is read in again (editor run)', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
const behavior = structHolder(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.world.syncToModel();
|
||
run.world.syncFromModel();
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('Crate');
|
||
expect((instanceOf(run, behavior).props.held as unknown as Record<string, unknown>).target).toBe(
|
||
crates(f, run)[0],
|
||
);
|
||
});
|
||
|
||
it('empties objects however deep they sit, and leaves everything else as it is', () => {
|
||
const crate = fakeEntity('Crate');
|
||
const bag = {
|
||
target: crate,
|
||
held: { who: crate, n: 1 },
|
||
crowd: [crate, 2, { who: crate }],
|
||
plain: [1, 2],
|
||
} as unknown as PropertyBag;
|
||
const out = bagWithoutObjects(bag);
|
||
expect(out).toEqual({
|
||
target: NO_OBJECT,
|
||
held: { who: NO_OBJECT, n: 1 },
|
||
crowd: [NO_OBJECT, 2, { who: NO_OBJECT }],
|
||
plain: [1, 2],
|
||
});
|
||
expect(out.plain).toBe(bag.plain);
|
||
expect(JSON.parse(JSON.stringify(out))).toEqual(out);
|
||
});
|
||
|
||
it('reads a destroyed object as nothing alone, as the empty object inside a list or struct', () => {
|
||
const crate = fakeEntity('Crate');
|
||
const dead = () => false;
|
||
expect(liveValue(crate as never, dead)).toBeNull();
|
||
expect(liveValue(NO_OBJECT, () => true)).toBeNull();
|
||
expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]);
|
||
expect(liveValue({ who: crate } as never, dead)).toEqual({ who: NO_OBJECT });
|
||
expect(liveValue([crate] as never, () => true)).toEqual([crate]);
|
||
});
|
||
|
||
it('names the objects of a list or struct when it reads as text', () => {
|
||
const crate = fakeEntity('Crate');
|
||
expect(asString({ who: crate, n: 1 } as never)).toBe('who: Crate, n: 1');
|
||
expect(asString([{ who: crate }] as never)).toBe('who: Crate');
|
||
});
|
||
});
|
||
|
||
describe('an object in a persistent Variables asset', () => {
|
||
/** On start: log what `target` holds, then spawn a Crate into it and into `crowd`, and count `score` up. */
|
||
function persistent() {
|
||
const f = makeSpriteScene('Crate');
|
||
const shared = makeVariables(f, 'Progress', ['score']);
|
||
f.stack.execute(new AddVariableCommand(shared.id, 'target', 'object'));
|
||
f.stack.execute(new AddVariableCommand(shared.id, 'crowd', 'list'));
|
||
f.stack.execute(new SetVariablesPersistentCommand(shared.id, true));
|
||
const behavior = makeBehavior(f, 'Spawner');
|
||
const edit = graphEditor(f, behavior);
|
||
const params = { [VARIABLES_PARAM]: shared.id };
|
||
const read = edit.variables(['score', 'target'], { params });
|
||
const log = edit.addNode('Log');
|
||
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||
const add = edit.addNode('Add', { b: 1 });
|
||
const one = edit.addNode('Fill', { count: 1 });
|
||
const write = edit.variables(['score', 'target', 'crowd'], { write: true, params });
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', log, 'exec'],
|
||
[read, 'target', log, 'message'],
|
||
[log, 'exec', spawn, 'exec'],
|
||
[spawn, 'exec', write, 'exec'],
|
||
[spawn, 'object', write, 'target'],
|
||
[spawn, 'object', one, 'value'],
|
||
[one, 'result', write, 'crowd'],
|
||
[read, 'score', add, 'a'],
|
||
[add, 'result', write, 'score'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
return { f, shared };
|
||
}
|
||
|
||
it('is saved as nothing and starts the next run at nothing, beside the kept values', () => {
|
||
const { f, shared } = persistent();
|
||
let saved: SavedValues = {};
|
||
const area: SaveArea = { read: () => structuredClone(saved), write: (s) => (saved = structuredClone(s)) };
|
||
const first = startWorld(f, { behaviors: true, saves: area });
|
||
first.frame();
|
||
expect(first.world.log).toEqual(['']);
|
||
const live = first.behaviors!.shared.variables[shared.id]!;
|
||
expect(asString(live.target as never)).toBe('Crate');
|
||
expect(asString(live.crowd as never)).toBe('Crate');
|
||
expect(saved).toEqual({ [shared.id]: { score: 1, target: NO_OBJECT, crowd: [NO_OBJECT] } });
|
||
const second = startWorld(f, { behaviors: true, saves: area });
|
||
second.frame();
|
||
expect(second.world.log).toEqual(['']);
|
||
expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('a kept call (#229)', () => {
|
||
it('hands a kept object on to the next call, and only a kept one', () => {
|
||
const crate = fakeEntity('Crate');
|
||
const left: PropertyBag = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
|
||
writeTyped(left, 'target', crate);
|
||
writeTyped(left, 'other', crate);
|
||
writeTyped(left, 'held', { who: crate } as never);
|
||
const defaults = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
|
||
const next = resumedVariables(defaults, left, ['target', 'held']);
|
||
expect(next.target).toBe(crate);
|
||
expect(next.other).toBe(NO_OBJECT);
|
||
expect((next.held as unknown as Record<string, unknown>).who).toBe(crate);
|
||
});
|
||
|
||
it('does not resume an object where the declaration now says text, or the other way round', () => {
|
||
const crate = fakeEntity('Crate');
|
||
const left: PropertyBag = { a: NO_OBJECT, b: '', c: { x: 1 } };
|
||
writeTyped(left, 'a', crate);
|
||
const next = resumedVariables({ a: '', b: NO_OBJECT, c: NO_OBJECT }, left, ['a', 'b', 'c']);
|
||
expect(next).toEqual({ a: '', b: NO_OBJECT, c: NO_OBJECT });
|
||
});
|
||
});
|