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:
|
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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user