Keep items a running game puts into a typed list
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m48s

A typed global list lost the items the game had added to it. The item
showed up in the inspector for a moment and then was gone again.

- Since #254 the project hands out a typed list as a fresh copy each
  time, with the `$struct` marks stripped.
- While a game runs, every change to the project runs
  `adoptDeclarations`. It took a declared value over when it differed
  from the last one it had seen, compared with `!==`.
- The fresh copy never equals the last one by identity. So the empty
  list from the project overwrote the live list on every project
  change, for example when the game writes something back to the scene.
- The comparison now uses `sameValue`. A real edit to the variable
  during a run still reaches the game.

Tests:
- ./check.sh: CHECK EXIT 0
- New unit test in scope.test.ts, red without the fix.
- e2e 55i: a graph appends to a typed global list, then another asset
  changes during the run. With the fix {"filled":2,"afterEdit":2},
  without it afterEdit is 1 and the check fails.
- GM_STEPS=1,55: ALL PASSED, 26 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 10:00:22 +02:00
1 parent b0d0868f55
commit e1d3c0cadb
4 files changed
+85 -5

No files matched your search

+1 -1
View File
@@ -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
+3 -2
View File
@@ -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];
+17
View File
@@ -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));
+64 -2
View File
@@ -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),
);
`);
}