diff --git a/README.md b/README.md index 896c975d..0be7cbba 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1403 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1406 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 63472004..e4fae962 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -12,6 +12,7 @@ import { type PortDef, type RepeatResult, } from './NodeRegistry'; +import { carryObjectSlots } from './objectSlots'; import { machineIdOf } from './stateMachine'; import type { Value } from './values'; @@ -58,6 +59,7 @@ export function resumedVariables( const was = kept.includes(key) ? left?.[key] : undefined; variables[key] = was !== undefined && typeof was === typeof value ? was : value; } + if (left) carryObjectSlots(left, variables, kept); return variables; } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 3c25b914..ad085770 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -142,6 +142,8 @@ export interface NodeContext { time: number; dt: number; findByName(name: string): EntityHandle | null; + /** Whether the object is still in the game – false once destroyed, or once its world has ended. */ + isAlive(target: EntityHandle): boolean; /** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */ childByName(parent: EntityHandle, name: string): EntityHandle | null; isKeyDown(key: string): boolean; diff --git a/src/core/graph/nodes/data/Properties.ts b/src/core/graph/nodes/data/Properties.ts index 362c6853..2d78edca 100644 --- a/src/core/graph/nodes/data/Properties.ts +++ b/src/core/graph/nodes/data/Properties.ts @@ -6,7 +6,8 @@ import { propertyPort, propertyTargetOf, } from '../../propertyPorts'; -import { asEntity, writeTyped } from '../../values'; +import { readWithObjects, writeWithObjects } from '../../objectSlots'; +import { asEntity } from '../../values'; import { exec, obj } from '../shared'; export const Properties: NodeDef = { @@ -27,12 +28,16 @@ export const Properties: NodeDef = { const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape; }, - read: (_ctx, _params, input) => asEntity(input('object'))?.props ?? {}, + // A behavior's variables are its props, so an object variable is read from its slot there too. + read: (ctx, _params, input, def) => { + const target = asEntity(input('object')); + return target ? readWithObjects(target.props, def.outputs, ctx.isAlive) : {}; + }, run: (_ctx, inputs, _params, def) => { const target = asEntity(inputs.object); if (target) for (const port of def.inputs) - if (port.type !== 'exec' && port.key !== 'object') writeTyped(target.props, port.key, inputs[port.key]); + if (port.type !== 'exec' && port.key !== 'object') writeWithObjects(target.props, port, inputs[port.key]); return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index aa7bddbe..5c4dca4a 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -3,7 +3,7 @@ import { coerceValue, flatDef, type PropertyDef } from '../../../properties/Prop import type { NodeDef } from '../../NodeRegistry'; /** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */ -function literal(type: string, kind: Exclude, description: string): NodeDef { +function literal(type: string, kind: Exclude, description: string): NodeDef { const value: PropertyDef = kind === 'list' ? { key: 'value', label: 'Value', kind, default: [] } diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index 397b490f..b076af75 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -1,7 +1,7 @@ import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; +import { readWithObjects, writeWithObjects } from '../../objectSlots'; import { variableBag } from '../../sharedState'; -import { writeTyped } from '../../values'; import { exec } from '../shared'; /** @@ -25,10 +25,11 @@ export const Variables: NodeDef = { const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; }, - read: (ctx, params) => variableBag(ctx, variablesSourceOf(params)), + read: (ctx, params, _input, def) => + readWithObjects(variableBag(ctx, variablesSourceOf(params)), def.outputs, ctx.isAlive), run: (ctx, inputs, params, def) => { const bag = variableBag(ctx, variablesSourceOf(params)); - for (const port of def.inputs) if (port.type !== 'exec') writeTyped(bag, port.key, inputs[port.key]); + for (const port of def.inputs) if (port.type !== 'exec') writeWithObjects(bag, port, inputs[port.key]); return { next: 'exec' }; }, }; diff --git a/src/core/graph/objectSlots.ts b/src/core/graph/objectSlots.ts new file mode 100644 index 00000000..bb291592 --- /dev/null +++ b/src/core/graph/objectSlots.ts @@ -0,0 +1,67 @@ +import type { PropertyBag } from '../model/Properties'; +import type { EntityHandle } from './NodeContext'; +import type { PortDef } from './NodeRegistry'; +import { asEntity, type Value, writeTyped } from './values'; + +/** + * The objects the object variables of a bag hold, beside the bag rather than in it: what sits in a + * bag reaches the scene (`World.syncToModel` writes a behavior's changed values back as overrides), + * the saves and the inspector, and none of them can keep a live handle. Keyed weakly by the bag, so + * a run that ends takes its objects along. + */ +const slots = new WeakMap>(); + +/** + * The object the variable holds, null for none – or for one destroyed since it was written, so a + * graph never goes on acting on an object that has left the game. + */ +export function readObjectSlot( + bag: PropertyBag, + key: string, + isAlive: (target: EntityHandle) => boolean, +): EntityHandle | null { + const held = slots.get(bag)?.get(key); + if (!held) return null; + if (isAlive(held)) return held; + slots.get(bag)!.delete(key); + return null; +} + +export function writeObjectSlot(bag: PropertyBag, key: string, target: EntityHandle | null): void { + let held = slots.get(bag); + if (!target) { + held?.delete(key); + return; + } + if (!held) slots.set(bag, (held = new Map())); + held.set(key, target); +} + +/** Hands the objects `keys` hold in `from` on to `to` – a kept variable of the next call (#229). */ +export function carryObjectSlots(from: PropertyBag, to: PropertyBag, keys: readonly string[]): void { + const held = slots.get(from); + if (!held) return; + for (const key of keys) { + const target = held.get(key); + if (target && key in to) writeObjectSlot(to, key, target); + } +} + +/** The bag as a graph reads it through `outputs`: an object port answers with the object held now. */ +export function readWithObjects( + bag: PropertyBag, + outputs: readonly PortDef[], + isAlive: (target: EntityHandle) => boolean, +): Record { + const objects = outputs.filter((port) => port.type === 'object'); + if (objects.length === 0) return bag; + const values: Record = { ...bag }; + for (const port of objects) values[port.key] = readObjectSlot(bag, port.key, isAlive); + return values; +} + +/** Writes one value arriving on `port`: an object into its slot, anything else typed into the bag. */ +export function writeWithObjects(bag: PropertyBag, port: PortDef, value: Value | undefined): void { + if (port.type === 'object') writeObjectSlot(bag, port.key, asEntity(value)); + else writeTyped(bag, port.key, value); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 92f3d9b1..bf3bd7b2 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -176,7 +176,22 @@ export type PropertyDef = kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[]; - }; + } + /** + * An object variable holds a live handle, which no file and no save can keep: the bag carries only + * this empty placeholder, and the handle sits beside it for the run (see `graph/objectSlots.ts`). + */ + | ObjectDef; + +export interface ObjectDef { + key: string; + label: string; + kind: 'object'; + default: typeof NO_OBJECT; +} + +/** What an object variable stores – always nothing, since an object exists only while the game runs. */ +export const NO_OBJECT = ''; export interface PrimitiveFieldDef { key: string; @@ -745,6 +760,8 @@ export function variableDef( const declared = fields ?? []; return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) }; } + case 'object': + return { key, label: key, kind, default: NO_OBJECT }; default: return flatDef(key, kind, { value: defaultValue }); } @@ -785,6 +802,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal return isList(value) ? value.map(listItemOf) : def.default; case 'struct': return structOf(def.fields, value); + case 'object': + return NO_OBJECT; default: return coerceAs(value, def.kind, def.default); } diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 6aaaaa8b..337f67d5 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -158,7 +158,8 @@ export const KINDS = [ field: true, port: true, }, - { kind: 'object', label: 'Object', travels: 'object', port: true }, + /** A variable of it holds a live object for the run only; nothing of it is stored (`graph/objectSlots.ts`). */ + { kind: 'object', label: 'Object', travels: 'object', variable: true, port: true }, { kind: 'list', label: 'List', travels: 'list', variable: true, port: true }, { kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true }, // Schema-only kinds: a setting has them, nothing a user declares does. diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index f0e353fb..53a6b65a 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -254,6 +254,11 @@ function buildPersistence(host: InspectorHost, asset: VariablesAsset): void { } addNote(host, 'Kept between runs, in this browser.', 'small'); addNote(host, 'The next run starts where one ended.', 'small'); + // An object is an instance of this run only: the next run has new ones, so there is nothing to keep. + if (asset.variables.some((def) => def.kind === 'object')) { + addNote(host, 'Object variables are not kept,', 'warning'); + addNote(host, 'every run starts them at (null).', 'warning'); + } host.body.add( new Button('Clear saved values', { variant: 'ghost', diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index f5463057..def27d6e 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -453,6 +453,10 @@ export function createField( parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])), }; } + case 'object': { + // Nothing to pick: the object is written by a graph while the game runs, and never stored. + return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} }; + } case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 174520b3..960dd7db 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -662,6 +662,8 @@ export class BehaviorSystem implements System { time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, + // By identity, not by id: a restarted scene builds new entities under the same ids. + isAlive: (target) => world.entities.some((e) => e === target), childByName: (parent, name) => { const found = resolve(parent); return (found && world.childByName(found, name)) ?? null; diff --git a/tests/core/object-variable.test.ts b/tests/core/object-variable.test.ts new file mode 100644 index 00000000..38a25dd0 --- /dev/null +++ b/tests/core/object-variable.test.ts @@ -0,0 +1,217 @@ +/** + * An object as a variable: a spawned object written into one is there on the next frame, a + * destroyed one reads as nothing, and no handle ever reaches the scene, the file or a save. + */ +import { describe, expect, it } from 'vitest'; +import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { resumedVariables, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph'; +import type { EntityHandle } from '../../src/core/graph/NodeContext'; +import { readObjectSlot, writeObjectSlot } from '../../src/core/graph/objectSlots'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { findScene } from '../../src/core/model/Project'; +import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds'; +import { serializeProject } from '../../src/core/store/serialize'; +import { + 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; +} + +const crates = (f: SceneFixture, run: ReturnType) => + run.world.entities.filter((e) => e.assetId === f.spriteId); + +describe('the object kind as a variable', () => { + it('can be declared, but has no field and no list item', () => { + expect(VARIABLE_KINDS).toContain('object'); + expect(isKindWith('field', 'object')).toBe(false); + }); + + it('is stored as its declaration only, with nothing as its value', () => { + const f = makeSpriteScene('Crate'); + const behavior = makeBehavior(f, 'Holder'); + f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object')); + 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: '' }); + }); +}); + +describe('an object variable in a running game', () => { + it('still holds the spawned object 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('reads as nothing once the object is destroyed', () => { + const f = makeSpriteScene('Crate'); + spawner(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('never reaches the behavior’s bag, the scene or the file', () => { + const f = makeSpriteScene('Crate'); + const behavior = spawner(f); + const run = startWorld(f, { behaviors: true }); + run.frame(); + run.frame(); + run.world.syncToModel(); + const instance = run.world.entities.find((e) => e.assetId === behavior.id)!; + expect(instance.props.spawned).toBe(''); + const placed = findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!; + expect('spawned' in placed.overrides).toBe(false); + expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow(); + }); + + 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('an object variable in a persistent Variables asset', () => { + /** On start: log what `target` holds, then spawn a Crate into it 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 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 write = edit.variables(['score', 'target'], { 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'], + [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 held = readObjectSlot(first.behaviors!.shared.variables[shared.id]!, 'target', () => true); + expect(held?.name).toBe('Crate'); + expect(saved).toEqual({ [shared.id]: { score: 1, target: '' } }); + 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('object slots', () => { + const handle = (name: string) => ({ name }) as unknown as EntityHandle; + + it('hand a kept object on to the next call, and only a kept one', () => { + const left = { target: '', other: '' }; + const crate = handle('Crate'); + writeObjectSlot(left, 'target', crate); + writeObjectSlot(left, 'other', crate); + const next = resumedVariables({ target: '', other: '' }, left, ['target']); + expect(readObjectSlot(next, 'target', () => true)).toBe(crate); + expect(readObjectSlot(next, 'other', () => true)).toBeNull(); + }); + + it('forget an object that is gone, even when it would come back under the same id', () => { + const bag = { target: '' }; + writeObjectSlot(bag, 'target', handle('Crate')); + expect(readObjectSlot(bag, 'target', () => false)).toBeNull(); + expect(readObjectSlot(bag, 'target', () => true)).toBeNull(); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index d5a83162..176e51cd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -20,6 +20,7 @@ import { portKindOf, PROPERTIES, VARIABLES, + VARIABLES_PARAM, } from '../src/core/graph'; import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset'; import { findObject } from '../src/core/model/hierarchy'; @@ -154,7 +155,10 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G stack.execute(command); const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key)); const ports = keys.map((key): DeclaredPort => { - const declared = behavior.variables.find((v) => v.key === key); + // A node pointed at a Variables asset declares that asset's variables, not the behavior's. + const source = fixture.project.assets.find((a) => a.id === params[VARIABLES_PARAM]); + const declarations = source?.type === 'variables' ? source.variables : behavior.variables; + const declared = declarations.find((v) => v.key === key); const kind = declared ? portKindOf(declared) : 'number'; return writes(key) ? { key, kind, write: true } : { key, kind }; }); @@ -224,6 +228,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { logs, stopped: false, findByName: () => null, + isAlive: () => true, childByName: () => null, isKeyDown: () => false, pointer: { ...NO_POINTER }, diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index 6ab89726..6982843b 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -318,6 +318,24 @@ export async function variableNodes(t) { `vz = ${counted} after ~1 s`, ); + // 23k. An object variable: declared from the menu like any other, but with nothing to type – it is + // set by a graph while the game runs, so the row says (null) and the file keeps no value. + await t.select({ kind: 'asset', assetId: 'vp-logic' }); + await clickLast('+ Variable'); + await clickLast('Object'); + const declared = (await asset('vp-logic')).variables.at(-1); + check( + 'an Object variable is declared from the menu and stores nothing as its value', + declared?.kind === 'object' && declared.default === '' && (await has('(null)')), + JSON.stringify(declared), + ); + await shot('06-object-variable'); + await undo(); + check( + 'undo takes it back', + (await asset('vp-logic')).variables.every((v) => v.kind !== 'object'), + ); + await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'vp-logic'); diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index 60aea761..0ef795dd 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -176,6 +176,22 @@ export async function scope(t) { ); await shot('04-persistent'); + // 32f-ii. An object variable in a persistent asset is allowed, but is never saved: the inspector + // says so once there is one, and the note goes again with it. + const objectNote = 'Object variables are not kept,'; + const noteBefore = await has(objectNote); + await clickLast('+ Variable'); + await clickLast('Object'); + const noteWith = await has(objectNote); + await shot('04b-persistent-object'); + await t.undo(); + const noteAfter = await has(objectNote); + check( + 'a persistent asset with an Object variable notes that the object is not kept', + !noteBefore && noteWith && !noteAfter && (await asset('sc-vars')).variables.length === 1, + JSON.stringify({ noteBefore, noteWith, noteAfter }), + ); + const scoreOfRun = async () => { await t.play(); await sleep(600);