Let a variable hold a list
A variable could only be a number, a text or a yes/no, so every set of values had to be smuggled through a string - which is where Tetris got its unreadable "03066326048763714" colours from. A list is now a value of its own, with an item kind (number, text, yes/no, colour) that decides how the inspector reads the one comma-separated line it is typed as, and eight nodes to work with: List, Fill, Count, Item at, Set item, Sub list, Join lists, Index of. Two traps came with it. An array is typeof 'object', so every place that told a value from an entity had to learn the difference, or a list would have been passed around as a game object. And a value was compared by identity in three places - with lists that marks everything as an overridden property after each reload, so those comparisons now compare contents. There is no list of objects: a list is written to the project file and an object is a live handle that no reload survives. A set of objects stays a list of names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6f907b4e0
commit
a9c3111377
25 files changed
+469
-25
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { sameValue } from '../../src/core/model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
const findObject = (project: Project, id: string) =>
|
||||
objectHolders(project)
|
||||
.flatMap((holder) => holder.objects)
|
||||
.find((o) => o.id === id);
|
||||
|
||||
describe('a list as a value', () => {
|
||||
it('survives saving and loading – with its items, not as a string', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'grid', 'list'));
|
||||
const object = place(f, behavior.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'grid', [1, 0, 2]));
|
||||
|
||||
const loaded = roundTrip(f.project);
|
||||
const stored = findObject(loaded, object)?.overrides.grid;
|
||||
expect(stored).toEqual([1, 0, 2]);
|
||||
});
|
||||
|
||||
it('survives inside a container instance too, where no schema is there to coerce it', () => {
|
||||
// Instance edits are read from an untyped slot: the old filter kept number/string/boolean and
|
||||
// dropped everything else, which would have swallowed the list without a word.
|
||||
const loaded = deserializeProject(
|
||||
{
|
||||
name: 'P',
|
||||
activeSceneId: 's1',
|
||||
assets: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'scene',
|
||||
objects: [
|
||||
{
|
||||
id: 'i1',
|
||||
assetId: 'c1',
|
||||
position: {},
|
||||
instanceEdits: { b1: { overrides: { grid: [3, 4] } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'c1', type: 'container', objects: [] },
|
||||
],
|
||||
},
|
||||
'p1',
|
||||
);
|
||||
expect(findObject(loaded, 'i1')?.instanceEdits.b1?.overrides.grid).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('is compared by its items, so reloading does not turn every list into an override', () => {
|
||||
// The same items in a fresh array: a reference compare would call this a deviation on every load.
|
||||
expect(sameValue([1, 2, 3], [1, 2, 3])).toBe(true);
|
||||
expect(sameValue([1, 2, 3], [1, 2])).toBe(false);
|
||||
expect(sameValue([1, 2, 3], [1, 9, 3])).toBe(false);
|
||||
});
|
||||
|
||||
it('an override that only repeats the default is not one', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'row', 'list'));
|
||||
const asset = findAsset(f.project, behavior.id)!;
|
||||
asset.props.row = [1, 2];
|
||||
const object = place(f, behavior.id, { x: 0, y: 0 });
|
||||
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2]));
|
||||
const placed = f.object(object);
|
||||
expect(deviatesFromAsset(placed, asset, 'row')).toBe(false);
|
||||
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3]));
|
||||
expect(deviatesFromAsset(placed, asset, 'row')).toBe(true);
|
||||
});
|
||||
|
||||
it('reads and writes as one comma-separated line, item kind by item kind', () => {
|
||||
expect(listFromText('1, 2, 3', 'number')).toEqual([1, 2, 3]);
|
||||
expect(listFromText('', 'number')).toEqual([]);
|
||||
expect(listFromText('a, b', 'text')).toEqual(['a', 'b']);
|
||||
// Spelling has to count here: Boolean('false') would be true.
|
||||
expect(listFromText('true, false, 0', 'boolean')).toEqual([true, false, false]);
|
||||
expect(listFromText('#ff0000, #00ff00', 'color')).toEqual([0xff0000, 0x00ff00]);
|
||||
expect(listToText([0xff0000], 'color')).toBe('#ff0000');
|
||||
expect(listToText([1, 2], 'number')).toBe('1, 2');
|
||||
});
|
||||
|
||||
it('a value that is not a list falls back to the default rather than becoming one', () => {
|
||||
const def = variableDef('grid', 'list', [7], 'number');
|
||||
expect(coerceValue(def, 'nonsense')).toEqual([7]);
|
||||
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -289,6 +289,62 @@ const CASES: Record<string, Case[]> = {
|
||||
outputs: { text: 'b' },
|
||||
},
|
||||
],
|
||||
// --- lists -----------------------------------------------------------------------------------
|
||||
|
||||
List: [
|
||||
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
|
||||
{ name: 'an unset list is empty, not missing', outputs: { value: [] } },
|
||||
],
|
||||
Fill: [
|
||||
{ name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } },
|
||||
{ name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } },
|
||||
],
|
||||
Count: [
|
||||
{ name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } },
|
||||
{ name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } },
|
||||
],
|
||||
ItemAt: [
|
||||
{ name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } },
|
||||
{ name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } },
|
||||
],
|
||||
SetItem: [
|
||||
{
|
||||
name: 'replaces one item',
|
||||
inputs: { list: [1, 2, 3], index: 1, value: 9 },
|
||||
outputs: { result: [1, 9, 3] },
|
||||
},
|
||||
{
|
||||
name: 'leaves the list it was given untouched – a graph may hold it elsewhere',
|
||||
inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 },
|
||||
outputs: { result: [9, 2, 3] },
|
||||
},
|
||||
{
|
||||
name: 'an index outside the list changes nothing',
|
||||
inputs: { list: [1, 2], index: 5, value: 9 },
|
||||
outputs: { result: [1, 2] },
|
||||
},
|
||||
],
|
||||
SubList: [
|
||||
{
|
||||
name: 'takes count items from start',
|
||||
inputs: { list: [1, 2, 3, 4], start: 1, count: 2 },
|
||||
outputs: { result: [2, 3] },
|
||||
},
|
||||
{
|
||||
name: 'asking past the end gives what is there',
|
||||
inputs: { list: [1, 2], start: 1, count: 9 },
|
||||
outputs: { result: [2] },
|
||||
},
|
||||
],
|
||||
Concat: [
|
||||
{ name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } },
|
||||
{ name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } },
|
||||
],
|
||||
IndexOf: [
|
||||
{ name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } },
|
||||
{ name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } },
|
||||
],
|
||||
|
||||
FindText: [
|
||||
{
|
||||
name: 'finds where the search first appears',
|
||||
|
||||
Reference in new issue
Block a user