From 92fad6b9e3497f4ac47342c9e05a8531f7acc37d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 27 Sep 2026 21:07:14 +0200 Subject: [PATCH] Show the list a running game fills in the list tab (#251) 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 --- README.md | 2 +- src/editor/Editor.ts | 1 + src/editor/list/ListEditor.ts | 25 +++++++++-- src/editor/list/slot.ts | 26 +++++++++-- tools/e2e/32-scope.mjs | 81 +++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 95967475..8b94b580 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, 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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 16b15ab2..4d378f7c 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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`). this.inspector.liveVariables = (assetId) => this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); + this.listEditor.liveVariables = this.inspector.liveVariables; this.usersPane = account.role === 'admin' diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 2407e836..050f5460 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -15,6 +15,7 @@ import { Spacer, TextField, Theme, + ui, VStack, type Widget, } from '../../ui'; @@ -50,7 +51,7 @@ import { } from '../inspector/fieldFactory'; import { kindMenu, markPicker } from '../inspector/variableRows'; import type { EditorState } from '../state'; -import { type ListAccess, resolveList } from './slot'; +import { type ListAccess, type LiveVariables, resolveList } from './slot'; const INDEX_WIDTH = 40; @@ -89,6 +90,10 @@ export class ListEditor extends VStack { private signature = ''; private setters: ((item: PropertyValue) => void)[] = []; 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) { super({ gap: 0 }); @@ -117,9 +122,19 @@ export class ListEditor extends VStack { state.on('list', () => this.rebuild()); state.on('load', () => this.rebuild()); state.on('project', () => this.refresh()); + state.on('runtime', () => this.refreshLive()); 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. */ fieldAt(index: number, field = ''): { x: number; y: number } | null { 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. */ - debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[] } { + debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[]; rows: number } { const access = this.access(); - if (!access) return { title: '', items: [], kinds: [], offers: [] }; + if (!access) return { title: '', items: [], kinds: [], offers: [], rows: this.rowWidgets.length }; 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, items: access.value.map((item) => textOf(item)), kinds: access.value.map(itemKindOf), @@ -156,7 +173,7 @@ export class ListEditor extends VStack { private access(): ListAccess | null { 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. */ diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index c6201ce9..234a7c5f 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -3,12 +3,13 @@ * 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 { withoutObjects } from '../../core/graph/liveObjects'; import { resolveDef } from '../../core/graph/nested'; import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry'; import type { Asset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; 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 { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; import { itemLike } from '../../core/properties/listItems'; @@ -59,9 +60,16 @@ interface Owner { spec: Omit, '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 = (owner: Owner | 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)); return inSlot(nodeOwner(state, slot)); } @@ -149,7 +157,15 @@ function access( }; } -function assetOwner(state: EditorState, slot: Extract): Owner | 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, + live: LiveVariables, +): Owner | null { const asset = findAsset(state.project, slot.assetId); const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key); if (!asset || !def) return null; @@ -158,6 +174,8 @@ function assetOwner(state: EditorState, slot: Extract { 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]; }, spec: assetValue(state, slot.assetId, slot.key), diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index 0ef795dd..74ac70bb 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -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) { const { check, sleep, json, shot, click, locate, asset, mutate, stop, has } = t; @@ -89,6 +104,72 @@ export async function scope(t) { ); 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. await selectAsset('sc-vars'); const declared = await has('score');