Keep items a running game puts into a typed list
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:
1 parent
b0d0868f55
commit
e1d3c0cadb
4 files changed
+85
-5
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -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 type { Project } from '../model/Project';
|
||||||
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
|
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
|
||||||
import type { NodeContext, SharedState } from './NodeContext';
|
import type { NodeContext, SharedState } from './NodeContext';
|
||||||
@@ -92,8 +92,9 @@ export function adoptDeclarations(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const before = seen[assetId] ?? {};
|
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))
|
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 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];
|
for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId];
|
||||||
|
|||||||
@@ -180,6 +180,23 @@ describe('a declaration edited during the run', () => {
|
|||||||
expect(bag().score).toBe(1);
|
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', () => {
|
it('writes nothing back into the document', () => {
|
||||||
const { f, shared, started, bag } = counting();
|
const { f, shared, started, bag } = counting();
|
||||||
f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3));
|
f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* rename on the asset reaches both; "Make struct type" turns a list's items into an asset. In a run
|
* 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
|
* `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.
|
* 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.
|
* 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),
|
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.
|
// Clean up.
|
||||||
await evaluate('__gm.state.select(null)');
|
await evaluate('__gm.state.select(null)');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
const scene = __gm.state.activeScene;
|
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 ?? '')};
|
const made = ${JSON.stringify(made?.id ?? '')};
|
||||||
p.assets = p.assets.filter(
|
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),
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user