Show the list a running game fills in the list tab (#251)
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Successful in 10m16s

While playing, the inspector said "2 items" for a list a graph had
filled, but clicking it opened an empty list tab. The inspector reads a
Variables asset from the running game, the list tab only read the list
stored in the project. It did not follow the run's ticks either.

- resolveList takes the live values. For a Variables asset it uses the
  running game's value while one runs, the project's otherwise. This is
  the inspector's rule.
- ListEditor refreshes on runtime ticks about 10 times a second, and
  not while a field in the tab is being typed in, like the inspector.
- Editor hands the list editor the inspector's liveVariables.
- __gm.list.info() also returns rows, the rows the last build drew.
- Editing in the tab while playing goes into the run's copy of the
  project, as in the inspector. A very long, growing list rebuilds all
  rows up to 10 times a second.

Tests:
- ./check.sh: 180 files, 2634 tests, CHECK EXIT 0
- e2e GM_STEPS=1,32: ALL PASSED, 30 checks
- e2e 32b-ii, 2 checks: a graph adds an item every frame, the test
  pauses and clicks "N items" with a real mouse. The tab draws 26 rows
  for 26 items, grows to 50 after resuming and is empty after Stop.
- Counter-checks: without live values the tab draws 0 rows for 23
  items, and without the refresh it stays at 23
- README: 1481 editor checks, counted by hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 21:07:14 +02:00
1 parent b90b62886b
commit 92fad6b9e3
5 files changed
+126 -9

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: 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, 1479 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1481 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
View File
@@ -320,6 +320,7 @@ export class Editor implements PlayHost {
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`). // Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) => this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.listEditor.liveVariables = this.inspector.liveVariables;
this.usersPane = this.usersPane =
account.role === 'admin' account.role === 'admin'
+21 -4
View File
@@ -15,6 +15,7 @@ import {
Spacer, Spacer,
TextField, TextField,
Theme, Theme,
ui,
VStack, VStack,
type Widget, type Widget,
} from '../../ui'; } from '../../ui';
@@ -50,7 +51,7 @@ import {
} from '../inspector/fieldFactory'; } from '../inspector/fieldFactory';
import { kindMenu, markPicker } from '../inspector/variableRows'; import { kindMenu, markPicker } from '../inspector/variableRows';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot'; import { type ListAccess, type LiveVariables, resolveList } from './slot';
const INDEX_WIDTH = 40; const INDEX_WIDTH = 40;
@@ -89,6 +90,10 @@ export class ListEditor extends VStack {
private signature = ''; private signature = '';
private setters: ((item: PropertyValue) => void)[] = []; private setters: ((item: PropertyValue) => void)[] = [];
private rowWidgets: RowWidgets[] = []; private rowWidgets: RowWidgets[] = [];
/** Last live refresh (play mode), in ms – the same pace as the inspector's. */
private lastLiveRefresh = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: LiveVariables = () => null;
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super({ gap: 0 }); super({ gap: 0 });
@@ -117,9 +122,19 @@ export class ListEditor extends VStack {
state.on('list', () => this.rebuild()); state.on('list', () => this.rebuild());
state.on('load', () => this.rebuild()); state.on('load', () => this.rebuild());
state.on('project', () => this.refresh()); state.on('project', () => this.refresh());
state.on('runtime', () => this.refreshLive());
this.rebuild(); this.rebuild();
} }
/** Play mode: follow a list the behaviors fill, ~10× a second, but never under a field being typed in. */
private refreshLive(): void {
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refresh();
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */ /** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number, field = ''): { x: number; y: number } | null { fieldAt(index: number, field = ''): { x: number; y: number } | null {
const row = this.rowWidgets[index]; const row = this.rowWidgets[index];
@@ -143,10 +158,12 @@ export class ListEditor extends VStack {
} }
/** For the check scripts: what the tab shows and how its rows read right now. */ /** For the check scripts: what the tab shows and how its rows read right now. */
debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[] } { debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[]; rows: number } {
const access = this.access(); const access = this.access();
if (!access) return { title: '', items: [], kinds: [], offers: [] }; if (!access) return { title: '', items: [], kinds: [], offers: [], rows: this.rowWidgets.length };
return { return {
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
rows: this.rowWidgets.length,
title: access.title, title: access.title,
items: access.value.map((item) => textOf(item)), items: access.value.map((item) => textOf(item)),
kinds: access.value.map(itemKindOf), kinds: access.value.map(itemKindOf),
@@ -156,7 +173,7 @@ export class ListEditor extends VStack {
private access(): ListAccess | null { private access(): ListAccess | null {
const slot = this.state.listSlot; const slot = this.state.listSlot;
return slot ? resolveList(this.state, slot) : null; return slot ? resolveList(this.state, slot, this.liveVariables) : null;
} }
/** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */ /** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */
+22 -4
View File
@@ -3,12 +3,13 @@
* edit gets the same command, coercion and undo step. A slot with a `path` still writes the whole value. * edit gets the same command, coercion and undo step. A slot with a `path` still writes the whole value.
*/ */
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph'; import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { withoutObjects } from '../../core/graph/liveObjects';
import { resolveDef } from '../../core/graph/nested'; import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry'; import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset'; import type { Asset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import type { ObjectRef } from '../../core/model/instance'; import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties'; import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
import { itemLike } from '../../core/properties/listItems'; import { itemLike } from '../../core/properties/listItems';
@@ -59,9 +60,16 @@ interface Owner<Target> {
spec: Omit<PreviewedValue<Target>, 'toModel'>; spec: Omit<PreviewedValue<Target>, 'toModel'>;
} }
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null { /** A Variables asset's values in the running game, or null while none runs it. */
export type LiveVariables = (assetId: string) => PropertyBag | null;
export function resolveList(
state: EditorState,
slot: ListSlot,
live: LiveVariables = () => null,
): ListAccess | null {
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot); const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot);
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot)); if (slot.kind === 'asset') return inSlot(assetOwner(state, slot, live));
if (slot.kind === 'object') return inSlot(objectOwner(state, slot)); if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
return inSlot(nodeOwner(state, slot)); return inSlot(nodeOwner(state, slot));
} }
@@ -149,7 +157,15 @@ function access<Target>(
}; };
} }
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null { /**
* While a run is on, a Variables asset shows what the run holds – the same rule as the inspector's
* `shownValues`, so a list a graph fills is not "2 items" in the row and empty in the tab.
*/
function assetOwner(
state: EditorState,
slot: Extract<ListSlot, { kind: 'asset' }>,
live: LiveVariables,
): Owner<Asset> | null {
const asset = findAsset(state.project, slot.assetId); const asset = findAsset(state.project, slot.assetId);
const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key); const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key);
if (!asset || !def) return null; if (!asset || !def) return null;
@@ -158,6 +174,8 @@ function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset'
title: `${asset.name} · ${def.label}`, title: `${asset.name} · ${def.label}`,
current: () => { current: () => {
const now = findAsset(state.project, slot.assetId); const now = findAsset(state.project, slot.assetId);
const bag = now?.type === 'variables' ? live(now.id) : null;
if (bag && slot.key in bag) return withoutObjects(bag[slot.key]);
return now && resolveAssetProps(state.project, now)[slot.key]; return now && resolveAssetProps(state.project, now)[slot.key];
}, },
spec: assetValue(state, slot.assetId, slot.key), spec: assetValue(state, slot.assetId, slot.key),
+81
View File
@@ -27,6 +27,21 @@ const counterGraph = {
], ],
}; };
/** `On update → Variables{set bag} ← Join lists(bag, 7)`: a shared list that grows every frame. */
const fillerGraph = {
nodes: [
node('sc-f-update', 'OnUpdate', 0, 0),
node('sc-f-read', 'Variables', 0, 200, { variablesAsset: 'sc-bag' }, [port('bag', 'list')]),
node('sc-f-join', 'Concat', 260, 200, { b: [7] }),
node('sc-f-write', 'Variables', 560, 0, { variablesAsset: 'sc-bag' }, [written('bag', 'list')]),
],
edges: [
edge('sc-f-e1', 'sc-f-update', 'exec', 'sc-f-write', 'exec'),
edge('sc-f-e2', 'sc-f-read', 'bag', 'sc-f-join', 'a'),
edge('sc-f-e3', 'sc-f-join', 'result', 'sc-f-write', 'bag'),
],
};
export async function scope(t) { export async function scope(t) {
const { check, sleep, json, shot, click, locate, asset, mutate, stop, has } = t; const { check, sleep, json, shot, click, locate, asset, mutate, stop, has } = t;
@@ -89,6 +104,72 @@ export async function scope(t) {
); );
await stop(); await stop();
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the List tab it
// opens has to show those too and follow them – not the empty list the document declares.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const mover = p.assets.find((a) => a.id === 'mover');
const f = structuredClone(mover);
Object.assign(f, {
id: 'sc-filler',
name: 'Filler',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(fillerGraph)},
});
p.assets.unshift(f, {
id: 'sc-bag',
type: 'variables',
name: 'Bag',
props: { bag: [] },
variables: ${JSON.stringify([variable('bag', 'list', [])])},
});
${MAKE_OBJECT}
scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler'));
`);
// Held, so the count the row shows stands still long enough to be found and clicked.
await t.play();
await sleep(300);
await t.clickToolbar('pause');
await sleep(150);
const held = (await json('__gm.world()')).shared.variables['sc-bag']?.bag ?? [];
await selectAsset('sc-bag');
await sleep(200);
const countRow = await t.find(`${held.length} items`);
if (countRow) await click(countRow.x, countRow.y);
await sleep(150);
const first = await json('__gm.list.info()');
await shot('01b-live-list');
await t.clickToolbar('pause');
await sleep(500);
const later = await json('__gm.list.info()');
await stop();
await sleep(200);
const stopped = await json('__gm.list.info()');
check(
'while playing, the List tab opened from a shared list shows the items the run put in',
held.length > 0 &&
Boolean(countRow) &&
(await json('__gm.tab()')) === 'list' &&
first.title === 'Bag · bag' &&
first.rows === held.length &&
first.items.every((item) => item === '7'),
JSON.stringify({ run: held.length, row: countRow, title: first.title, rows: first.rows }),
);
check(
'and follows the list as it grows, then shows the declared list again once the run stops',
later.rows > first.rows && stopped.rows === 0 && stopped.items.length === 0,
JSON.stringify({ first: first.rows, later: later.rows, stopped: stopped.rows }),
);
await mutate(`
const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f');
p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag');
__gm.state.openList(null);
__gm.state.select(null);
`);
// 32c. The asset is where the declaration lives, and it says what it is for. // 32c. The asset is where the declaration lives, and it says what it is for.
await selectAsset('sc-vars'); await selectAsset('sc-vars');
const declared = await has('score'); const declared = await has('score');