diff --git a/README.md b/README.md index f2af98d3..ca67bed9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/structTypeCommands.ts b/src/core/commands/structTypeCommands.ts index d30b1562..df0489fd 100644 --- a/src/core/commands/structTypeCommands.ts +++ b/src/core/commands/structTypeCommands.ts @@ -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. diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 07723c24..56b0107b 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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 diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index ea395073..ad5c0cdc 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -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>; +} + +/** + * 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>; } export const NO_LOOKUP: GraphLookup = { @@ -61,4 +78,6 @@ export const NO_LOOKUP: GraphLookup = { curve: () => null, structType: () => null, textureSize: () => null, + ownerOf: () => '', + variableTyping: () => undefined, }; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 645077af..ce80ce91 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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') => { diff --git a/src/core/graph/nodes/data/AddItem.ts b/src/core/graph/nodes/data/AddItem.ts index a71731e3..a92c196a 100644 --- a/src/core/graph/nodes/data/AddItem.ts +++ b/src/core/graph/nodes/data/AddItem.ts @@ -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', diff --git a/src/core/graph/nodes/data/Properties.ts b/src/core/graph/nodes/data/Properties.ts index 79d1e0f0..9d1f1a80 100644 --- a/src/core/graph/nodes/data/Properties.ts +++ b/src/core/graph/nodes/data/Properties.ts @@ -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' }; }, }; diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index d1a0c821..2dc66bac 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -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). diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index f0aafde6..b6ea4c67 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -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, 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] }; diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index 1038011e..4cbe6809 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -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' }; }, }; diff --git a/src/core/graph/typedValues.ts b/src/core/graph/typedValues.ts new file mode 100644 index 00000000..fc36cdb9 --- /dev/null +++ b/src/core/graph/typedValues.ts @@ -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 = { ...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; +} diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 2f36c5d9..dff5bc49 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -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); + }, }; } diff --git a/src/core/model/structTypes.ts b/src/core/model/structTypes.ts index fb1d8ca1..460c945a 100644 --- a/src/core/model/structTypes.ts +++ b/src/core/model/structTypes.ts @@ -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 | 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, }; } diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 1f0c8ade..b54cec10 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -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); } } diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index 87920bd2..7703949b 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -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 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}`, diff --git a/tests/core/kinds.test.ts b/tests/core/kinds.test.ts index cb6f04d0..52a95ab5 100644 --- a/tests/core/kinds.test.ts +++ b/tests/core/kinds.test.ts @@ -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); } }); }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d3d27f83..5c15134d 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -802,6 +802,11 @@ const CASES: Record = { 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] } }, diff --git a/tests/core/typed-writes.test.ts b/tests/core/typed-writes.test.ts new file mode 100644 index 00000000..b9749305 --- /dev/null +++ b/tests/core/typed-writes.test.ts @@ -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 }], + }); + }); +}); diff --git a/tools/e2e/55-typed-lists.mjs b/tools/e2e/55-typed-lists.mjs index 5fac0927..4d6aada4 100644 --- a/tools/e2e/55-typed-lists.mjs +++ b/tools/e2e/55-typed-lists.mjs @@ -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), ); `); }