Check the declared type when a graph writes a typed variable (#271)

A list variable with an item type only kept its shape in the editor.
A graph could write any list into it at runtime, and the List node had
no type at all. Now the declaration decides what a write may store.

- Writing a list into a typed list variable leaves out every item that
  does not have exactly the fields of the type. The rest is written and
  the console names each item that was left out, like Add item does.
- The same rule holds for typed list fields inside a struct variable
  and for lists inside typed items, at any depth.
- A typed struct variable refuses a struct with a field the type does
  not have, and a value that is no struct. A struct with missing fields
  still merges in as before, so a partial Pack keeps working.
- The check runs in the Variables node (own variables and Variables
  assets) and in the Properties node when it sets a behavior variable
  of another object.
- Ports learn their declaration through GraphLookup: what a variable
  declares about its type, and which behavior owns a graph. The rule
  itself lives in the new src/core/graph/typedValues.ts, fitsType moved
  there from SetItem.
- The List node gets a Type setting. With a type its items are shaped
  and marked, follow the struct asset, count when the type is deleted
  and offer Split into fields in For each. The mark never reaches the
  running game.
- A type on a List node holding something other than structs is
  refused with a console line.
- Setting the List node back to None removes the marks, unlike a list
  variable. A node list has no per-item type row, so marked items could
  never be freed there. Undo brings everything back.
- tests/core/kinds.test.ts looked for the value as the first parameter
  of a literal. Type now comes first on the List node, so the test
  finds the value by its key.

Tests:
- Gate: 198 files, 2869 tests (2856 + 13), CHECK EXIT 0
- e2e 55: 19 checks (+55j write at runtime, +2 for 55k List node)
- e2e 54: 16 checks, smokes 24 / 19 / 21
- Five mutations, each fails between 1 and 4 tests
- e2e total 1606 is computed (1603 + 3), no full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-30 22:08:22 +02:00
1 parent f2282a81fb
commit 0f6e628616
19 files changed
+682 -52

No files matched your search

+1 -1
View File
@@ -45,7 +45,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, 1603 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1606 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
+49 -6
View File
@@ -17,12 +17,15 @@ import {
type PropertyValue,
type StructValue,
tagOf,
withoutItemTypes,
} from '../model/Properties';
import type { Project } from '../model/Project';
import {
containsType,
type FieldRename,
isListNode,
isTypedNode,
LIST_VALUE,
listDeclAt,
setListItemType,
setStructType,
@@ -280,15 +283,17 @@ const isStructItem = (item: PropertyValue): boolean => isStruct(item) && tagOf(i
*/
function requireStructItems(project: Project, target: ListTarget): ListValue[] {
const lists = listsOf(project, target);
const others = lists.reduce((n, list) => n + list.filter((item) => !isStructItem(item)).length, 0);
if (others > 0) {
const name = [target.key, ...target.path].join('.');
const what = others === 1 ? '1 item that is' : `${others} items that are`;
throw new CommandError(`${name} holds ${what} no struct – only a list of structs can take a type`);
}
refuseOtherItems([target.key, ...target.path].join('.'), lists);
return lists;
}
function refuseOtherItems(name: string, lists: readonly ListValue[]): void {
const others = lists.reduce((n, list) => n + list.filter((item) => !isStructItem(item)).length, 0);
if (others === 0) return;
const what = others === 1 ? '1 item that is' : `${others} items that are`;
throw new CommandError(`${name} holds ${what} no struct – only a list of structs can take a type`);
}
/**
* Makes every item of a list one Struct asset, or with `''` a mixed list again. Items keep their
* values where the type has the field; a mixed list whose items keep their marks loses nothing.
@@ -318,6 +323,44 @@ export class SetListItemTypeCommand implements Command {
}
}
/**
* The item type of a `List` node (#271), or with `''` a mixed list again. Its items take on the type
* as a typed variable's do; back to mixed they lose the marks, since a node's list offers no type
* per item.
*/
export class SetListNodeTypeCommand implements Command {
readonly label: string;
private rec = new Recorder();
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly typeId: string,
) {
this.label = typeId ? 'Set item type' : 'Mixed items';
}
execute(project: Project): void {
if (this.typeId && !structAssetOf(project, this.typeId)) throw new CommandError('That struct is gone');
const node = requireNode(project, this.assetId, this.nodeId);
if (!isListNode(node)) throw new CommandError('Only a List node takes an item type');
const value = node.params[LIST_VALUE];
const items = isList(value) ? value : [];
if (this.typeId) refuseOtherItems('The list', [items]);
this.rec = new Recorder();
if (this.typeId) this.rec.set(node.params, STRUCT_TYPE_PARAM, this.typeId);
else {
this.rec.delete(node.params, STRUCT_TYPE_PARAM);
if (value !== undefined) this.rec.set(node.params, LIST_VALUE, withoutItemTypes(items));
}
syncStructTypes(project, this.rec);
}
undo(): void {
this.rec.revert();
}
}
/**
* Turns the shape of a list's items into a Struct asset and types the list with it. Every field any
* item has is in it, at the value the first item holding it has – so no item loses a field.
+4 -2
View File
@@ -2,7 +2,7 @@ import { isList, type ListValue, type PropertyBag, type PropertyValue } from '..
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup } from './lookup';
import type { GraphLookup, ValueTyping } from './lookup';
import type { NodeContext } from './NodeContext';
import type { SharedPortGroup } from './portTypes';
import { asList, namedValue, type Value } from './values';
@@ -25,6 +25,8 @@ export interface PortDef {
* only the pin says what currently flows in. Set by `drawnDef`, read by `NodeView`, nowhere else.
*/
paintType?: PortType;
/** On a port writing a declared variable: what the written value is held to (#271). */
typing?: ValueTyping;
}
/**
@@ -115,7 +117,7 @@ export interface NodeDef {
*/
declaresPorts?: 'inputs' | 'outputs' | 'both';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
declaredPort?(port: DeclaredPort, node: GraphNode, graph: Graph, lookup: GraphLookup): PortDef;
/**
* Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual
* curve, and hung on ports that are never shown. A transition graph is read as directed steps
+20 -1
View File
@@ -38,7 +38,8 @@ export interface StateMachine {
/**
* What a node may look up in the project around its graph: the behaviors it can run, the state
* machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item`
* checks (#254). One handle, so no caller can resolve a node with half of it.
* checks (#254), and what a variable written by a graph is held to (#271). One handle, so no caller
* can resolve a node with half of it.
*/
export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null;
@@ -48,11 +49,27 @@ export interface GraphLookup {
structType(assetId: string): StructShape | null;
/** A texture's, frame's or pixel-art frame's own size in pixels; null for no picture. */
textureSize(pictureId: string): { w: number; h: number } | null;
/** The asset whose graph this is, `''` for none. */
ownerOf(graph: Graph): string;
/** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */
variableTyping(assetId: string, key: string): ValueTyping | undefined;
}
export interface StructShape {
name: string;
fields: readonly string[];
/** What the type's fields declare in turn, for those that declare anything. */
inner: Readonly<Record<string, ValueTyping>>;
}
/**
* What a written value is held to (#271): the Struct asset it is, the one every item of it is, and
* what its fields declare. Only the ids travel; a run asks `structType` for the rest.
*/
export interface ValueTyping {
structType?: string;
itemType?: string;
fields?: Readonly<Record<string, ValueTyping>>;
}
export const NO_LOOKUP: GraphLookup = {
@@ -61,4 +78,6 @@ export const NO_LOOKUP: GraphLookup = {
curve: () => null,
structType: () => null,
textureSize: () => null,
ownerOf: () => '',
variableTyping: () => undefined,
};
+1 -1
View File
@@ -178,7 +178,7 @@ function shapedDef(
return shared ? withSharedType(def, shared, node, graph, lookup) : def;
const own = (side: 'inputs' | 'outputs') =>
declaredOn(def, node.ports ?? [], side).map((p) =>
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
def.declaredPort ? def.declaredPort(p, node, graph, lookup) : portDefOf(p),
);
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
const side = (key: 'inputs' | 'outputs') => {
+2 -1
View File
@@ -3,8 +3,9 @@ import { itemKindOf } from '../../../properties/listItems';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asString, asValueItem, type ValueList } from '../../values';
import { fitsType } from '../../typedValues';
import { any, int, list } from '../shared';
import { fitsType, fitToSlot } from './SetItem';
import { fitToSlot } from './SetItem';
export const AddItem: NodeDef = {
type: 'AddItem',
+14 -4
View File
@@ -1,12 +1,15 @@
import { textOf } from '../../../model/Properties';
import { collapsedTitle, PROPERTIES } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import {
OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
propertyPort,
propertyTargetOf,
} from '../../propertyPorts';
import { objectsOf, readLive, writePort } from '../../liveObjects';
import { objectsOf, readLive } from '../../liveObjects';
import { writeDeclared } from '../../typedValues';
import { asEntity } from '../../values';
import { exec, obj } from '../shared';
@@ -22,7 +25,13 @@ export const Properties: NodeDef = {
params: [OBJECT_TYPE_SETTING, OBJECT_BEHAVIOR_SETTING],
hiddenParams: (params) => (propertyTargetOf(params) === 'behavior' ? [] : [OBJECT_BEHAVIOR_SETTING.key]),
declaresPorts: 'both',
declaredPort: (port, node) => propertyPort(port, node.params),
declaredPort: (port, node, _graph, lookup) => {
const def = propertyPort(port, node.params);
const onBehavior = propertyTargetOf(node.params) === 'behavior';
const behavior = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
const typing = onBehavior && behavior ? lookup.variableTyping(behavior, port.key) : undefined;
return typing ? { ...def, typing } : def;
},
derivePorts: ({ node }) => {
const ports = node.ports ?? [];
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
@@ -32,11 +41,12 @@ export const Properties: NodeDef = {
const target = asEntity(input('object'));
return target ? readLive(target.props, def.outputs, objectsOf(ctx, target)) : {};
},
run: (_ctx, inputs, _params, def) => {
run: (ctx, inputs, _params, def, _entry, lookup) => {
const target = asEntity(inputs.object);
if (target)
for (const port of def.inputs)
if (port.type !== 'exec' && port.key !== 'object') writePort(target.props, port, inputs[port.key]);
if (port.type !== 'exec' && port.key !== 'object')
writeDeclared(target.props, port, inputs[port.key], lookup, (m) => ctx.log(m));
return { next: 'exec' };
},
};
+3 -13
View File
@@ -1,9 +1,9 @@
import { isStruct, type PropertyValue, tagOf, textOf } from '../../../model/Properties';
import { type PropertyValue, textOf } from '../../../model/Properties';
import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems';
import type { StructShape } from '../../lookup';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asString, asValueItem, isEntity, type Value, type ValueList } from '../../values';
import { fitsType } from '../../typedValues';
import { asList, asNumber, asString, asValueItem, isEntity, type ValueList } from '../../values';
import { any, int, list } from '../shared';
export const SetItem: NodeDef = {
@@ -34,16 +34,6 @@ export const SetItem: NodeDef = {
params: [STRUCT_TYPE_SETTING],
};
/**
* A run knows a struct only by its fields (#254): one fits a type when it has exactly the type's
* field names – none missing, none more.
*/
export function fitsType(value: Value | undefined, type: StructShape): boolean {
if (isEntity(value) || !isStruct(value) || tagOf(value) !== undefined) return false;
const keys = Object.keys(value);
return keys.length === type.fields.length && type.fields.every((field) => field in value);
}
/** `value` as the kind `slot` holds, the way `Set item` and `Add item` write into a list. */
export function fitToSlot(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] {
// An object has no kind to keep and none to take on, so it is written as it is (#157).
+10 -3
View File
@@ -1,5 +1,7 @@
import { withoutItemTypes } from '../../../model/Properties';
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
@@ -18,7 +20,10 @@ function literal(type: string, kind: Exclude<VariableKind, 'struct' | 'object'>,
inputs: [],
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
params: [value],
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
// The items of a typed list carry their type's mark in the node, and a run never sees one (#271).
run: (_ctx, _inputs, params) => ({
outputs: { value: withoutItemTypes(coerceValue(value, params.value ?? value.default)) },
}),
};
}
@@ -55,8 +60,10 @@ export const AssetLiteral = literal(
'asset',
'A constant placeable asset, for a "Spawn" to place or an "Is asset" to compare with.',
);
export const ListLiteral = literal(
const list = literal(
'List',
'list',
'A constant list, edited item by item in the inspector – each item with its own kind.',
'A constant list, edited item by item in the inspector – each item with its own kind. With a type, every item is a struct of that type.',
);
export const ListLiteral: NodeDef = { ...list, params: [STRUCT_TYPE_SETTING, ...list.params] };
+11 -4
View File
@@ -1,7 +1,8 @@
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { objectsOf, readLive, writePort } from '../../liveObjects';
import { objectsOf, readLive } from '../../liveObjects';
import { variableBag } from '../../sharedState';
import { writeDeclared } from '../../typedValues';
import { exec } from '../shared';
/**
@@ -19,7 +20,12 @@ export const Variables: NodeDef = {
outputs: [],
params: [VARIABLES_SETTING],
declaresPorts: 'both',
declaredPort: (port) => withInlineDefault(portDefOf(port)),
declaredPort: (port, node, graph, lookup) => {
const owner = variablesSourceOf(node.params) || lookup.ownerOf(graph);
const typing = lookup.variableTyping(owner, port.key);
const def = withInlineDefault(portDefOf(port));
return typing ? { ...def, typing } : def;
},
derivePorts: ({ node }) => {
const ports = node.ports ?? [];
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
@@ -27,9 +33,10 @@ export const Variables: NodeDef = {
},
read: (ctx, params, _input, def) =>
readLive(variableBag(ctx, variablesSourceOf(params)), def.outputs, objectsOf(ctx, ctx.self)),
run: (ctx, inputs, params, def) => {
run: (ctx, inputs, params, def, _entry, lookup) => {
const bag = variableBag(ctx, variablesSourceOf(params));
for (const port of def.inputs) if (port.type !== 'exec') writePort(bag, port, inputs[port.key]);
for (const port of def.inputs)
if (port.type !== 'exec') writeDeclared(bag, port, inputs[port.key], lookup, (m) => ctx.log(m));
return { next: 'exec' };
},
};
+83
View File
@@ -0,0 +1,83 @@
import {
isList,
isStruct,
type PropertyBag,
type PropertyValue,
type StructValue,
tagOf,
} from '../model/Properties';
import { writePort } from './liveObjects';
import type { GraphLookup, StructShape, ValueTyping } from './lookup';
import type { PortDef } from './NodeRegistry';
import { asString, isEntity, type Value } from './values';
/**
* A run knows a struct only by its fields (#254): one fits a type when it has exactly the type's
* field names – none missing, none more.
*/
export function fitsType(value: Value | undefined, type: StructShape): boolean {
if (!isPlainStruct(value)) return false;
const keys = Object.keys(value);
return keys.length === type.fields.length && type.fields.every((field) => field in value);
}
/**
* What a typed struct variable takes (#271): a struct may leave fields out, which keep their value
* as with any struct slot, but may bring none the type lacks.
*/
function fitsWithin(value: Value | undefined, type: StructShape): boolean {
return isPlainStruct(value) && Object.keys(value).every((key) => type.fields.includes(key));
}
function isPlainStruct(value: Value | undefined): value is StructValue {
return !isEntity(value) && isStruct(value) && tagOf(value) === undefined;
}
/**
* Writes what arrives on `port` into a declared variable, held to its declaration (#271): a struct
* bringing a field its type lacks is not written, and an item of a typed list that is no struct of
* the type is left out – in the variable and in every list its declaration reaches inside it. The
* console says so for each, in the words `Add item` uses.
*/
export function writeDeclared(
bag: PropertyBag,
port: PortDef,
value: Value | undefined,
lookup: GraphLookup,
log: (message: string) => void,
): void {
const typing = port.typing;
const type = typing?.structType ? lookup.structType(typing.structType) : null;
if (type && !fitsWithin(value, type)) {
log(`Set ${port.key}: ${asString(value ?? null) || 'nothing'} is no ${type.name} – not set`);
return;
}
writePort(bag, port, value);
const written = bag[port.key];
if (!typing || written === undefined) return;
bag[port.key] = heldTo(written, typing, lookup, port.key, (at, item, name) =>
log(`Set ${at}: ${asString(item) || 'nothing'} is no ${name} – left out`),
);
}
type Report = (at: string, item: PropertyValue, typeName: string) => void;
function heldTo(
value: PropertyValue,
typing: ValueTyping,
lookup: GraphLookup,
at: string,
report: Report,
): PropertyValue {
if (isList(value)) {
const type = typing.itemType ? lookup.structType(typing.itemType) : null;
if (!type) return value;
const kept = value.filter((item) => fitsType(item, type) || (report(at, item, type.name), false));
return kept.map((item) => heldTo(item, { fields: type.inner }, lookup, at, report));
}
if (!typing.fields || !isPlainStruct(value)) return value;
const out: Record<string, PropertyValue> = { ...value };
for (const [key, inner] of Object.entries(typing.fields))
if (value[key] !== undefined) out[key] = heldTo(value[key], inner, lookup, `${at}.${key}`, report);
return out;
}
+12 -3
View File
@@ -2,9 +2,10 @@ import type { GraphLookup } from '../graph/lookup';
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
import { pictureSize } from '../properties/aspect';
import { resolveAssetProps } from '../properties/resolve';
import { isGraphAsset, isVariableOwner } from './Asset';
import { bagWithoutItemTypes } from './Properties';
import { typeFields } from './structTypes';
import { findAssetOf, type Project } from './Project';
import { fieldTypings, typeFields, typingOf } from './structTypes';
import { findAsset, findAssetOf, type Project } from './Project';
import { resolvePicture } from './texture';
/**
@@ -36,11 +37,19 @@ export function graphLookup(project: Project): GraphLookup {
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
structType: (assetId) => {
const asset = findAssetOf(project, assetId, 'struct');
return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null;
if (!asset) return null;
const fields = typeFields(asset);
return { name: asset.name, fields: fields.map((f) => f.key), inner: fieldTypings(fields) ?? {} };
},
textureSize: (pictureId) => {
const ref = resolvePicture(project, pictureId);
return ref ? pictureSize(ref) : null;
},
ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '',
variableTyping: (assetId, key) => {
const owner = findAsset(project, assetId);
const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === key) : undefined;
return def && typingOf(def);
},
};
}
+54 -3
View File
@@ -24,6 +24,7 @@ import {
VARIABLES,
variablesSourceOf,
} from '../graph/nested';
import type { ValueTyping } from '../graph/lookup';
import { splitField, splitPortKey } from '../graph/portSplits';
import { nodeDef } from '../graph/NodeRegistry';
import { isKindWith } from '../properties/kinds';
@@ -56,6 +57,8 @@ import { findAsset, findAssetOf, type Project } from './Project';
const PACK_STRUCT = 'PackStruct';
const EXPAND_STRUCT = 'ExpandStruct';
const LIST_NODE = 'List';
export const LIST_VALUE = 'value';
/** Deeper than this only a cycle written by hand can go; it is cut there. */
const MAX_DEPTH = 32;
@@ -72,6 +75,33 @@ export function nodeStructType(node: GraphNode): string {
return isTypedNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
}
/** A `List` node's items may all be one Struct asset (#271), picked in its Type setting. */
export function isListNode(node: GraphNode): boolean {
return node.type === LIST_NODE;
}
export function listNodeItemType(node: GraphNode): string {
return isListNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
}
/** What a declaration says about the structs its value holds – what a run holds a write to (#271). */
export function typingOf(def: PropertyDef | StructFieldDef): ValueTyping | undefined {
if (def.kind === 'list') return def.itemType ? { itemType: def.itemType } : undefined;
if (def.kind !== 'struct') return undefined;
const fields = fieldTypings(def.fields);
if (!def.structType && !fields) return undefined;
return { ...(def.structType ? { structType: def.structType } : {}), ...(fields ? { fields } : {}) };
}
/** `typingOf` for each field that declares anything, by key; undefined when none does. */
export function fieldTypings(fields: readonly StructFieldDef[]): Record<string, ValueTyping> | undefined {
const typed = fields.flatMap((f) => {
const typing = typingOf(f);
return typing ? [[f.key, typing] as const] : [];
});
return typed.length > 0 ? Object.fromEntries(typed) : undefined;
}
/** A declared variable as the struct field it becomes in every value of the type. */
function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
if (def.kind === 'struct')
@@ -394,6 +424,10 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
const value = holder.read();
if (value !== undefined) holder.write(renamedItems(project, value, rename));
}
for (const node of typedListNodes(project)) {
const value = node.params[LIST_VALUE];
if (value !== undefined) rec.set(node.params, LIST_VALUE, renamedItems(project, value, rename));
}
if (rename.path.length > 0) return;
renameSplitEdges(project, rec, rename);
for (const { graph, node } of typedNodes(project)) {
@@ -469,6 +503,12 @@ function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
);
}
function typedListNodes(project: Project): GraphNode[] {
return project.assets.flatMap((asset) =>
isGraphAsset(asset) ? asset.graph.nodes.filter((node) => listNodeItemType(node) !== '') : [],
);
}
/** The ports follow the fields; an edge on a port that is gone goes with it, as its typed-in value does. */
function syncNodePorts(
graph: Graph,
@@ -499,9 +539,9 @@ function syncNodePorts(
/**
* Brings every typed place in line with its Struct asset: the copied fields, every value kept for
* it, the ports of typed `Pack`/`Expand` nodes. A type holding another type is only right once that
* one is, so the passes repeat until one changes nothing. Every write goes through `rec`, so the
* whole reach of an edit is undone in one go.
* it, the ports of typed `Pack`/`Expand` nodes, the items of a typed `List` node. A type holding
* another type is only right once that one is, so the passes repeat until one changes nothing.
* Every write goes through `rec`, so the whole reach of an edit is undone in one go.
*/
export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldRename): void {
if (rename) applyRename(project, rec, rename);
@@ -530,6 +570,12 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
const type = structAssetOf(project, nodeStructType(node));
if (type) syncNodePorts(graph, node, typeFields(type), rec);
}
for (const node of typedListNodes(project)) {
const type = structAssetOf(project, listNodeItemType(node));
const value = node.params[LIST_VALUE];
if (type && isList(value))
rec.set(node.params, LIST_VALUE, shapedItems(project, markedItems(value, type.id)));
}
for (const { graph, node, split } of splitOutputs(project)) {
const type = structAssetOf(project, split.structType);
if (type) syncSplit(graph, node, split, type, rec);
@@ -561,6 +607,10 @@ function outputDecl(
const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === port) : undefined;
return def && fieldOfVariable(def);
}
if (isListNode(node)) {
const itemType = listNodeItemType(node);
return itemType ? { key: port, kind: 'list', itemType } : undefined;
}
const typeId = nodeStructType(node);
if (!typeId) return undefined;
if (node.type === PACK_STRUCT) return { key: port, kind: 'struct', fields: [], structType: typeId };
@@ -602,6 +652,7 @@ export function structTypeUsages(
lists: lists.filter((id) => id === typeId).length,
nodes:
typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length +
typedListNodes(project).filter((node) => listNodeItemType(node) === typeId).length +
splitOutputs(project).filter(({ split }) => split.structType === typeId).length,
};
}
+16 -4
View File
@@ -27,8 +27,14 @@ import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { isTypedNode, nodeStructType, structAssetOf } from '../../core/model/structTypes';
import { SetStructTypeCommand } from '../../core/commands/structTypeCommands';
import {
isListNode,
isTypedNode,
listNodeItemType,
nodeStructType,
structAssetOf,
} from '../../core/model/structTypes';
import { SetListNodeTypeCommand, SetStructTypeCommand } from '../../core/commands/structTypeCommands';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
@@ -67,8 +73,9 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
.join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
// The source names which declarations the port menu offers, so switching it has to rebuild.
const typeName = structAssetOf(project, nodeStructType(node))?.name ?? '';
const source = `${variablesSourceOf(node.params)}:${nodeStructType(node)}:${typeName}`;
const typeId = nodeStructType(node) || listNodeItemType(node);
const typeName = structAssetOf(project, typeId)?.name ?? '';
const source = `${variablesSourceOf(node.params)}:${typeId}:${typeName}`;
const choices = variableChoices(project, assetId, node)
.map((v) => v.key)
.join(',');
@@ -116,6 +123,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
};
addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick });
}
// A List node's type reshapes its items (#271), which is no plain param edit either.
const pickItemType = (value: PropertyValue) =>
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) {
@@ -129,6 +139,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
for (const param of settings) {
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
buildTransitionPicker(host, assetId, node, asset.graph);
else if (param.key === STRUCT_TYPE_PARAM && isListNode(node))
addSetting(param, { onInput: () => {}, onChange: pickItemType });
else addSetting(param);
}
}
+5 -3
View File
@@ -9,7 +9,7 @@ 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 { listItemTypeAt } from '../../core/model/structTypes';
import { LIST_VALUE, listItemTypeAt, listNodeItemType } from '../../core/model/structTypes';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
@@ -234,9 +234,11 @@ function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>
const found = currentNode(state.project, slot.assetId, slot.nodeId);
if (!found) return null;
const nodeDef = resolveDef(found.node, found.graph, graphLookup(state.project));
const def =
const param =
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
if (!nodeDef || !def) return null;
if (!nodeDef || !param) return null;
const itemType = slot.key === LIST_VALUE ? listNodeItemType(found.node) : '';
const def = itemType && param.kind === 'list' ? { ...param, itemType } : param;
return {
def,
title: `${nodeDef.label} · ${def.label}`,
+1 -1
View File
@@ -135,7 +135,7 @@ describe('the literal nodes', () => {
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
expect(def.label, type).toBe(kindLabel(kind));
expect(def.outputs[0]?.type, type).toBe(KIND[kind].travels);
expect(def.params[0]?.kind, type).toBe(kind);
expect(def.params.find((p) => p.key === 'value')?.kind, type).toBe(kind);
}
});
});
+5
View File
@@ -802,6 +802,11 @@ const CASES: Record<string, Case[]> = {
List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
{ name: 'an unset list is empty, not missing', outputs: { value: [] } },
{
name: 'with a type, hands out the items without the mark they carry in the node (#271)',
params: { structType: 'loot', value: [{ Name: 'Gem', $struct: 'loot' }] },
outputs: { value: [{ Name: 'Gem' }] },
},
],
Fill: [
{ name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } },
+292
View File
@@ -0,0 +1,292 @@
/**
* Typed variables at run time (#271): a graph writing a list variable with an item type leaves out
* every item that is no struct of the type, and one writing a typed struct variable refuses a struct
* bringing a field the type lacks – one leaving fields out still merges in. A `List` node can be
* typed like a list variable.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
SetListItemTypeCommand,
SetListNodeTypeCommand,
SetStructTypeCommand,
WithStructTypes,
} from '../../src/core/commands/structTypeCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import { GraphInterpreter, type GraphNode, VARIABLES_PARAM } from '../../src/core/graph';
import { OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts';
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import { ITEM_TYPE_KEY, type PropertyBag, type PropertyValue } from '../../src/core/model/Properties';
import { listNodeItemType, outputStructType, structTypeUsages } from '../../src/core/model/structTypes';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeContext,
fakeEntity,
fire,
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
runDef,
type SceneFixture,
} from '../helpers';
/** `Loot` = { Name: "Coin", Amount: 1 } */
function lootType(f: SceneFixture): StructAsset {
const create = new CreateAssetCommand('struct', 'Loot');
f.stack.execute(create);
const id = create.asset.id;
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
f.stack.execute(new SetAssetPropertyCommand(id, 'Name', 'Coin'));
f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1));
return create.asset as StructAsset;
}
/** A behavior declaring `key` of `kind`, and `src` of the same kind that `On start` copies into it. */
function copying(f: SceneFixture, kind: 'list' | 'struct', source: PropertyBag = {}) {
const owner = makeBehavior(f, 'Chest');
f.stack.execute(new AddVariableCommand(owner.id, 'src', kind));
const edit = graphEditor(f, owner);
return {
owner,
/** Wires `src` into a Set of `key`, once `key` is declared the way the test wants it. */
wire(key: string) {
const start = edit.addNode('OnStart');
const get = edit.variables(['src']);
const set = edit.variables([key], { write: true, params: source });
edit.wire([
[start, 'exec', set, 'exec'],
[get, 'src', set, key],
]);
},
};
}
function run(f: SceneFixture, owner: BehaviorAsset, variables: PropertyBag, shared: PropertyBag = {}) {
const ctx = fakeContext({ variables, shared: { variables: shared as never, states: {} } });
fire(new GraphInterpreter(owner.graph, graphLookup(f.project)), 'OnStart', ctx);
return ctx;
}
const typeList = (f: SceneFixture, ownerId: string, key: string, typeId: string, path: string[] = []) =>
f.stack.execute(new SetListItemTypeCommand({ ownerId, key, path }, typeId));
describe('writing a list variable with an item type', () => {
it('keeps the items of the type and leaves out the rest, each one said in the console', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { owner, wire } = copying(f, 'list');
f.stack.execute(new AddVariableCommand(owner.id, 'loots', 'list'));
typeList(f, owner.id, 'loots', loot.id);
wire('loots');
const src: PropertyValue[] = [
{ Name: 'Gem', Amount: 2 },
{ Name: 'Axe' },
5,
{ Name: 'Ruby', Amount: 1, Extra: 0 },
];
const ctx = run(f, owner, { src, loots: [] });
expect(ctx.variables.loots).toEqual([{ Name: 'Gem', Amount: 2 }]);
expect(ctx.logs).toEqual([
'Set loots: Name: Axe is no Loot – left out',
'Set loots: 5 is no Loot – left out',
'Set loots: Name: Ruby, Amount: 1, Extra: 0 is no Loot – left out',
]);
});
it('takes anything into a mixed list, as before', () => {
const f = makeSpriteScene();
lootType(f);
const { owner, wire } = copying(f, 'list');
f.stack.execute(new AddVariableCommand(owner.id, 'loots', 'list'));
wire('loots');
const ctx = run(f, owner, { src: [{ Name: 'Axe' }, 5], loots: [] });
expect(ctx.variables.loots).toEqual([{ Name: 'Axe' }, 5]);
expect(ctx.logs).toEqual([]);
});
it("holds a Variables asset's typed list to its type as well", () => {
const f = makeSpriteScene();
const loot = lootType(f);
const globals = makeVariables(f, 'Globals', []);
f.stack.execute(new AddVariableCommand(globals.id, 'bag', 'list'));
typeList(f, globals.id, 'bag', loot.id);
const { owner, wire } = copying(f, 'list', { [VARIABLES_PARAM]: globals.id });
wire('bag');
const shared = { [globals.id]: { bag: [] } };
const ctx = run(f, owner, { src: [7, { Name: 'Gem', Amount: 3 }] }, shared);
expect(shared[globals.id]!.bag).toEqual([{ Name: 'Gem', Amount: 3 }]);
expect(ctx.logs).toEqual(['Set bag: 7 is no Loot – left out']);
});
it('reaches a typed list inside a struct variable, and the lists inside typed items', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const bag = new CreateAssetCommand('struct', 'Bag');
f.stack.execute(bag);
f.stack.execute(new AddVariableCommand(bag.asset.id, 'inside', 'list'));
typeList(f, bag.asset.id, 'inside', loot.id);
const { owner, wire } = copying(f, 'struct');
f.stack.execute(new AddVariableCommand(owner.id, 'room', 'struct'));
f.stack.execute(new AddStructFieldCommand(owner.id, 'room', { key: 'bags', kind: 'list' }));
typeList(f, owner.id, 'room', bag.asset.id, ['bags']);
wire('room');
const src = { bags: [{ inside: [{ Name: 'Gem', Amount: 1 }, 'junk'] }, 3] };
const ctx = run(f, owner, { src, room: { bags: [] } });
expect(ctx.variables.room).toEqual({ bags: [{ inside: [{ Name: 'Gem', Amount: 1 }] }] });
expect(ctx.logs).toEqual([
'Set room.bags: 3 is no Bag – left out',
'Set room.bags.inside: junk is no Loot – left out',
]);
});
});
describe('writing a struct variable with a type', () => {
function typedDrop(f: SceneFixture) {
const loot = lootType(f);
const { owner, wire } = copying(f, 'struct');
f.stack.execute(new AddVariableCommand(owner.id, 'drop', 'struct'));
const at = { kind: 'variable', ownerId: owner.id, key: 'drop', path: [] } as const;
f.stack.execute(new SetStructTypeCommand(at, loot.id));
wire('drop');
return owner;
}
const before = { Name: 'Coin', Amount: 1 };
it('refuses a struct bringing a field the type lacks, and anything but a struct', () => {
const f = makeSpriteScene();
const owner = typedDrop(f);
const extra = run(f, owner, { src: { Name: 'Axe', Amount: 2, Sharp: true }, drop: before });
expect(extra.variables.drop).toEqual(before);
expect(extra.logs).toEqual(['Set drop: Name: Axe, Amount: 2, Sharp: true is no Loot – not set']);
const other = run(f, owner, { src: 4, drop: before });
expect(other.variables.drop).toEqual(before);
expect(other.logs).toEqual(['Set drop: 4 is no Loot – not set']);
});
it('merges a struct leaving fields out, as any struct slot does', () => {
const f = makeSpriteScene();
const owner = typedDrop(f);
const ctx = run(f, owner, { src: { Amount: 5 }, drop: before });
expect(ctx.variables.drop).toEqual({ Name: 'Coin', Amount: 5 });
expect(ctx.logs).toEqual([]);
});
});
describe('the Properties node writing a behavior variable', () => {
it('holds the variable of the object it is handed to its declaration', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { owner } = copying(f, 'list');
f.stack.execute(new AddVariableCommand(owner.id, 'loots', 'list'));
typeList(f, owner.id, 'loots', loot.id);
const edit = graphEditor(f, owner);
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const get = edit.variables(['src']);
const set = edit.properties([{ key: 'loots', kind: 'list', write: true }], {
[OBJECT_TYPE_PARAM]: 'behavior',
[OBJECT_BEHAVIOR_PARAM]: owner.id,
});
edit.wire([
[start, 'exec', set, 'exec'],
[self, 'object', set, 'object'],
[get, 'src', set, 'loots'],
]);
const target = { ...fakeEntity('self'), props: { loots: [] } };
const ctx = fakeContext({ self: target, variables: { src: [{ Name: 'Gem', Amount: 1 }, 'x'] } });
fire(new GraphInterpreter(owner.graph, graphLookup(f.project)), 'OnStart', ctx);
expect(target.props.loots).toEqual([{ Name: 'Gem', Amount: 1 }]);
expect(ctx.logs).toEqual(['Set loots: x is no Loot – left out']);
});
});
describe('a List node with a type', () => {
function listNode(f: SceneFixture, value: PropertyValue) {
const owner = makeBehavior(f, 'Chest');
const node = graphEditor(f, owner).addNode('List', { value });
const typed = (typeId: string) => f.stack.execute(new SetListNodeTypeCommand(owner.id, node.id, typeId));
return { owner, node, typed };
}
const output = (f: SceneFixture, node: GraphNode) =>
runDef('List', fakeContext(), {}, node.params, 'exec', graphLookup(f.project)).outputs?.value;
const untyped = (node: GraphNode) => expect(listNodeItemType(node)).toBe('');
it('brings its items into the type, marked, and hands them out without the mark', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { node, typed } = listNode(f, [{ Name: 'Gem', Old: 4 }, {}]);
typed(loot.id);
expect(node.params).toMatchObject({ structType: loot.id });
expect(node.params.value).toEqual([
{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id },
{ Name: 'Coin', Amount: 1, [ITEM_TYPE_KEY]: loot.id },
]);
expect(output(f, node)).toEqual([
{ Name: 'Gem', Amount: 1 },
{ Name: 'Coin', Amount: 1 },
]);
f.stack.undo();
untyped(node);
expect(node.params.value).toEqual([{ Name: 'Gem', Old: 4 }, {}]);
});
it('refuses a list holding anything but structs, and changes nothing', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { node, typed } = listNode(f, [{ Name: 'Gem' }, 3]);
expect(() => typed(loot.id)).toThrow(
'The list holds 1 item that is no struct – only a list of structs can take a type',
);
untyped(node);
expect(node.params.value).toEqual([{ Name: 'Gem' }, 3]);
});
it('goes back to mixed without the marks, keeping the values', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { node, typed } = listNode(f, [{ Name: 'Gem' }]);
typed(loot.id);
typed('');
untyped(node);
expect(node.params.value).toEqual([{ Name: 'Gem', Amount: 1 }]);
f.stack.undo();
expect(node.params.value).toEqual([{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]);
});
it('follows the type, counts as a node using it and names it for the items a loop reads', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { owner, node, typed } = listNode(f, [{ Name: 'Gem', Amount: 7 }]);
typed(loot.id);
const renamed = new RenameVariableCommand(loot.id, 'Amount', 'Count');
f.stack.execute(
new WithStructTypes(renamed, { structType: loot.id, path: [], from: 'Amount', to: 'Count' }),
);
expect(node.params.value).toEqual([{ Name: 'Gem', Count: 7, [ITEM_TYPE_KEY]: loot.id }]);
expect(structTypeUsages(f.project, loot.id).nodes).toBe(1);
const edit = graphEditor(f, owner);
const each = edit.addNode('ForEach');
edit.wire([[node, 'value', each, 'list']]);
expect(outputStructType(f.project, owner.graph, owner.id, each, 'item')).toBe(loot.id);
});
it('survives saving and loading', () => {
const f = makeSpriteScene();
const loot = lootType(f);
const { owner, node, typed } = listNode(f, [{ Name: 'Gem' }]);
typed(loot.id);
const loaded = deserializeProject(serializeProject(f.project));
const asset = loaded.assets.find((a) => a.id === owner.id) as BehaviorAsset;
expect(asset.graph.nodes.find((n) => n.id === node.id)!.params).toEqual({
structType: loot.id,
value: [{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }],
});
});
});
+99 -2
View File
@@ -6,6 +6,8 @@
* `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.
* A shared typed list keeps what a run added to it when the document changes during the run.
* Writing a typed list variable leaves out what is no item of the type (#271), and a List node takes
* a type in its settings like a list variable does.
*
* Everything it adds carries a `tl55` id or is an asset it made, and goes again at the end.
*/
@@ -306,6 +308,100 @@ export async function typedLists(t) {
JSON.stringify(logged),
);
// 55j. Writing a typed list variable (#271): a mixed List node hands a Loot, a 9 and a struct of
// other fields to `kept`, typed Loot 55 – only the Loot stays, the other two are reported.
const writerGraph = {
nodes: [
node('tl55-w-start', 'OnStart', -400, 0),
node('tl55-w-list', 'List', -400, 160, { value: [{ Name: 'Gem', Amount: 4 }, 9, { Name: 'X' }] }),
node('tl55-w-set', 'Variables', -200, 0, {}, [{ key: 'kept', kind: 'list', write: true }]),
node('tl55-w-get', 'Variables', -200, 160, {}, [{ key: 'kept', kind: 'list' }]),
node('tl55-w-count', 'Count', 0, 160),
node('tl55-w-log', 'Log', 200, 0),
node('tl55-w-typed', 'List', -400, 320, { value: [{ Name: 'Gem' }] }),
],
edges: [
edge('tl55-w-e1', 'tl55-w-start', 'exec', 'tl55-w-set', 'exec'),
edge('tl55-w-e2', 'tl55-w-list', 'value', 'tl55-w-set', 'kept'),
edge('tl55-w-e3', 'tl55-w-set', 'exec', 'tl55-w-log', 'exec'),
edge('tl55-w-e4', 'tl55-w-get', 'kept', 'tl55-w-count', 'list'),
edge('tl55-w-e5', 'tl55-w-count', 'count', 'tl55-w-log', 'message'),
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({
id: 'tl55-writer',
type: 'behavior',
name: 'Writer 55',
props: { enabled: true, runInEditor: false },
variables: [{ key: 'kept', label: 'kept', kind: 'list', itemType: '${LOOT}', default: [] }],
graph: ${JSON.stringify(writerGraph)},
});
${MAKE_OBJECT}
scene.objects.push(make('tl55-writer-obj', 'tl55-writer', '', 480, 'Writer 55'));
`);
const writeFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const written = t.consoleLog
.slice(writeFrom)
.filter((line) => line.startsWith('[behavior Writer 55]'))
.map((line) => line.split(']').pop().trim());
check(
'a typed list variable keeps the Loot a graph writes into it and reports the 9 and the stranger',
JSON.stringify(written) ===
JSON.stringify([
'Set kept: 9 is no Loot 55 – left out',
'Set kept: Name: X is no Loot 55 – left out',
'1',
]),
JSON.stringify(written),
);
// 55k. A List node picks Loot 55 in its Type setting: its items become Loots, marked, with fixed
// fields, and + Item adds one at the defaults. The mixed List with a 9 in it refuses.
const listNode = async (id) =>
(await asset('tl55-writer')).graph.nodes.find((n) => n.id === id)?.params ?? null;
await evaluate('__gm.console.clear()');
await select({ kind: 'node', assetId: 'tl55-writer', nodeId: 'tl55-w-list' });
await sleep(150);
await press_(await json("__gm.inspectorField('Type')"), 'Type of the mixed List');
await pick('Loot 55 (struct)');
await sleep(300);
const nodeLines = ((await json('__gm.console.lines()')) ?? []).map((l) => l.text);
check(
'a List node holding a 9 refuses the type, in the console, and stays as it was',
nodeLines.includes('The list holds 1 item that is no struct – only a list of structs can take a type') &&
!(await listNode('tl55-w-list'))?.structType,
JSON.stringify({ nodeLines, params: await listNode('tl55-w-list') }),
);
await select({ kind: 'node', assetId: 'tl55-writer', nodeId: 'tl55-w-typed' });
await sleep(150);
await press_(await json("__gm.inspectorField('Type')"), 'Type of the struct List');
await pick('Loot 55 (struct)');
await sleep(200);
const nodeHead = await json(`__gm.values.headAt('inspector', 'List · Value')`);
await press_(nodeHead?.add, '+ Item of the List node');
const typedNode = await listNode('tl55-w-typed');
const nodeLocked =
((await json("__gm.values.lists('inspector')")) ?? []).find((l) => l.title === 'List · Value')?.locked ??
null;
check(
'the List node takes the type: Gem becomes a Loot, + Item adds one, both with fixed fields',
typedNode?.structType === LOOT &&
JSON.stringify(typedNode.value) ===
JSON.stringify([
{ Name: 'Gem', Amount: 1, [MARK]: LOOT },
{ Name: 'Coin', Amount: 1, [MARK]: LOOT },
]) &&
JSON.stringify(nodeLocked) === '[true,true]',
JSON.stringify({ typedNode, nodeLocked }),
);
await shot('05-list-node-type');
// 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 = {
@@ -371,11 +467,12 @@ export async function typedLists(t) {
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj' && o.id !== 'tl55-fill-obj');
const placed = ['tl55-obj', 'tl55-fill-obj', 'tl55-writer-obj'];
scene.objects = scene.objects.filter((o) => !placed.includes(o.id));
const made = ${JSON.stringify(made?.id ?? '')};
p.assets = p.assets.filter(
(a) =>
!['tl55-chest', 'tl55-save', 'tl55-filler', '${LOOT}', '${DROP}', made].includes(a.id),
!['tl55-chest', 'tl55-save', 'tl55-filler', 'tl55-writer', '${LOOT}', '${DROP}', made].includes(a.id),
);
`);
}