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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b90b62886b
commit
92fad6b9e3
5 files changed
+126
-9
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:
|
||||
|
||||
```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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+22
-4
@@ -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<Target> {
|
||||
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);
|
||||
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<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 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<ListSlot, { kind: 'asset'
|
||||
title: `${asset.name} · ${def.label}`,
|
||||
current: () => {
|
||||
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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user