An Object variable could only be set by a graph while the game runs.
A placed object always showed "(null)", although the other objects of
its scene are there to choose from and the runtime can already find
any object by its id. Now the placed object stores a reference to one
of them, and the running game turns it into the real object.
- Stored as {"$object": "<id>"}. The empty placeholder stays as it
was, so old projects load unchanged and empty still means "(null)".
- The row of an Object variable on a placed object is a dropdown with
None and every object of the scene, indented like the Hierarchy.
The same goes for Object fields in structs and Object items in
lists, also in the Variables and List tabs.
- The behavior asset keeps "(null)". An asset has no scene, so "Apply
to asset" is gone from Object rows.
- The runtime resolves the reference when the Variables or Properties
node reads it. A destroyed target reads as null, a missing one shows
"(missing)" in the row.
- Inside a container only objects of the same container are offered.
The reference keeps the template id, so every placement finds its
own copy.
- Deleting the target clears the reference in the same undo step.
Paste points references inside the pasted group at the copies and
leaves outside references alone. Create container and Unpack carry
references along with the new ids.
- A value a graph writes while running stays in the run. The scene
keeps the picked object, also in "Run in editor".
Found on the way:
- Setting an override coerced every object slot to empty, in plain
values, struct fields and list items. The shared objectPlace() now
keeps the reference.
Not done:
- No eyedropper to pick in the scene, the dropdown is enough for now.
- Deleting a template object in the container editor does not clean up
references in other scenes. They read as null there.
Tests:
- ./check.sh: 187 files, 2724 tests, CHECK EXIT 0
- e2e 1-9, 51, 57: ALL PASSED, 389 checks
- e2e 1, 57 on the final bundle: ALL PASSED, 17 checks
- 13 unit tests in object-references.test.ts, 5 checks in e2e 57
- Counter-checks: 8 unit breaks fail their tests, the browser break
without a pick list fails 57b-57d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
9.6 KiB
TypeScript
231 lines
9.6 KiB
TypeScript
/**
|
||
* Structs and lists nest to any depth (#239): a struct field may be a struct with a declaration of
|
||
* its own, a list item a list, and nothing deeper is dropped – not on load, not on the way through
|
||
* a graph. What was stored under the old bound still loads as it was.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddPortsCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
AddStructFieldCommand,
|
||
AddVariableCommand,
|
||
RemoveStructFieldCommand,
|
||
RenameStructFieldCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import { writeTyped } from '../../src/core/graph';
|
||
import { liveValue, withoutObjects } from '../../src/core/graph/liveObjects';
|
||
import { asField, heldObject, type Value } from '../../src/core/graph/values';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import {
|
||
NO_OBJECT,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
sameValue,
|
||
tagged,
|
||
} from '../../src/core/model/Properties';
|
||
import { variableDef, type StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||
import { valueAt, withValueAt } from '../../src/core/properties/valuePath';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
fakeEntity,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
/** `enemy` = { hp, loot: { item, drops } }, `loot` declared inside the struct. */
|
||
function enemyOn(f: SceneFixture): BehaviorAsset {
|
||
const behavior = makeBehavior(f, 'Enemy');
|
||
const id = behavior.id;
|
||
const run = (command: Parameters<typeof f.stack.execute>[0]) => f.stack.execute(command);
|
||
run(new AddVariableCommand(id, 'enemy', 'struct'));
|
||
run(new AddStructFieldCommand(id, 'enemy', { key: 'hp', kind: 'number' }));
|
||
run(new AddStructFieldCommand(id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
|
||
run(new AddStructFieldCommand(id, 'enemy', { key: 'item', kind: 'text' }, ['loot']));
|
||
run(new AddStructFieldCommand(id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
|
||
return behavior;
|
||
}
|
||
|
||
const declared = (behavior: BehaviorAsset) => behavior.variables.find((v) => v.key === 'enemy')!;
|
||
|
||
describe('a struct field that is a struct', () => {
|
||
it('declares its own fields, and every value takes that shape at once', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = enemyOn(f);
|
||
expect(declared(behavior)).toMatchObject({
|
||
fields: [
|
||
{ key: 'hp', kind: 'number' },
|
||
{
|
||
key: 'loot',
|
||
kind: 'struct',
|
||
fields: [
|
||
{ key: 'item', kind: 'text' },
|
||
{ key: 'drops', kind: 'list' },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
const empty = { hp: 0, loot: { item: '', drops: [] } };
|
||
expect(declared(behavior).default).toEqual(empty);
|
||
expect(behavior.props.enemy).toEqual(empty);
|
||
});
|
||
|
||
it('brings what arrives into the declared shape, however deep', () => {
|
||
const f = makeSpriteScene();
|
||
const fields = (declared(enemyOn(f)) as { fields: readonly StructFieldDef[] }).fields;
|
||
const def = variableDef('e', 'struct', { hp: '5', loot: { item: 3, extra: 1 }, other: 2 }, fields);
|
||
expect(def.default).toEqual({ hp: 5, loot: { item: '3', drops: [] } });
|
||
});
|
||
|
||
it('keeps declaration and a deep value through save and load', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = enemyOn(f);
|
||
const deep = {
|
||
hp: 3,
|
||
loot: { item: 'gem', drops: [{ name: 'a', tags: ['x', [1, tagged('color', 0xff0000)]] }, [[2]]] },
|
||
};
|
||
behavior.props.enemy = deep;
|
||
const back = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
expect(back.props.enemy).toEqual(deep);
|
||
expect(declared(back)).toEqual(declared(behavior));
|
||
});
|
||
|
||
it('adds, renames and removes a nested field in the declaration and every value, undo included', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = enemyOn(f);
|
||
behavior.props.enemy = { hp: 1, loot: { item: 'gem', drops: [7] } };
|
||
|
||
f.stack.execute(new RenameStructFieldCommand(behavior.id, 'enemy', 'item', 'name', ['loot']));
|
||
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
|
||
|
||
f.stack.execute(new RemoveStructFieldCommand(behavior.id, 'enemy', 'drops', ['loot']));
|
||
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem' } });
|
||
expect((declared(behavior) as { fields: readonly StructFieldDef[] }).fields).toEqual([
|
||
{ key: 'hp', kind: 'number' },
|
||
{ key: 'loot', kind: 'struct', fields: [{ key: 'name', kind: 'text' }] },
|
||
]);
|
||
|
||
f.stack.undo();
|
||
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
|
||
f.stack.undo();
|
||
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { item: 'gem', drops: [7] } });
|
||
});
|
||
|
||
it('refuses a path that leads to no struct', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = enemyOn(f);
|
||
expect(() =>
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'enemy', { key: 'x', kind: 'number' }, ['hp'])),
|
||
).toThrow('Field "hp" has no fields');
|
||
expect(behavior.props.enemy).toEqual({ hp: 0, loot: { item: '', drops: [] } });
|
||
});
|
||
});
|
||
|
||
describe('stored values from before the bound was lifted', () => {
|
||
it('load exactly as they were', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'Old');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'rows', 'list'));
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'route', 'struct'));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'name', kind: 'text' }));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'stops', kind: 'list' }));
|
||
behavior.props.rows = [{ x: 1, c: tagged('color', 5) }, 2, 'a', tagged('texture', 't')];
|
||
behavior.props.route = { name: 'n', stops: [1, tagged('color', 3)] };
|
||
const stored = serializeProject(f.project);
|
||
expect(serializeProject(deserializeProject(stored))).toEqual(stored);
|
||
});
|
||
});
|
||
|
||
describe('a nested value in a running game', () => {
|
||
/** Pack(item) into Pack(loot) into `enemy`; on every update Expand twice and log the item. */
|
||
function packer(f: SceneFixture): BehaviorAsset {
|
||
const behavior = enemyOn(f);
|
||
const edit = graphEditor(f, behavior);
|
||
const inner = edit.addNode('PackStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, inner.id, [{ key: 'item', kind: 'text' }]));
|
||
inner.params.item = 'gem';
|
||
const outer = edit.addNode('PackStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, outer.id, [{ key: 'loot', kind: 'struct' }]));
|
||
const set = edit.variables(['enemy'], { write: true });
|
||
const get = edit.variables(['enemy']);
|
||
const first = edit.addNode('ExpandStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, first.id, [{ key: 'loot', kind: 'struct' }]));
|
||
const second = edit.addNode('ExpandStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, second.id, [{ key: 'item', kind: 'text' }]));
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', set, 'exec'],
|
||
[inner, 'value', outer, 'loot'],
|
||
[outer, 'value', set, 'enemy'],
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'enemy', first, 'value'],
|
||
[first, 'loot', second, 'value'],
|
||
[second, 'item', log, 'message'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
return behavior;
|
||
}
|
||
|
||
it('is written level by level and taken apart the same way', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = packer(f);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('gem');
|
||
// Merged into the declared shape: the fields nobody wrote keep their values.
|
||
const instance = run.world.entities.find((e) => e.assetId === behavior.id)!;
|
||
expect(instance.props.enemy).toEqual({ hp: 0, loot: { item: 'gem', drops: [] } });
|
||
});
|
||
|
||
it('keeps a list in a list and a struct in a struct when written into a variable', () => {
|
||
const bag: PropertyBag = { grid: [], nest: { a: { b: 0 } } };
|
||
writeTyped(bag, 'grid', [
|
||
[1, 2],
|
||
[3, [4]],
|
||
]);
|
||
writeTyped(bag, 'nest', { a: { b: 5, c: 1 } });
|
||
expect(bag.grid).toEqual([
|
||
[1, 2],
|
||
[3, [4]],
|
||
]);
|
||
expect(bag.nest).toEqual({ a: { b: 5 } });
|
||
});
|
||
|
||
it('holds an object at any depth, and lets none of them out', () => {
|
||
const crate = fakeEntity('Crate');
|
||
const held = asField([{ who: { at: [crate] } }] as unknown as Value);
|
||
expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate));
|
||
expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
||
expect(liveValue(held, { isAlive: () => false, referenced: () => null })).toEqual([
|
||
{ who: { at: [NO_OBJECT] } },
|
||
]);
|
||
expect(liveValue(held, { isAlive: () => true, referenced: () => null })).toEqual(held);
|
||
});
|
||
});
|
||
|
||
describe('a path into a nested value', () => {
|
||
const value: PropertyValue = { a: [1, { b: [2, 3] }] };
|
||
|
||
it('reads what sits there, and nothing where a step finds nothing', () => {
|
||
expect(valueAt(value, ['a', 1, 'b', 0])).toBe(2);
|
||
expect(valueAt(value, ['a', 5])).toBeUndefined();
|
||
expect(valueAt(value, ['a', 'b'])).toBeUndefined();
|
||
});
|
||
|
||
it('writes a copy, leaving the value it started from alone', () => {
|
||
const next = withValueAt(value, ['a', 1, 'b', 1], 9);
|
||
expect(next).toEqual({ a: [1, { b: [2, 9] }] });
|
||
expect(value).toEqual({ a: [1, { b: [2, 3] }] });
|
||
expect(withValueAt(value, ['a', 7], 9)).toBe(value);
|
||
});
|
||
|
||
it('compares nested values by content', () => {
|
||
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [2] }] })).toBe(true);
|
||
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [3] }] })).toBe(false);
|
||
});
|
||
});
|