diff --git a/README.md b/README.md index 6ea9ef82..0e1e57f9 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, 1513 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1514 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/sharedState.ts b/src/core/graph/sharedState.ts index 60d284d0..742eef15 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -1,4 +1,4 @@ -import { type PropertyBag, withoutItemTypes } from '../model/Properties'; +import { type PropertyBag, sameValue, withoutItemTypes } from '../model/Properties'; import type { Project } from '../model/Project'; import { isPerScene, isShared, sharedScopeOf } from '../model/Scope'; import type { NodeContext, SharedState } from './NodeContext'; @@ -92,8 +92,9 @@ export function adoptDeclarations( continue; } const before = seen[assetId] ?? {}; + // By value, not identity: a list of typed items is declared as a fresh copy every time (#254). for (const [key, value] of Object.entries(declared)) - if (!(key in bag) || before[key] !== value) bag[key] = value; + if (!(key in bag) || !sameValue(before[key], value)) bag[key] = value; for (const key of Object.keys(bag)) if (!(key in declared)) delete bag[key]; } for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId]; diff --git a/tests/core/scope.test.ts b/tests/core/scope.test.ts index 5e3df3fc..ce530583 100644 --- a/tests/core/scope.test.ts +++ b/tests/core/scope.test.ts @@ -180,6 +180,23 @@ describe('a declaration edited during the run', () => { expect(bag().score).toBe(1); }); + /** + * A list of typed items is declared as a fresh copy each time, the `$struct` marks taken off + * (#254). Equal but not the same object must still count as unchanged. + */ + it('leaves a list the run has filled when the declaration only reads the same again', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new AddVariableCommand(shared.id, 'bags', 'list')); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'bags', [{ Owner: 'Player', $struct: 'inv' }])); + started.behaviors!.syncDeclarations(); + expect(bag().bags).toEqual([{ Owner: 'Player' }]); + + bag().bags = [{ Owner: 'Player' }, { Owner: 'Test' }]; + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); + started.behaviors!.syncDeclarations(); + expect(bag().bags).toEqual([{ Owner: 'Player' }, { Owner: 'Test' }]); + }); + it('writes nothing back into the document', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); diff --git a/tools/e2e/55-typed-lists.mjs b/tools/e2e/55-typed-lists.mjs index 25b7d50a..5fac0927 100644 --- a/tools/e2e/55-typed-lists.mjs +++ b/tools/e2e/55-typed-lists.mjs @@ -5,6 +5,7 @@ * rename on the asset reaches both; "Make struct type" turns a list's items into an asset. In a run * `Add item` with a type takes an item of that list and a Pack of it, and turns a number away. A * typed item's asset field is drawn as the asset picker its type declares, not as the text it holds. + * A shared typed list keeps what a run added to it when the document changes during the run. * * Everything it adds carries a `tl55` id or is an asset it made, and goes again at the end. */ @@ -305,15 +306,76 @@ export async function typedLists(t) { JSON.stringify(logged), ); + // 55i. A shared typed list the run has added to keeps the item when the document changes while + // the run is on: its declaration is a fresh copy each time, which is no edit of it. + const fillGraph = { + nodes: [ + node('tl55-f-start', 'OnStart', -400, 0), + node('tl55-f-get', 'Variables', -400, 160, { variablesAsset: 'tl55-save' }, [ + { key: 'inv', kind: 'list' }, + ]), + node('tl55-f-pack', 'PackStruct', -400, 320, { structType: LOOT, Name: 'Bow', Amount: 2 }, fields), + node('tl55-f-add', 'AddItem', -200, 160, { structType: LOOT }), + node('tl55-f-set', 'Variables', 0, 0, { variablesAsset: 'tl55-save' }, [ + { key: 'inv', kind: 'list', write: true }, + ]), + ], + edges: [ + edge('tl55-f-e1', 'tl55-f-start', 'exec', 'tl55-f-set', 'exec'), + edge('tl55-f-e2', 'tl55-f-get', 'inv', 'tl55-f-add', 'list'), + edge('tl55-f-e3', 'tl55-f-pack', 'value', 'tl55-f-add', 'value'), + edge('tl55-f-e4', 'tl55-f-add', 'result', 'tl55-f-set', 'inv'), + ], + }; + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const inv = [{ Name: 'Gem', Amount: 4, [${JSON.stringify(MARK)}]: '${LOOT}' }]; + p.assets.push({ + id: 'tl55-save', + type: 'variables', + name: 'Save 55', + props: { inv }, + variables: [{ key: 'inv', label: 'inv', kind: 'list', itemType: '${LOOT}', default: [] }], + }); + p.assets.push({ + id: 'tl55-filler', + type: 'behavior', + name: 'Filler 55', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(fillGraph)}, + }); + ${MAKE_OBJECT} + scene.objects.push(make('tl55-fill-obj', 'tl55-filler', '', 440, 'Filler 55')); + `); + const held = async () => ((await json('__gm.world()'))?.shared?.variables?.['tl55-save']?.inv ?? []).length; + await t.play(); + await sleep(400); + await t.clickToolbar('pause'); + await sleep(150); + const filled = await held(); + // Any edit while the run is on – here one on another asset – brings the declarations in again. + await mutate(`__gm.state.project.assets.find((a) => a.id === '${LOOT}').props.Amount = 7`); + await sleep(150); + const afterEdit = await held(); + await stop(); + check( + 'a shared typed list keeps the item the run added when the document changes during the run', + filled === 2 && afterEdit === 2, + JSON.stringify({ filled, afterEdit }), + ); + // Clean up. await evaluate('__gm.state.select(null)'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; - scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj'); + scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj' && o.id !== 'tl55-fill-obj'); const made = ${JSON.stringify(made?.id ?? '')}; p.assets = p.assets.filter( - (a) => a.id !== 'tl55-chest' && a.id !== '${LOOT}' && a.id !== '${DROP}' && a.id !== made, + (a) => + !['tl55-chest', 'tl55-save', 'tl55-filler', '${LOOT}', '${DROP}', made].includes(a.id), ); `); }