diff --git a/README.md b/README.md index 6dfcaa2d..6ea9ef82 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1500 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1513 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 b37a07f8..8d8b728b 100644 --- a/src/core/commands/structTypeCommands.ts +++ b/src/core/commands/structTypeCommands.ts @@ -7,17 +7,29 @@ import type { GraphNode } from '../graph/Graph'; import { STRUCT_TYPE_PARAM } from '../graph/nested'; import type { StructAsset } from '../model/Asset'; import { nextAssetName } from './naming'; -import { isStruct, type PropertyValue } from '../model/Properties'; +import { + isList, + isStruct, + itemTypeOf, + type ListValue, + type PropertyValue, + type StructValue, + tagOf, +} from '../model/Properties'; import type { Project } from '../model/Project'; import { containsType, type FieldRename, isTypedNode, + listDeclAt, + setListItemType, setStructType, structAssetOf, structAt, syncStructTypes, + variableValues, } from '../model/structTypes'; +import { structItemFields } from '../properties/listItems'; import { coerceValue, type PropertyDef, @@ -127,6 +139,7 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined ); if (value !== undefined) def.default = coerceValue(def, value); if (def.kind === 'struct' && field.kind === 'struct' && field.structType) def.structType = field.structType; + if (def.kind === 'list' && field.kind === 'list' && field.itemType) def.itemType = field.itemType; return def; } @@ -176,3 +189,134 @@ export class MakeStructTypeCommand implements Command { if (this.asset) project.assets = removeById(project.assets, this.asset.id); } } + +/** A list variable, or a list field inside a struct variable – `path` leads to the field (#254). */ +export interface ListTarget { + ownerId: string; + key: string; + path: readonly string[]; +} + +function requireListDecl(project: Project, target: ListTarget) { + const owner = requireVariableOwner(project, target.ownerId); + const def = owner.variables.find((v) => v.key === target.key); + const decl = def && listDeclAt(def, target.path); + if (!def || !decl) throw new CommandError(`"${[target.key, ...target.path].join('.')}" is no list`); + return { owner, def, decl }; +} + +/** Every list the target holds anywhere – its default, the owner's value, each override. */ +function listsOf(project: Project, target: ListTarget): ListValue[] { + const { owner, def } = requireListDecl(project, target); + return variableValues(project, owner, def).flatMap((value) => { + const list = valueAt(value, target.path); + return isList(list) ? [list] : []; + }); +} + +const isStructItem = (item: PropertyValue): boolean => isStruct(item) && tagOf(item) === undefined; + +/** + * A list holding anything but structs cannot take a type: which of its items would become what is + * not for the editor to guess (#254). The refusal says so, in the console like any refused edit. + */ +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`); + } + return lists; +} + +/** + * 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. + */ +export class SetListItemTypeCommand implements Command { + readonly label: string; + private rec = new Recorder(); + + constructor( + private readonly target: ListTarget, + 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 { def } = requireListDecl(project, this.target); + if (this.typeId) requireStructItems(project, this.target); + this.rec = new Recorder(); + setListItemType(def, this.target.path, this.typeId, this.rec); + 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. + */ +export class MakeListItemTypeCommand implements Command { + readonly label = 'Make struct type'; + private asset?: StructAsset; + private rec = new Recorder(); + + constructor(private readonly target: ListTarget) {} + + get assetId(): string { + return this.asset?.id ?? ''; + } + + execute(project: Project): void { + const { def, decl } = requireListDecl(project, this.target); + if (decl.itemType && structAssetOf(project, decl.itemType)) + throw new CommandError('This list has a type already'); + const lists = requireStructItems(project, this.target); + if (!this.asset) { + const items = lists.flat().filter(isStruct); + const values: Record = {}; + const fields: StructFieldDef[] = []; + for (const item of items) + for (const field of itemFieldDefs(item)) + if (!(field.key in values)) { + values[field.key] = item[field.key]!; + fields.push(field); + } + const variables = fields.map((f) => variableOfField(f, values[f.key])); + const props = Object.fromEntries(variables.map((v) => [v.key, v.default])); + const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key); + this.asset = { id: newId(), type: 'struct', name, props, variables }; + } + project.assets.push(this.asset); + this.rec = new Recorder(); + setListItemType(def, this.target.path, this.asset.id, this.rec); + syncStructTypes(project, this.rec); + } + + undo(project: Project): void { + this.rec.revert(); + if (this.asset) project.assets = removeById(project.assets, this.asset.id); + } +} + +/** The fields a struct item has, as a declaration: a struct in it declares its own again. */ +function itemFieldDefs(item: StructValue): StructFieldDef[] { + return structItemFields(item).map(({ key, kind }): StructFieldDef => { + const value = item[key]; + if (kind === 'struct' && isStruct(value)) { + const typeId = itemTypeOf(value); + return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) }; + } + if (kind === 'struct') return { key, kind, fields: [] }; + if (kind === 'list') return { key, kind }; + return { key, kind }; + }); +} diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 47ee82dc..da5a19d3 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -37,13 +37,25 @@ 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 and the curves a `Lerp` eases along (#247). One handle, so no caller can - * resolve a node with half of it. + * 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. */ export interface GraphLookup { behavior(assetId: string): BehaviorProgram | null; machine(assetId: string): StateMachine | null; curve(assetId: string): readonly VectorPoint[] | null; + /** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */ + structType(assetId: string): StructShape | null; } -export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null }; +export interface StructShape { + name: string; + fields: readonly string[]; +} + +export const NO_LOOKUP: GraphLookup = { + behavior: () => null, + machine: () => null, + curve: () => null, + structType: () => null, +}; diff --git a/src/core/graph/nodes/data/AddItem.ts b/src/core/graph/nodes/data/AddItem.ts index 427dc5f4..a71731e3 100644 --- a/src/core/graph/nodes/data/AddItem.ts +++ b/src/core/graph/nodes/data/AddItem.ts @@ -1,20 +1,27 @@ -import type { PropertyValue } from '../../../model/Properties'; +import { type PropertyValue, textOf } from '../../../model/Properties'; import { itemKindOf } from '../../../properties/listItems'; +import { STRUCT_TYPE_SETTING } from '../../nested'; import type { NodeDef } from '../../NodeRegistry'; -import { asList, asNumber, asValueItem, type ValueList } from '../../values'; +import { asList, asNumber, asString, asValueItem, type ValueList } from '../../values'; import { any, int, list } from '../shared'; -import { fitToSlot } from './SetItem'; +import { fitsType, fitToSlot } from './SetItem'; export const AddItem: NodeDef = { type: 'AddItem', label: 'Add item', category: 'data', description: - 'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone.', + 'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.', inputs: [list('list'), any('value'), int('index', -1)], outputs: [list('result')], - run: (_ctx, inputs) => { + run: (ctx, inputs, params, _def, _entry, lookup) => { const items = asList(inputs.list); + const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? ''); + const type = typeId ? lookup.structType(typeId) : null; + if (type && !fitsType(inputs.value, type)) { + ctx.log(`Add item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not added`); + return { outputs: { result: items } }; + } const i = inputs.index === undefined ? -1 : Math.floor(asNumber(inputs.index)); // No index, −1 or anything outside the list appends – an index can only ever miss towards the end. const at = i >= 0 && i <= items.length ? i : items.length; @@ -22,7 +29,7 @@ export const AddItem: NodeDef = { next.splice(at, 0, fitted(items, asValueItem(inputs.value))); return { outputs: { result: next } }; }, - params: [], + params: [STRUCT_TYPE_SETTING], }; /** diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index 8e783443..d1a0c821 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -1,29 +1,49 @@ -import type { PropertyValue } from '../../../model/Properties'; +import { isStruct, type PropertyValue, tagOf, 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, asValueItem, isEntity, type ValueList } from '../../values'; +import { asList, asNumber, asString, asValueItem, isEntity, type Value, type ValueList } from '../../values'; import { any, int, list } from '../shared'; export const SetItem: NodeDef = { type: 'SetItem', label: 'Set item', category: 'data', - description: 'The same list with one item replaced. The list that came in is left alone.', + description: + 'The same list with one item replaced. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.', inputs: [list('list'), int('index'), any('value')], outputs: [list('result')], - run: (_ctx, inputs) => { + run: (ctx, inputs, params, _def, _entry, lookup) => { const items = asList(inputs.list); const i = Math.floor(asNumber(inputs.index)); if (i < 0 || i >= items.length) return { outputs: { result: items } }; + // Checked like `Add item` (#254): by the field names, the only thing a run knows a struct by. + const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? ''); + const type = typeId ? lookup.structType(typeId) : null; + if (type && !fitsType(inputs.value, type)) { + ctx.log(`Set item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not set`); + return { outputs: { result: items } }; + } const next = [...items]; // The slot keeps its kind: writing a number into a color item leaves a color behind, the same // way writing into a number variable leaves a number (see `asTypeOf`). next[i] = fitToSlot(items[i], asValueItem(inputs.value)); return { outputs: { result: next } }; }, - params: [], + 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/sharedState.ts b/src/core/graph/sharedState.ts index 93a606c2..60d284d0 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -1,4 +1,4 @@ -import type { PropertyBag } from '../model/Properties'; +import { type PropertyBag, withoutItemTypes } from '../model/Properties'; import type { Project } from '../model/Project'; import { isPerScene, isShared, sharedScopeOf } from '../model/Scope'; import type { NodeContext, SharedState } from './NodeContext'; @@ -14,7 +14,7 @@ export function declaredVariables(project: Project): Record for (const asset of project.assets) { if (asset.type !== 'variables') continue; const bag: PropertyBag = {}; - for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default; + for (const def of asset.variables) bag[def.key] = withoutItemTypes(asset.props[def.key] ?? def.default); declared[asset.id] = bag; } return declared; diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index f617f9ed..61d25bf9 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -32,12 +32,55 @@ interface TaggedFields { const TAG = '$'; +/** + * The Struct asset a struct item follows (#254), kept in the item because a list declares at most + * one type for all of them – and a mixed list none. Model only: a run never sees it + * (`withoutItemTypes`), and no field can be called that, as no field name starts with `$`. + */ +export const ITEM_TYPE_KEY = '$struct'; + /** The kind `value` wears as a tag, if it is a tagged item. */ export function tagOf(value: unknown): string | undefined { if (!isStruct(value)) return undefined; const keys = Object.keys(value); const key = keys.length === 1 ? keys[0] : undefined; - return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined; + return key?.startsWith(TAG) && key !== ITEM_TYPE_KEY ? key.slice(TAG.length) : undefined; +} + +/** The Struct asset id a struct item is marked with, if any. */ +export function itemTypeOf(value: PropertyValue | undefined): string | undefined { + if (!isStruct(value)) return undefined; + const id = value[ITEM_TYPE_KEY]; + return typeof id === 'string' && id !== '' ? id : undefined; +} + +/** The struct with its fields as they are, marked as `typeId` – or unmarked for `''`. */ +export function withItemType(value: StructValue, typeId: string): StructValue { + const { [ITEM_TYPE_KEY]: _dropped, ...fields } = value; + return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields; +} + +/** The value as a run holds it: every item-type mark taken out, at any depth. */ +export function withoutItemTypes(value: T): T { + if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T; + if (!isStruct(value) || !holdsItemType(value)) return value; + const out: Record = {}; + for (const [key, item] of Object.entries(value)) + if (key !== ITEM_TYPE_KEY) out[key] = withoutItemTypes(item); + return out as T; +} + +export function bagWithoutItemTypes(bag: PropertyBag): PropertyBag { + if (!Object.values(bag).some(holdsItemType)) return bag; + const out: PropertyBag = {}; + for (const [key, value] of Object.entries(bag)) out[key] = withoutItemTypes(value); + return out; +} + +function holdsItemType(value: PropertyValue | undefined): boolean { + if (isList(value)) return value.some(holdsItemType); + if (!isStruct(value)) return false; + return ITEM_TYPE_KEY in value || Object.values(value).some(holdsItemType); } export function tagged(kind: TaggedKind, value: number | string): TaggedItem { @@ -75,6 +118,7 @@ export function textOf(value: PropertyValue | undefined): string { if (tagOf(value) !== undefined) return textOf(untag(value)); if (isStruct(value)) return Object.entries(value) + .filter(([field]) => field !== ITEM_TYPE_KEY) .map(([field, item]) => `${field}: ${textOf(item)}`) .join(', '); return String(value); diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index b2482cfd..b401160f 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,6 +1,8 @@ import type { GraphLookup } from '../graph/lookup'; import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine'; import { resolveAssetProps } from '../properties/resolve'; +import { bagWithoutItemTypes } from './Properties'; +import { typeFields } from './structTypes'; import { findAssetOf, type Project } from './Project'; /** @@ -15,7 +17,7 @@ export function graphLookup(project: Project): GraphLookup { return { name: asset.name, graph: asset.graph, - defaults: resolveAssetProps(project, asset), + defaults: bagWithoutItemTypes(resolveAssetProps(project, asset)), kept: asset.keptVariables ?? [], }; }, @@ -30,5 +32,9 @@ 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; + }, }; } diff --git a/src/core/model/structTypes.ts b/src/core/model/structTypes.ts index c829ce1f..e1081159 100644 --- a/src/core/model/structTypes.ts +++ b/src/core/model/structTypes.ts @@ -3,6 +3,9 @@ * of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such * copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset * changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own. + * + * Items declare nothing, so a struct item carries its type as a mark (#254): every item of a typed + * list, and any struct item given one in a mixed list. The same sync keeps marked items in shape. */ import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph'; import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested'; @@ -16,11 +19,22 @@ import { type StructFieldDef, structOf, } from '../properties/PropertySchema'; -import { valueAt, withValueAt } from '../properties/valuePath'; -import type { Recorder } from '../util/recorder'; +import { valueAt, type ValuePath, withValueAt } from '../properties/valuePath'; +import { Recorder } from '../util/recorder'; import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset'; import { overrideSites } from './instance'; -import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties'; +import { + type FieldValue, + isList, + isStruct, + ITEM_TYPE_KEY, + itemTypeOf, + type PropertyValue, + type StructValue, + tagOf, + textOf, + withItemType, +} from './Properties'; import { findAssetOf, type Project } from './Project'; export const PACK_STRUCT = 'PackStruct'; @@ -50,7 +64,8 @@ export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined { fields: def.fields, ...(def.structType ? { structType: def.structType } : {}), }; - if (def.kind === 'list') return { key: def.key, kind: 'list' }; + if (def.kind === 'list') + return { key: def.key, kind: 'list', ...(def.itemType ? { itemType: def.itemType } : {}) }; return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined; } @@ -144,11 +159,12 @@ interface Holder { write(value: PropertyValue): void; } -function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] { - const { owner, def } = site; - const holders: Holder[] = [ - { read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) }, - ]; +function holdersOf( + project: Project, + { owner, def }: { owner: VariableOwnerAsset; def: PropertyDef }, + rec: Recorder, +): Holder[] { + const holders: Holder[] = [{ read: () => def.default, write: (value) => rec.set(def, 'default', value) }]; if (owner.props[def.key] !== undefined) holders.push({ read: () => owner.props[def.key], @@ -183,10 +199,97 @@ export function shapedStruct( } else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field); else shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field); + if (field.kind === 'list' && field.itemType) + shaped[field.key] = markedItems(shaped[field.key], field.itemType); } return shaped; } +/** A new item of the type (#254): the type's defaults, or what `value` has of its fields, marked. */ +export function typedItem(project: Project, type: StructAsset, value?: PropertyValue): StructValue { + return withItemType(shapedStruct(project, typeFields(type), value, typeDefault(type)), type.id); +} + +/** The struct items of a typed list marked with its type; anything else is left as it is. */ +function markedItems(value: PropertyValue | undefined, typeId: string): PropertyValue { + if (!isList(value)) return []; + return value.map((item) => + isStruct(item) && tagOf(item) === undefined && itemTypeOf(item) !== typeId + ? withItemType(item, typeId) + : item, + ); +} + +/** A declared value with the items of every typed list in it marked – the declaration says which. */ +function declaredMarks( + def: PropertyDef | StructFieldDef, + value: PropertyValue | undefined, +): PropertyValue | undefined { + if (def.kind === 'list') return def.itemType ? markedItems(value, def.itemType) : value; + if (def.kind !== 'struct' || !isStruct(value)) return value; + const out: Record = { ...value }; + for (const field of def.fields) { + const next = declaredMarks(field, value[field.key]); + if (next !== undefined) out[field.key] = next; + } + return out; +} + +/** + * Every marked struct item inside `value` brought into the shape of its type, at any depth – what + * a typed struct variable gets from `shapedStruct`, for the items no declaration holds. A mark + * naming a type that is gone leaves the item as it is. + */ +function shapedItems(project: Project, value: PropertyValue, depth = 0): PropertyValue { + if (depth > MAX_DEPTH) return value; + if (isList(value)) return value.map((item) => shapedItems(project, item, depth + 1)); + if (!isStruct(value) || tagOf(value) !== undefined) return value; + const type = structAssetOf(project, itemTypeOf(value)); + const shaped = type ? typedItem(project, type, value) : value; + const out: Record = {}; + for (const [key, item] of Object.entries(shaped)) + out[key] = key === ITEM_TYPE_KEY ? item : shapedItems(project, item, depth + 1); + return out; +} + +/** A rename reaching the marked items: each one of a type holding the renamed field, at any depth. */ +function renamedItems(project: Project, value: PropertyValue, rename: FieldRename, depth = 0): PropertyValue { + if (depth > MAX_DEPTH) return value; + if (isList(value)) return value.map((item) => renamedItems(project, item, rename, depth + 1)); + if (!isStruct(value) || tagOf(value) !== undefined) return value; + let next: PropertyValue = value; + const typeId = itemTypeOf(value); + if (typeId) + for (const inside of typePaths(project, typeId, rename.structType)) { + const path = [...inside, ...rename.path]; + const at = valueAt(next, path); + if (isStruct(at) && rename.from in at) + next = withValueAt(next, path, renamedKey(at, rename.from, rename.to)) ?? next; + } + const out: Record = {}; + for (const [key, item] of Object.entries(next as StructValue)) + out[key] = key === ITEM_TYPE_KEY ? item : renamedItems(project, item, rename, depth + 1); + return out; +} + +/** Every variable of every asset declaring some, with where its value is kept. */ +function everyVariable(project: Project): { owner: VariableOwnerAsset; def: PropertyDef }[] { + return project.assets.flatMap((owner) => + isVariableOwner(owner) ? owner.variables.map((def) => ({ owner, def })) : [], + ); +} + +/** Every value kept for a variable – what an edit of its declaration has to fit. */ +export function variableValues( + project: Project, + owner: VariableOwnerAsset, + def: PropertyDef, +): PropertyValue[] { + return holdersOf(project, { owner, def }, new Recorder()) + .map((holder) => holder.read()) + .filter((value) => value !== undefined); +} + /** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */ function withFieldsAt( fields: readonly StructFieldDef[], @@ -275,6 +378,11 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!); } } + for (const variable of everyVariable(project)) + for (const holder of holdersOf(project, variable, rec)) { + const value = holder.read(); + if (value !== undefined) holder.write(renamedItems(project, value, rename)); + } if (rename.path.length > 0) return; for (const { graph, node } of typedNodes(project)) { if (nodeStructType(node) !== rename.structType) continue; @@ -357,6 +465,11 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR holder.write(withValueAt(value, site.path, next) ?? next); } } + for (const variable of everyVariable(project)) + for (const holder of holdersOf(project, variable, rec)) { + const value = declaredMarks(variable.def, holder.read()); + if (value !== undefined) holder.write(shapedItems(project, value)); + } for (const { graph, node } of typedNodes(project)) { const type = structAssetOf(project, nodeStructType(node)); if (type) syncNodePorts(graph, node, typeFields(type), rec); @@ -366,9 +479,94 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR } /** How many places name the type – what deleting it would cut loose. */ -export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } { +export function structTypeUsages( + project: Project, + typeId: string, +): { places: number; lists: number; nodes: number } { + const lists = everyVariable(project).flatMap(({ def }) => typedListsIn(def)); return { places: typedSites(project).filter((site) => site.structType === typeId).length, + lists: lists.filter((id) => id === typeId).length, nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length, }; } + +/** The item types of the typed lists a declaration holds; a typed struct's are its type's. */ +function typedListsIn(def: PropertyDef | StructFieldDef): string[] { + if (def.kind === 'list') return def.itemType ? [def.itemType] : []; + if (def.kind !== 'struct' || def.structType) return []; + return def.fields.flatMap(typedListsIn); +} + +/** + * The declaration of the list at `path` inside a variable: the variable itself for none, else the + * list field the struct keys lead to. Undefined where there is no declared list. + */ +export function listDeclAt(def: PropertyDef, path: readonly string[]): { itemType?: string } | undefined { + if (path.length === 0) return def.kind === 'list' ? def : undefined; + if (def.kind !== 'struct') return undefined; + const at = structAt(def, path.slice(0, -1)); + const field = at?.fields.find((f) => f.key === path.at(-1)); + return field?.kind === 'list' ? field : undefined; +} + +/** + * The item type of the list at `path` inside a variable's value, wherever the path runs: through + * declared struct fields, list items and marked struct items alike. A list inside a typed item has + * the item type its type declares for that field. + */ +export function listItemTypeAt( + project: Project, + def: PropertyDef, + value: PropertyValue | undefined, + path: ValuePath, +): string | undefined { + let decl: { fields?: readonly StructFieldDef[]; itemType?: string } = + def.kind === 'struct' ? { fields: def.fields } : def.kind === 'list' ? { itemType: def.itemType } : {}; + let at = value; + for (const step of path) { + at = valueAt(at, [step]); + const field = typeof step === 'string' ? decl.fields?.find((f) => f.key === step) : undefined; + if (field?.kind === 'list') decl = { itemType: field.itemType }; + else if (field?.kind === 'struct') decl = { fields: field.fields }; + else { + const type = structAssetOf( + project, + (typeof step === 'number' ? decl.itemType : undefined) ?? itemTypeOf(at), + ); + decl = type ? { fields: typeFields(type) } : {}; + } + } + return decl.itemType; +} + +/** Names the item type of the list at `path` (see `listDeclAt`); `''` makes it mixed again. */ +export function setListItemType( + def: PropertyDef, + path: readonly string[], + typeId: string, + rec: Recorder, +): void { + if (path.length === 0) { + if (def.kind !== 'list') return; + if (typeId) rec.set(def, 'itemType', typeId); + else rec.delete(def, 'itemType'); + return; + } + if (def.kind !== 'struct') return; + const last = path.at(-1); + const retyped = (fields: readonly StructFieldDef[]): StructFieldDef[] => + fields.map((f) => { + if (f.key !== last || f.kind !== 'list') return f; + const { itemType: _dropped, ...rest } = f; + return typeId ? { ...rest, itemType: typeId } : rest; + }); + const parent = path.slice(0, -1); + rec.set( + def, + 'fields', + parent.length === 0 + ? retyped(def.fields) + : withFieldsAt(def.fields, parent, (f) => ({ ...f, fields: retyped(f.fields) })), + ); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index ea297e50..9340fc28 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -163,9 +163,10 @@ export type PropertyDef = | FlatDef /** * A list declares nothing about its items: each one carries its own kind (see `listItems.ts`), - * and the graph types a list as a list, so one `list` port fits them all. + * and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes + * every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`). */ - | { key: string; label: string; kind: 'list'; default: ListValue } + | { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields @@ -196,10 +197,14 @@ export interface PrimitiveFieldDef { kind: FieldKind; } -/** A list held by a struct field. Its items declare nothing either, and may nest like any list. */ +/** + * A list held by a struct field. Its items declare nothing either, and may nest like any list – + * unless `itemType` names the Struct asset every item is (#254), as on a list variable. + */ export interface ListFieldDef { key: string; kind: 'list'; + itemType?: string; } /** A struct held by a struct field declares its fields like a struct variable does (#239). */ diff --git a/src/core/properties/listItems.ts b/src/core/properties/listItems.ts index 067f9071..a8424ca0 100644 --- a/src/core/properties/listItems.ts +++ b/src/core/properties/listItems.ts @@ -7,6 +7,7 @@ * through the same coercion again, so nothing deeper is dropped. */ import { + ITEM_TYPE_KEY, isList, isStruct, type ListValue, @@ -65,5 +66,7 @@ export function structItemOf(value: PropertyValue | undefined): StructValue { /** The shape a struct item has right now: its keys in order, each with the kind of its value. */ export function structItemFields(item: PropertyValue | undefined): { key: string; kind: NestedKind }[] { if (!isStruct(item) || tagOf(item) !== undefined) return []; - return Object.entries(item).map(([key, value]) => ({ key, kind: itemKindOf(value) })); + return Object.entries(item) + .filter(([key]) => key !== ITEM_TYPE_KEY) + .map(([key, value]) => ({ key, kind: itemKindOf(value) })); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index ec07f1aa..7318d05a 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -393,6 +393,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] { const def = variableDef(key, kind, undefined, fields); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); if (def.kind === 'struct') readStructType(def, v.structType); + if (def.kind === 'list') readItemType(def, v.itemType); defs.push(def); } return defs; @@ -416,7 +417,11 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] { function readField(key: string, f: Record): StructFieldDef | null { const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value)); - if (f.kind === 'list') return { key, kind: 'list' }; + if (f.kind === 'list') { + const field: StructFieldDef = { key, kind: 'list' }; + readItemType(field, f.itemType); + return field; + } if (f.kind === 'struct') { const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) }; readStructType(field, f.structType); @@ -430,6 +435,11 @@ function readStructType(def: { structType?: string }, raw: unknown): void { if (typeof raw === 'string' && raw !== '') def.structType = raw; } +/** The same for a typed list (#254): gone, it is a mixed list whose items keep their marks. */ +function readItemType(def: { itemType?: string }, raw: unknown): void { + if (typeof raw === 'string' && raw !== '') def.itemType = raw; +} + function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] { const ports: DeclaredPort[] = []; const seen = new Set(fixed); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index a8be2945..c682abde 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -134,14 +134,16 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const animations = picture ? animationsUsing(state.project, ids).length : 0; const tiles = picture ? tilesUsing(state.project, ids).length : 0; const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 }; - const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, nodes: 0 }; + const typed = + asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 }; + const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : ''; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` : eased.keys > 0 || eased.nodes > 0 ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` - : typed.places > 0 || typed.nodes > 0 - ? `${typed.places} struct(s) and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.` + : typed.places > 0 || typed.lists > 0 || typed.nodes > 0 + ? `${typed.places} struct(s)${lists} and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.` : references > 0 || animations > 0 || tiles > 0 ? wornDetail(references, animations, tiles, asset.type) : usages === 0 diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index e7705fa7..bb8c071f 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -106,6 +106,9 @@ export function documentHooks({ state, editor }: HookContext) { removeAt: (where: ListPlace, title: string, index: number) => listIn(where, title)?.removeAt(index) ?? null, moreAt: (where: ListPlace, title: string) => listIn(where, title)?.moreAt() ?? null, + /** The Type picker of a struct item in a mixed list, or of a struct field inside it (#254). */ + typeAt: (where: ListPlace, title: string, index: number, field = '') => + listIn(where, title)?.typeAt(index, field) ?? null, /** The head of a list: its "+ Item" and – where it has one – its "Open". */ headAt: (where: ListPlace, title: string) => { const head = listIn(where, title)?.head; diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index aa31c448..e295b060 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -28,7 +28,9 @@ import { type VariablesAsset, } from '../../core/model/Asset'; import { + MakeListItemTypeCommand, MakeStructTypeCommand, + SetListItemTypeCommand, SetStructTypeCommand, WithStructTypes, } from '../../core/commands/structTypeCommands'; @@ -90,7 +92,9 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe .flatMap((def): string[] => def.kind === 'struct' ? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)] - : [], + : def.kind === 'list' + ? [structAssetOf(project, def.itemType)?.name ?? ''] + : [], ) .join(','); } @@ -98,8 +102,14 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe export function defSignature(def: PropertyDef | StructFieldDef): string { // Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252). const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : ''; - // A type decides which of those rows may be changed, and names itself in a row (#253). - const type = def.kind === 'struct' && def.structType ? `@${def.structType}` : ''; + // A type decides which of those rows may be changed, and names itself in a row (#253) – so does + // the item type of a list (#254). + const type = + def.kind === 'struct' && def.structType + ? `@${def.structType}` + : def.kind === 'list' && def.itemType + ? `@${def.itemType}` + : ''; return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`; } @@ -311,6 +321,26 @@ function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: strin ]; return entries; }, + listMenu: (path, itemType) => { + const current = structAssetOf(project(), itemType); + const target = { ownerId: owner.id, key, path }; + // A list starts empty, so unlike a field a list may hold the type it sits in. + const types = project().assets.filter((a) => a.type === 'struct'); + return [ + { label: 'Mixed', current: !current, onSelect: () => run(new SetListItemTypeCommand(target, '')) }, + ...types.map((type) => ({ + label: type.name, + current: type.id === current?.id, + onSelect: () => run(new SetListItemTypeCommand(target, type.id)), + })), + { separator: true }, + { + label: 'Make struct type', + disabled: current !== undefined, + onSelect: () => run(new MakeListItemTypeCommand(target)), + }, + ]; + }, }; } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 5093dccc..9181138a 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -317,14 +317,10 @@ export function withBelow(row: Widget, field: Field, depth: number): Widget { return stack; } -/** - * A widget `depth` indent steps in, as far as a struct field of that depth. A list's items go one - * step less: their index column already reads as the step in. - */ +/** A widget `depth` indent steps in, as far as a struct field of that depth. */ export function indentedBy(widget: Widget, depth: number): HStack { - const steps = widget instanceof InlineList ? Math.max(0, depth - 1) : depth; const line = new HStack({ gap: 0 }); - if (steps > 0) line.add(new Spacer(steps * (FIELD_INDENT + Theme.gap))); + if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap))); line.add(widget, 1); return line; } diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 765c239c..971abce2 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -65,6 +65,8 @@ export interface StructTyping { /** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */ typeName: (structType: string | undefined) => string | null; menu: (path: readonly string[], structType: string | undefined) => MenuEntry[]; + /** The item type of a list (#254); `path` leads to the list field, none for a list variable. */ + listMenu: (path: readonly string[], itemType: string | undefined) => MenuEntry[]; } export interface VariableOptions { @@ -105,6 +107,8 @@ export function buildVariable( if (edit) head.add(removeButton(edit.remove)); const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); + if (def.kind === 'list' && typing) + host.body.add(itemsRow(1, typing.typeName(def.itemType), () => typing.listMenu([], def.itemType))); if (below) host.body.add(indentedBy(below, 1)); if (def.kind === 'struct') { @@ -190,6 +194,14 @@ function structFieldRows( setters.set(field.key, addValue(row, sub)); if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); + if (field.kind === 'list' && typing) { + const inner = [...path, field.key]; + host.body.add( + itemsRow(path.length + 2, typing.typeName(field.itemType), () => + typing.listMenu(inner, field.itemType), + ), + ); + } if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2)); } return (next) => { @@ -205,9 +217,18 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable /** Which Struct asset a struct follows, as a row of its own under the struct's head. */ function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack { - const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' }); + return pickerRow(depth, 'Type', typeName ?? 'Own fields', menu); +} + +/** Which Struct asset every item of a list is (#254), as a row of its own under the list's head. */ +function itemsRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack { + return pickerRow(depth, 'Items', typeName ?? 'Mixed', menu); +} + +function pickerRow(depth: number, label: string, text: string, menu: () => MenuEntry[]): HStack { + const picker = new Dropdown(text, { variant: 'ghost' }); picker.onClick = () => new Menu(menu()).openBelow(picker); - const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }); + const name = new Label(label, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }); const row = gridRow(depth, new Spacer(MARK_WIDTH), name); row.add(picker, 1); return row; diff --git a/src/editor/list/InlineList.ts b/src/editor/list/InlineList.ts index d6500d6c..8e1fa269 100644 --- a/src/editor/list/InlineList.ts +++ b/src/editor/list/InlineList.ts @@ -5,7 +5,9 @@ * step further in. * * A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a - * row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. + * row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. A typed list + * (#254) is the exception: its items are its type, fixed – and in a mixed list a struct item may pick + * a type of its own, which is a value edit again, since the item carries it. */ import { Button, @@ -13,6 +15,7 @@ import { HStack, Label, Menu, + type MenuEntry, ScrollContainer, Spacer, TextField, @@ -25,11 +28,16 @@ import { type FieldValue, isList, isStruct, + itemTypeOf, type ListValue, type PropertyValue, + type StructValue, textOf, untag, + withItemType, } from '../../core/model/Properties'; +import type { Project } from '../../core/model/Project'; +import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes'; import { nextFieldName } from '../../core/commands/naming'; import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; import { @@ -39,22 +47,23 @@ import { structItemFields, structItemOf, } from '../../core/properties/listItems'; -import { flatDef } from '../../core/properties/PropertySchema'; +import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema'; import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath'; import { addValue, createField, type FieldContext, FIELD_INDENT, + MARK_WIDTH, nameWidth, removeButton, } from '../inspector/fieldFactory'; -import { kindMenu, markPicker } from '../inspector/variableRows'; +import { kindMenu, markLabel, markPicker } from '../inspector/variableRows'; import { shownItems } from '../variables/target'; import type { ListAccess } from './slot'; -/** Two digits; a longer index widens its own label. */ -const INDEX_WIDTH = 16; +/** Room for three digits; a longer index widens its own label. */ +const INDEX_WIDTH = 28; /** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */ interface RowWidgets { @@ -64,6 +73,12 @@ interface RowWidgets { parts: Map; marks: Map; names: Map; + /** The kind each struct field is drawn as – the type's where it has one (#254). */ + kinds: Map; + /** The Type picker of the struct item (`''`) and of struct fields inside it. */ + types: Map; + /** A struct item drawn with its type's fields: no names to change, no + Field. */ + locked: boolean; } const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({ @@ -72,6 +87,9 @@ const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({ parts: new Map(), marks: new Map(), names: new Map(), + kinds: new Map(), + types: new Map(), + locked: false, }); const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => { @@ -102,9 +120,13 @@ export class ListHead extends HStack { this.add(this.count); this.add(new Spacer(), 1); this.addButton = new Dropdown('+ Item', { variant: 'ghost' }); - // Built on click, not once: which kinds fit depends on the list as it is then. - this.addButton.onClick = () => - new Menu(kindMenu(access()?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton); + // Built on click, not once: which kinds fit depends on the list as it is then. A typed list + // has one kind only, so there is nothing to pick. + this.addButton.onClick = () => { + const now = access(); + if (now?.itemType) add('struct'); + else new Menu(kindMenu(now?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton); + }; this.add(this.addButton); this.setCount(now?.value.length ?? 0); } @@ -149,10 +171,20 @@ export class InlineList extends VStack { return this.access()?.title ?? ''; } - /** Adds an item of `kind` at the end – the head's `+ Item`. */ + /** Adds an item of `kind` at the end – the head's `+ Item`; in a typed list one of its type. */ addItem(kind: NestedKind): void { const access = this.access(); - if (access) access.replace([...access.value, makeItem(kind, undefined)]); + if (!access) return; + const type = this.listType(access); + access.replace([...access.value, type ? typedItem(this.project, type) : makeItem(kind, undefined)]); + } + + private get project(): Project { + return this.ctx.project(); + } + + private listType(access: ListAccess) { + return structAssetOf(this.project, access.itemType); } /** Re-reads the list: new values go into the rows, a new structure builds them again. */ @@ -193,6 +225,11 @@ export class InlineList extends VStack { return centreOf(this.rowWidgets[index]?.names.get(field)); } + /** For the check scripts: the Type picker of a struct item, or of a struct field inside it. */ + typeAt(index: number, field = ''): { x: number; y: number } | null { + return centreOf(this.rowWidgets[index]?.types.get(field)); + } + /** For the check scripts: the "… N more" line, which opens the list in its tab. */ moreAt(): { x: number; y: number } | null { return centreOf(this.more ?? undefined); @@ -206,10 +243,28 @@ export class InlineList extends VStack { offers: NestedKind[]; rows: number; more: number; + itemType: string; + /** The type each item is marked with, `''` for none. */ + types: string[]; + /** Per drawn row: whether its fields are the type's, fixed. */ + locked: boolean[]; + /** Per drawn row: the kind each struct field is drawn as, by its dotted id. */ + fieldKinds: Record[]; } { const access = this.access(); const base = { rows: this.rowWidgets.length, more: 0 }; - if (!access) return { ...base, title: '', items: [], kinds: [], offers: [] }; + if (!access) + return { + ...base, + title: '', + items: [], + kinds: [], + offers: [], + itemType: '', + types: [], + locked: [], + fieldKinds: [], + }; return { ...base, // Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen. @@ -218,6 +273,10 @@ export class InlineList extends VStack { items: access.value.map((item) => textOf(item)), kinds: access.value.map(itemKindOf), offers: [...access.kinds], + itemType: access.itemType ?? '', + types: access.value.map((item) => itemTypeOf(item) ?? ''), + locked: this.rowWidgets.map((row) => row.locked), + fieldKinds: this.rowWidgets.map((row) => Object.fromEntries(row.kinds)), }; } @@ -225,8 +284,14 @@ export class InlineList extends VStack { private signatureOf(access: ListAccess | null): string { if (!access) return ''; const { shown } = shownItems(access.value.length, this.options.limit); - const shape = access.value.slice(0, shown).map(shapeOf).join(','); - return `${access.title}|${shape}|${access.value.length}`; + // A type's name is on screen too, so a renamed or deleted type has to rebuild the rows. + const typeName = (id: string | undefined) => + id ? `@${structAssetOf(this.project, id)?.name ?? ''}` : ''; + const shape = access.value + .slice(0, shown) + .map((item) => shapeOf(item, typeName)) + .join(','); + return `${access.title}|${typeName(access.itemType)}|${shape}|${access.value.length}`; } private scrollArea(): ScrollContainer | null { @@ -276,7 +341,8 @@ export class InlineList extends VStack { label.minWidth = INDEX_WIDTH; if (tall) label.minHeight = Theme.rowHeight; row.add(label); - const mark = this.kindPicker(access, [index], kind); + // A typed list's items are its type: there is no other kind to turn one into. + const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind); if (tall) mark.minHeight = Theme.rowHeight; row.add(mark); let below: Widget | null = null; @@ -339,12 +405,20 @@ export class InlineList extends VStack { ); } - /** A struct item carries its own shape, so its fields are named, typed and removed right here. */ + /** + * A struct item carries its own shape, so its fields are named, typed and removed right here – + * unless it has a type (#254): of the list, or picked for the item. Then its fields are the type's. + */ private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void { const stack = new VStack({ gap: Theme.gap }); const widgets = rowWidgets(mark, stack); const leaves: Leaf[] = []; - this.addStructFields(access, stack, [index], 0, widgets, leaves); + const item = valueAt(access.value, [index]); + const typed = this.listType(access) !== undefined; + if (!typed && access.typable) stack.add(this.typeLine(access, [index], 0, widgets, '')); + const declared = this.declaredFields(this.listType(access)?.id ?? itemTypeOf(item)); + widgets.locked = declared !== undefined; + this.addStructFields(access, stack, [index], 0, widgets, leaves, declared); row.add(stack, 1); this.setters.push((next) => { for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? ''); @@ -352,10 +426,19 @@ export class InlineList extends VStack { this.rowWidgets.push(widgets); } + /** The fields of a Struct asset, or none for a struct of its own fields. */ + private declaredFields(typeId: string | undefined): readonly StructFieldDef[] | undefined { + const type = structAssetOf(this.project, typeId); + return type ? typeFields(type) : undefined; + } + /** * The fields of the struct at `path`, a line each; a field that is a struct again gets its own * lines below, indented one step further (#239), and so does the item list of a list field. Check * scripts find a nested field by its keys joined with dots (`loot.count`). + * + * `declared` are the type's fields when the struct has one: its shape is fixed then, and a field + * is the kind the type says – an item keeps an asset id as a plain text, which alone reads as one. */ private addStructFields( access: ListAccess, @@ -364,17 +447,24 @@ export class InlineList extends VStack { depth: number, widgets: RowWidgets, leaves: Leaf[], + declared: readonly StructFieldDef[] | undefined, ): void { + const locked = declared !== undefined; // A field value edit does not rebuild the row, so every button reads the struct as it is now. const now = () => structItemOf(valueAt(access.value, path)); - for (const field of structItemFields(valueAt(access.value, path))) { + for (const own of structItemFields(valueAt(access.value, path))) { + const decl = declared?.find((d) => d.key === own.key); + const field = { key: own.key, kind: declaredKind(own.kind, decl) }; const fieldPath = [...path, field.key]; const id = fieldPath.slice(1).join('.'); const line = indentedLine(depth); - const fieldMark = this.kindPicker(access, fieldPath, field.kind); - const name = nameField(field.key, depth, (to) => { - if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to)); - }); + // The fields of a typed struct are the type's: its values change here, its shape on the asset. + const fieldMark = locked ? markLabel(field.kind) : this.kindPicker(access, fieldPath, field.kind); + const name = locked + ? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }) + : nameField(field.key, depth, (to) => { + if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to)); + }); line.add(fieldMark); line.add(name); let below: Widget | null = null; @@ -391,19 +481,73 @@ export class InlineList extends VStack { widgets.parts.set(id, sub.widget); } widgets.marks.set(id, fieldMark); + widgets.kinds.set(id, field.kind); widgets.names.set(id, name); - line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key)))); + if (!locked) line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key)))); stack.add(line); - // Items under the field's own line: their index column is the step in. - if (below) stack.add(indented(below, depth * (FIELD_INDENT + Theme.gap))); - if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves); + // Items under the field's own line, one step further in than the field. + if (below) stack.add(indented(below, (depth + 1) * (FIELD_INDENT + Theme.gap))); + if (field.kind === 'struct') { + // A struct in a struct of its own fields may take a type as well; inside a type it is the type's. + if (!locked && access.typable) stack.add(this.typeLine(access, fieldPath, depth + 1, widgets, id)); + const inner = + decl?.kind === 'struct' + ? (this.declaredFields(decl.structType) ?? decl.fields) + : locked + ? [] + : this.declaredFields(itemTypeOf(valueAt(access.value, fieldPath))); + this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves, inner); + } } + if (locked) return; const foot = indentedLine(depth); foot.add(addFieldMenu((kind) => this.addField(access, path, kind))); foot.add(new Spacer(), 1); stack.add(foot); } + /** + * Which Struct asset the struct at `path` follows (#254): its own fields, or a type – picking one + * brings the struct into that shape, keeping what it has of the type's fields. + */ + private typeLine( + access: ListAccess, + path: ValuePath, + depth: number, + widgets: RowWidgets, + id: string, + ): HStack { + const current = structAssetOf(this.project, itemTypeOf(valueAt(access.value, path))); + const picker = new Dropdown(current?.name ?? 'Own fields', { variant: 'ghost' }); + const struct = (): StructValue => structItemOf(valueAt(access.value, path)); + picker.onClick = () => { + const entries: MenuEntry[] = [ + { + label: 'Own fields', + current: !current, + onSelect: () => this.replaceAt(access, path, withItemType(struct(), '')), + }, + ...this.project.assets + .filter((a) => a.type === 'struct') + .map((type) => ({ + label: type.name, + current: type.id === current?.id, + onSelect: () => { + const asset = structAssetOf(this.project, type.id); + if (asset) this.replaceAt(access, path, typedItem(this.project, asset, struct())); + }, + })), + ]; + new Menu(entries).openBelow(picker); + }; + widgets.types.set(id, picker); + const line = indentedLine(depth); + line.add(new Spacer(MARK_WIDTH)); + line.add(new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })); + line.add(picker, 1); + return line; + } + private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void { const bundle = valueAt(access.value, path); const key = nextFieldName(structItemFields(bundle)); @@ -417,6 +561,16 @@ export class InlineList extends VStack { } } +/** + * The kind a field is drawn as: the declared one for a value that is not a list or a struct – a + * struct or list stays what its value is until the type brings it into shape. + */ +function declaredKind(own: NestedKind, decl: StructFieldDef | undefined): NestedKind { + if (!decl || own === 'list' || own === 'struct' || decl.kind === 'list' || decl.kind === 'struct') + return own; + return decl.kind; +} + /** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */ interface Leaf { within: ValuePath; @@ -465,11 +619,14 @@ const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem return next; }; -/** An item's kind, and for a struct the shape of every field below it – what its rows are built from. */ -function shapeOf(item: PropertyValue | undefined): string { +/** + * An item's kind, and for a struct its type and the shape of every field below it – what its rows + * are built from. + */ +function shapeOf(item: PropertyValue | undefined, typeName: (id: string | undefined) => string): string { const kind = itemKindOf(item); if (kind !== 'struct' || !isStruct(item)) return kind; - return `struct(${structItemFields(item) - .map((f) => `${f.key}:${shapeOf(item[f.key])}`) + return `struct${typeName(itemTypeOf(item))}(${structItemFields(item) + .map((f) => `${f.key}:${shapeOf(item[f.key], typeName)}`) .join(',')})`; } diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index 8db35752..87920bd2 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -9,6 +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 type { ObjectRef } from '../../core/model/instance'; import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; @@ -56,6 +57,10 @@ export interface ListAccess { sub(path: ValuePath): ListAccess | null; /** A whole new list in one command – adding, deleting, and the bulk text field. */ replace(next: ListValue): void; + /** The Struct asset every item is (#254), declared on the list or on the typed item it sits in. */ + readonly itemType: string | undefined; + /** Whether a struct item may pick a type of its own – not in a node's list, which a run reads as it is. */ + readonly typable: boolean; } /** The property a slot's `key` names, and how the section that owns it reads and writes it. */ @@ -86,6 +91,7 @@ const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? /** The owner's own list, or – with a `path` – a list nested inside its value. */ function listIn(state: EditorState, owner: Owner, slot: ListSlot): ListAccess | null { const path = slot.path ?? []; + const itemType = () => listItemTypeAt(state.project, owner.def, owner.current(), path); const nested: Nested = { open: slot.kind === 'node' @@ -95,7 +101,7 @@ function listIn(state: EditorState, owner: Owner, slot: ListSlot }; if (path.length === 0) return owner.def.kind === 'list' - ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested) + ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested, itemType) : null; // A struct is brought into its declared shape first, so a declared list not stored yet is there. const def = owner.def; @@ -126,6 +132,7 @@ function listIn(state: EditorState, owner: Owner, slot: ListSlot commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)), }, nested, + itemType, ); } @@ -147,6 +154,7 @@ function access( current: () => ListValue, spec: Omit, 'toModel'>, nested: Nested, + itemType: () => string | undefined, ): ListAccess { // The value keeps the kind it has: a color item written from its field stays a color. const handlersAt = (path: ValuePath): FieldHandlers => @@ -174,6 +182,10 @@ function access( // No coercion here: every command behind `commit` runs the value through `coerceValue`. // A preview does not, which is why `toModel` above has to. replace: (next) => state.execute(spec.commit(next)), + get itemType() { + return itemType(); + }, + typable: nested.open !== undefined, }; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index ac2de9e3..e2553ba0 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -2,7 +2,7 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from import { newId } from '../core/ids'; import type { Asset, TilemapAsset } from '../core/model/Asset'; import { withoutObjects } from '../core/graph/liveObjects'; -import { type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties'; +import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { treeOrder } from '../core/model/hierarchy'; @@ -249,7 +249,7 @@ export class World { entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; - const resolved = resolveSceneObjectProps(this.project, asset, object); + const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; // The scene only ever got the objects' empty places, so a value that differs by them alone stays. for (const [key, value] of Object.entries(resolved)) @@ -320,7 +320,7 @@ export class World { if (!entity || !asset) continue; const p = object.position; const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; - const base = resolveSceneObjectProps(this.project, asset, object); + const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); // What a run holds of an object stays in the run; the scene gets the empty one. const drifted = Object.keys(entity.props).filter( (k) => diff --git a/tests/core/list-of-structs.test.ts b/tests/core/list-of-structs.test.ts index 8c7611e9..9b92f6a4 100644 --- a/tests/core/list-of-structs.test.ts +++ b/tests/core/list-of-structs.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { type GraphLookup, NO_LOOKUP } from '../../src/core/graph/lookup'; import { AddItem } from '../../src/core/graph/nodes/data/AddItem'; import { Fill } from '../../src/core/graph/nodes/data/Fill'; import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf'; @@ -13,10 +14,18 @@ const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }) /** A `list` variable holding struct items, the way a seeder writes it. */ const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never); -/** The node interface only needs `run`; nothing here reads the context. */ +/** The node interface only needs `run`; nothing here reads the context, and no node has a setting. */ const run = (def: { run?: unknown }, inputs: Record) => - (def.run as (ctx: unknown, i: Record) => { outputs: Record })({}, inputs) - .outputs; + ( + def.run as ( + ctx: unknown, + i: Record, + params: Record, + def: unknown, + entry: string, + lookup: GraphLookup, + ) => { outputs: Record } + )({}, inputs, {}, def, '', NO_LOOKUP).outputs; describe('a list whose items are structs', () => { /** Each item keeps the fields it carries: there is no declaration left to fill or drop any. */ diff --git a/tests/core/struct-types.test.ts b/tests/core/struct-types.test.ts index c2b2c50a..2b7ae626 100644 --- a/tests/core/struct-types.test.ts +++ b/tests/core/struct-types.test.ts @@ -234,7 +234,7 @@ describe('a struct typed by a Struct asset', () => { const f = makeSpriteScene(); const loot = lootType(f); const chest = dropper(f, loot); - expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 1, nodes: 0 }); + expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 1, lists: 0, nodes: 0 }); f.stack.execute(new DeleteAssetCommand(loot.id)); expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Amount']); expect(chest.props.drop).toEqual({ Name: 'Coin', Amount: 1 }); diff --git a/tests/core/typed-lists.test.ts b/tests/core/typed-lists.test.ts new file mode 100644 index 00000000..82f548ae --- /dev/null +++ b/tests/core/typed-lists.test.ts @@ -0,0 +1,360 @@ +/** + * Typed lists (#254): a list naming a Struct asset holds items of that type only, each marked with + * it, kept in its shape as the type changes. A struct item in a mixed list may carry a type of its + * own. A run never sees the mark, and `Add item` with a type takes a fitting struct only. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + MakeListItemTypeCommand, + SetListItemTypeCommand, + SetStructTypeCommand, + WithStructTypes, +} from '../../src/core/commands/structTypeCommands'; +import { + AddStructFieldCommand, + AddVariableCommand, + RemoveVariableCommand, + RenameVariableCommand, +} from '../../src/core/commands/variableCommands'; +import type { Command } from '../../src/core/commands/Command'; +import { declaredVariables } from '../../src/core/graph/sharedState'; +import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { ITEM_TYPE_KEY, withoutItemTypes } from '../../src/core/model/Properties'; +import { listItemTypeAt, structTypeUsages, typedItem } from '../../src/core/model/structTypes'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { + fakeContext, + makeBehavior, + makeSpriteScene, + makeVariables, + place, + 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 with a list variable `loots` holding `items`. */ +function chestWith(f: SceneFixture, items: unknown[]): BehaviorAsset { + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'loots', 'list')); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'loots', items as never)); + return chest; +} + +const loots = (chest: BehaviorAsset) => chest.variables.find((v) => v.key === 'loots')!; +const target = (chest: BehaviorAsset, path: string[] = []) => ({ ownerId: chest.id, key: 'loots', path }); +const onType = ( + f: SceneFixture, + command: Command, + rename?: ConstructorParameters[1], +) => f.stack.execute(new WithStructTypes(command, rename)); +const rename = (loot: StructAsset, from: string, to: string) => ({ structType: loot.id, path: [], from, to }); + +describe('a list typed by a Struct asset', () => { + it('brings every item into the type, marked with it, keeping what fits', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem', Old: 4 }, {}]); + f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id)); + expect(loots(chest)).toMatchObject({ itemType: loot.id }); + expect(chest.props.loots).toEqual([ + { Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }, + { Name: 'Coin', Amount: 1, [ITEM_TYPE_KEY]: loot.id }, + ]); + f.stack.undo(); + expect(loots(chest)).not.toHaveProperty('itemType'); + expect(chest.props.loots).toEqual([{ Name: 'Gem', Old: 4 }, {}]); + }); + + it('refuses a list holding anything but structs, and changes nothing', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem' }, 3, 'x']); + expect(() => f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id))).toThrow( + 'loots holds 2 items that are no struct – only a list of structs can take a type', + ); + expect(loots(chest)).not.toHaveProperty('itemType'); + expect(chest.props.loots).toEqual([{ Name: 'Gem' }, 3, 'x']); + }); + + it('looks at the overrides too: a placed object holding a number refuses the type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem' }]); + const placed = place(f, chest.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, placed, 'loots', [{ Name: 'Ruby' }, 5])); + expect(() => f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id))).toThrow( + /1 item that is/, + ); + }); + + it('follows the type: a field added, renamed and removed reaches every item, the overrides too', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem', Amount: 7 }]); + const placed = place(f, chest.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, placed, 'loots', [{ Name: 'Ruby', Amount: 3 }])); + f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id)); + onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean')); + expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]); + onType(f, new RenameVariableCommand(loot.id, 'Amount', 'Count'), rename(loot, 'Amount', 'Count')); + expect(chest.props.loots).toEqual([{ Name: 'Gem', Count: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]); + expect(f.object(placed).overrides.loots).toEqual([ + { Name: 'Ruby', Count: 3, Rare: false, [ITEM_TYPE_KEY]: loot.id }, + ]); + onType(f, new RemoveVariableCommand(loot.id, 'Name')); + expect(chest.props.loots).toEqual([{ Count: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]); + f.stack.undo(); + f.stack.undo(); + expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]); + }); + + it('goes back to mixed with the items keeping their marks, so nothing is lost', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem' }]); + f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id)); + f.stack.execute(new SetListItemTypeCommand(target(chest), '')); + expect(loots(chest)).not.toHaveProperty('itemType'); + expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]); + expect(structTypeUsages(f.project, loot.id).lists).toBe(0); + }); + + it('types a list field inside a struct variable, and a list inside a typed item follows its type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'room', 'struct')); + f.stack.execute(new AddStructFieldCommand(chest.id, 'room', { key: 'loots', kind: 'list' })); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'room', { loots: [{ Name: 'Gem' }] })); + f.stack.execute(new SetListItemTypeCommand({ ownerId: chest.id, key: 'room', path: ['loots'] }, loot.id)); + expect(chest.props.room).toEqual({ loots: [{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }] }); + expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 0, lists: 1, nodes: 0 }); + // A Bag type whose `inside` is a list of Loot: a Bag item's `inside` is typed through it. + const bag = new CreateAssetCommand('struct', 'Bag'); + f.stack.execute(bag); + f.stack.execute(new AddVariableCommand(bag.asset.id, 'inside', 'list')); + f.stack.execute(new SetListItemTypeCommand({ ownerId: bag.asset.id, key: 'inside', path: [] }, loot.id)); + const holder = chestWith(f, [{ inside: [{ Name: 'Axe' }] }]); + f.stack.execute(new SetListItemTypeCommand(target(holder), bag.asset.id)); + expect(holder.props.loots).toEqual([ + { inside: [{ Name: 'Axe', Amount: 1, [ITEM_TYPE_KEY]: loot.id }], [ITEM_TYPE_KEY]: bag.asset.id }, + ]); + expect(listItemTypeAt(f.project, loots(holder), holder.props.loots, [0, 'inside'])).toBe(loot.id); + expect(listItemTypeAt(f.project, loots(holder), holder.props.loots, [])).toBe(bag.asset.id); + }); + + it('makes a type from its items: every field any item has, at the first value it has', () => { + const f = makeSpriteScene(); + const chest = chestWith(f, [ + { Name: 'Gem', Amount: 2 }, + { Name: 'Axe', Rare: true }, + ]); + const make = new MakeListItemTypeCommand(target(chest)); + f.stack.execute(make); + const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset; + expect(type).toMatchObject({ + type: 'struct', + name: 'loots', + props: { Name: 'Gem', Amount: 2, Rare: true }, + }); + expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual([ + 'Name:text', + 'Amount:number', + 'Rare:boolean', + ]); + expect(chest.props.loots).toEqual([ + { Name: 'Gem', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id }, + { Name: 'Axe', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id }, + ]); + f.stack.undo(); + expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false); + expect(chest.props.loots).toEqual([ + { Name: 'Gem', Amount: 2 }, + { Name: 'Axe', Rare: true }, + ]); + }); + + it('survives saving and loading, and a stale item in a file is brought in line on load', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem' }]); + f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id)); + const raw = serializeProject(f.project) as { assets: Record[] }; + const stored = raw.assets.find((a) => a.id === chest.id) as { + variables: Record[]; + props: Record; + }; + expect(stored.variables[0]).toMatchObject({ itemType: loot.id }); + stored.props.loots = [{ Name: 'Ruby', Stale: 1 }]; + const reloaded = deserializeProject(raw).assets.find((a) => a.id === chest.id) as BehaviorAsset; + expect(reloaded.props.loots).toEqual([{ Name: 'Ruby', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]); + }); +}); + +describe('a struct item with a type of its own, in a mixed list', () => { + it('follows its type while the other items are left alone', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [typedItem(f.project, loot, { Name: 'Gem' }), { Name: 'Own', x: 1 }, 5]); + onType(f, new RenameVariableCommand(loot.id, 'Name', 'Title'), rename(loot, 'Name', 'Title')); + expect(chest.props.loots).toEqual([ + { Title: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }, + { Name: 'Own', x: 1 }, + 5, + ]); + }); + + it('reaches a typed struct nested inside an item of its own fields', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ prize: { Name: 'Gem', [ITEM_TYPE_KEY]: loot.id } }]); + expect(chest.props.loots).toEqual([{ prize: { Name: 'Gem', [ITEM_TYPE_KEY]: loot.id } }]); + onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean')); + expect(chest.props.loots).toEqual([ + { prize: { Name: 'Gem', Amount: 1, Rare: false, [ITEM_TYPE_KEY]: loot.id } }, + ]); + }); + + it('is typed inside a typed struct variable too', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct')); + f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'more', kind: 'list' })); + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, ''), + ); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { more: [typedItem(f.project, loot)] })); + onType(f, new RemoveVariableCommand(loot.id, 'Amount')); + expect(chest.props.drop).toEqual({ more: [{ Name: 'Coin', [ITEM_TYPE_KEY]: loot.id }] }); + }); +}); + +describe('a run never sees the mark', () => { + it('is taken out of variables, behavior defaults and placed objects', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = chestWith(f, [{ Name: 'Gem' }]); + f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id)); + const shared = makeVariables(f, 'Globals', []); + f.stack.execute(new AddVariableCommand(shared.id, 'bag', 'list')); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'bag', [typedItem(f.project, loot)])); + expect(shared.props.bag).toEqual([{ Name: 'Coin', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]); + expect(declaredVariables(f.project)[shared.id]!.bag).toEqual([{ Name: 'Coin', Amount: 1 }]); + expect(graphLookup(f.project).behavior(chest.id)!.defaults.loots).toEqual([{ Name: 'Gem', Amount: 1 }]); + expect(withoutItemTypes({ a: [{ b: { [ITEM_TYPE_KEY]: 'x', c: 1 } }] })).toEqual({ + a: [{ b: { c: 1 } }], + }); + }); +}); + +describe('Add item with a type', () => { + const run = (f: SceneFixture, loot: StructAsset, value: unknown) => { + const ctx = fakeContext(); + const out = runDef( + 'AddItem', + ctx, + { list: [{ Name: 'Gem', Amount: 1 }], value: value as never, index: -1 }, + { structType: loot.id }, + 'exec', + graphLookup(f.project), + ).outputs; + return { result: out?.result, logs: ctx.logs }; + }; + + it('adds a struct with exactly the fields of the type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + expect(run(f, loot, { Amount: 3, Name: 'Axe' })).toEqual({ + result: [ + { Name: 'Gem', Amount: 1 }, + { Amount: 3, Name: 'Axe' }, + ], + logs: [], + }); + }); + + it('leaves the list as it was and says so for anything else', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const unchanged = [{ Name: 'Gem', Amount: 1 }]; + expect(run(f, loot, { Name: 'Axe' })).toEqual({ + result: unchanged, + logs: ['Add item: Name: Axe is no Loot – not added'], + }); + expect(run(f, loot, { Name: 'Axe', Amount: 1, Extra: 0 }).result).toEqual(unchanged); + expect(run(f, loot, 7)).toEqual({ result: unchanged, logs: ['Add item: 7 is no Loot – not added'] }); + }); + + it('checks nothing without a type, or with one that is gone', () => { + const f = makeSpriteScene(); + const out = runDef( + 'AddItem', + fakeContext(), + { list: [], value: 7, index: -1 }, + { structType: 'gone' }, + 'exec', + graphLookup(f.project), + ); + expect(out.outputs?.result).toEqual([7]); + }); +}); + +describe('Set item with a type', () => { + const run = (f: SceneFixture, loot: StructAsset, value: unknown, structType = loot.id) => { + const ctx = fakeContext(); + const out = runDef( + 'SetItem', + ctx, + { list: [{ Name: 'Gem', Amount: 1 }, 5], index: 0, value: value as never }, + { structType }, + 'exec', + graphLookup(f.project), + ).outputs; + return { result: out?.result, logs: ctx.logs }; + }; + + it('sets a struct with exactly the fields of the type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + expect(run(f, loot, { Amount: 3, Name: 'Axe' })).toEqual({ + result: [{ Amount: 3, Name: 'Axe' }, 5], + logs: [], + }); + }); + + it('leaves the list as it was and says so for anything else', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const unchanged = [{ Name: 'Gem', Amount: 1 }, 5]; + expect(run(f, loot, { Name: 'Axe' })).toEqual({ + result: unchanged, + logs: ['Set item: Name: Axe is no Loot – not set'], + }); + expect(run(f, loot, { Name: 'Axe', Amount: 1, Extra: 0 }).result).toEqual(unchanged); + expect(run(f, loot, 7)).toEqual({ result: unchanged, logs: ['Set item: 7 is no Loot – not set'] }); + }); + + it('checks nothing without a type, or with one that is gone', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + // Without a check the slot keeps its kind, as ever: a struct slot takes no number. + expect(run(f, loot, { Other: 1 }, '').result).toEqual([{ Other: 1 }, 5]); + expect(run(f, loot, { Other: 1 }, 'gone').logs).toEqual([]); + }); +}); diff --git a/tools/e2e/55-typed-lists.mjs b/tools/e2e/55-typed-lists.mjs new file mode 100644 index 00000000..25b7d50a --- /dev/null +++ b/tools/e2e/55-typed-lists.mjs @@ -0,0 +1,319 @@ +/** + * Typed lists (#254), with the real mouse: a list variable picks a Struct asset under its head and + * its items take the type; a list holding a number refuses and says so in the console; `+ Item` + * adds one of the type straight away; a struct item in a mixed list picks a type of its own; a + * rename on the asset reaches both; "Make struct type" turns a list's items into an asset. In a run + * `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. + * + * Everything it adds carries a `tl55` id or is an asset it made, and goes again at the end. + */ +import { inView, MAKE_OBJECT } from './lib/scaffold.mjs'; + +export async function typedLists(t) { + const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t; + const press_ = async (at, what) => { + if (!at) throw new Error(`nothing to click: ${what}`); + await click(at.x, at.y); + await sleep(150); + }; + /** A menu entry, always the last drawn: the panels behind a menu carry the same words. */ + const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label); + /** Every hit of a text, top to bottom. */ + const all = async (text) => + ((await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []).sort((a, b) => a.y - b.y); + const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`); + const chest = () => asset('tl55-chest'); + const variable = async (key) => (await chest()).variables.find((v) => v.key === key); + /** What the chest holds: its own value once one is set, else the declared default. */ + const value = async (key) => { + const now = await chest(); + return now.props[key] ?? now.variables.find((v) => v.key === key).default; + }; + /** Per drawn item of a list in the inspector: whether its fields are the type's, fixed. */ + const locked = async (key) => + ((await json("__gm.values.lists('inspector')")) ?? []).find((l) => l.title === list(key))?.locked ?? null; + const list = (key) => `Chest 55 · ${key}`; + const MARK = '$struct'; + const LOOT = 'tl55-loot'; + const DROP = 'tl55-drop'; + + const node = (id, type, x, y, params = {}, ports) => ({ + id, + type, + x, + y, + params, + ...(ports ? { ports } : {}), + }); + const edge = (id, from, fromPort, to, toPort) => ({ + id, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, + }); + const fields = [ + { key: 'Name', kind: 'text' }, + { key: 'Amount', kind: 'number' }, + ]; + // `loots` = two structs, `mixed` = a struct and a number, `bags` = structs of two shapes, `drops` = + // one item of a type with an asset field. The graph adds an item of `loots`, a packed Loot and a + // number to `loots`, each through a typed Add item, then tries the number in a typed Set item. + const graph = { + nodes: [ + node('tl55-start', 'OnStart', -400, 0), + node('tl55-vars', 'Variables', -400, 160, {}, [{ key: 'loots', kind: 'list' }]), + node('tl55-at', 'ItemAt', -200, 160, { index: 0 }), + node('tl55-add1', 'AddItem', 0, 160, { structType: LOOT }), + node('tl55-pack', 'PackStruct', -200, 320, { structType: LOOT, Name: 'Bow', Amount: 2 }, fields), + node('tl55-add2', 'AddItem', 200, 160, { structType: LOOT }), + node('tl55-num', 'Number', 0, 480, { value: 9 }), + node('tl55-add3', 'AddItem', 400, 160, { structType: LOOT }), + node('tl55-set', 'SetItem', 600, 160, { structType: LOOT, index: 0 }), + node('tl55-count', 'Count', 800, 160), + node('tl55-log', 'Log', 1000, 0), + ], + edges: [ + edge('tl55-e1', 'tl55-start', 'exec', 'tl55-log', 'exec'), + edge('tl55-e2', 'tl55-vars', 'loots', 'tl55-at', 'list'), + edge('tl55-e3', 'tl55-vars', 'loots', 'tl55-add1', 'list'), + edge('tl55-e4', 'tl55-at', 'item', 'tl55-add1', 'value'), + edge('tl55-e5', 'tl55-add1', 'result', 'tl55-add2', 'list'), + edge('tl55-e6', 'tl55-pack', 'value', 'tl55-add2', 'value'), + edge('tl55-e7', 'tl55-add2', 'result', 'tl55-add3', 'list'), + edge('tl55-e8', 'tl55-num', 'value', 'tl55-add3', 'value'), + edge('tl55-e9', 'tl55-add3', 'result', 'tl55-set', 'list'), + edge('tl55-e11', 'tl55-num', 'value', 'tl55-set', 'value'), + edge('tl55-e12', 'tl55-set', 'result', 'tl55-count', 'list'), + edge('tl55-e10', 'tl55-count', 'count', 'tl55-log', 'message'), + ], + }; + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + p.assets.push({ + id: '${LOOT}', + type: 'struct', + name: 'Loot 55', + props: { Name: 'Coin', Amount: 1 }, + variables: [ + { key: 'Name', label: 'Name', kind: 'text', default: '' }, + { key: 'Amount', label: 'Amount', kind: 'number', default: 0 }, + ], + }); + p.assets.push({ + id: '${DROP}', + type: 'struct', + name: 'Drop 55', + props: { Name: '', Drop: '' }, + variables: [ + { key: 'Name', label: 'Name', kind: 'text', default: '' }, + { key: 'Drop', label: 'Drop', kind: 'asset', default: '' }, + ], + }); + p.assets.push({ + id: 'tl55-chest', + type: 'behavior', + name: 'Chest 55', + props: { enabled: true, runInEditor: false }, + variables: [ + { key: 'loots', label: 'loots', kind: 'list', default: [{ Name: 'Gem', Amount: 4 }, { Name: 'Axe' }] }, + { key: 'mixed', label: 'mixed', kind: 'list', default: [{ Name: 'Own' }, 3] }, + { key: 'bags', label: 'bags', kind: 'list', default: [{ Gold: 5 }, { Gold: 2, Gems: 1 }] }, + { + key: 'drops', + label: 'drops', + kind: 'list', + itemType: '${DROP}', + default: [{ Name: 'Gold', Drop: '', [${JSON.stringify('$struct')}]: '${DROP}' }], + }, + ], + graph: ${JSON.stringify(graph)}, + }); + ${MAKE_OBJECT} + scene.objects.push(make('tl55-obj', 'tl55-chest', '', 400, 'Chest 55')); + `); + + // 55a. The Items row under a list names its type: Mixed, until Loot 55 is picked – then every item + // is a Loot, keeping what fits and taking the type's defaults for the rest. + await select({ kind: 'asset', assetId: 'tl55-chest' }); + await sleep(200); + const rows = await all('Mixed'); + check('each list variable has an Items row reading "Mixed"', rows.length === 3, JSON.stringify(rows)); + await press_(rows[0], 'Items of loots'); + await pick('Loot 55'); + const typed = { itemType: (await variable('loots')).itemType, items: await value('loots') }; + check( + 'picking Loot 55 types loots: Gem keeps its 4, Axe takes the default Amount 1, both marked', + JSON.stringify(typed) === + JSON.stringify({ + itemType: LOOT, + items: [ + { Name: 'Gem', Amount: 4, [MARK]: LOOT }, + { Name: 'Axe', Amount: 1, [MARK]: LOOT }, + ], + }), + JSON.stringify(typed), + ); + await shot('01-typed-list'); + + // 55b. A list holding a number cannot take a type: the console says why, the list stays mixed. + await evaluate('__gm.console.clear()'); + await press_((await all('Mixed'))[0], 'Items of mixed'); + await pick('Loot 55'); + await sleep(300); + const lines = ((await json('__gm.console.lines()')) ?? []).map((l) => l.text); + const refused = 'mixed holds 1 item that is no struct – only a list of structs can take a type'; + check( + 'typing a list with a number in it is refused, in the console, and nothing changes', + lines.includes(refused) && + (await variable('mixed')).itemType === undefined && + JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own' }, 3]), + JSON.stringify({ lines, mixed: await value('mixed') }), + ); + + // 55c. + Item on the typed list adds a Loot at its defaults, no kind menu in between. A typed item + // has the type's fields, fixed; the mixed list's struct item keeps its own. + const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(list('loots'))})`); + await press_(head?.add, '+ Item of loots'); + const added = (await value('loots'))[2]; + check( + '+ Item on a typed list adds a Loot at its defaults straight away', + JSON.stringify(added) === JSON.stringify({ Name: 'Coin', Amount: 1, [MARK]: LOOT }) && + ((await json('__gm.menuRows()')) ?? []).length === 0, + JSON.stringify(added), + ); + const drawn = { loots: await locked('loots'), mixed: await locked('mixed') }; + check( + 'the typed items are drawn with fixed fields, the struct item of the mixed list with its own', + JSON.stringify(drawn) === JSON.stringify({ loots: [true, true, true], mixed: [false, false] }), + JSON.stringify(drawn), + ); + + // 55d. The struct item of the mixed list picks a type of its own; the number beside it stays. + await press_( + await json(`__gm.values.typeAt('inspector', ${JSON.stringify(list('mixed'))}, 0)`), + 'Type of mixed[0]', + ); + await pick('Loot 55'); + check( + 'the struct item in the mixed list becomes a Loot with fixed fields, the number beside it stays', + JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own', Amount: 1, [MARK]: LOOT }, 3]) && + JSON.stringify(await locked('mixed')) === JSON.stringify([true, false]), + JSON.stringify(await value('mixed')), + ); + await shot('02-item-type'); + + // 55e. A field renamed on the asset reaches the typed list and the typed item, values kept. + await select({ kind: 'asset', assetId: LOOT }); + await sleep(200); + const amount = (await all('Amount')).sort((a, b) => b.x - a.x)[0]; + await press_(amount, 'the Amount name'); + await press('a', { ctrl: true }); + await typeText('Count'); + await press('Enter'); + await sleep(200); + const renamed = { loots: await value('loots'), mixed: (await value('mixed'))[0] }; + check( + 'renaming Amount to Count reaches every Loot item, typed list and mixed list alike', + JSON.stringify(renamed) === + JSON.stringify({ + loots: [ + { Name: 'Gem', Count: 4, [MARK]: LOOT }, + { Name: 'Axe', Count: 1, [MARK]: LOOT }, + { Name: 'Coin', Count: 1, [MARK]: LOOT }, + ], + mixed: { Name: 'Own', Count: 1, [MARK]: LOOT }, + }), + JSON.stringify(renamed), + ); + await undo(); + check( + 'one undo takes it back in both', + JSON.stringify((await value('loots'))[0]) === JSON.stringify({ Name: 'Gem', Amount: 4, [MARK]: LOOT }) && + JSON.stringify((await value('mixed'))[0]) === JSON.stringify({ Name: 'Own', Amount: 1, [MARK]: LOOT }), + JSON.stringify({ loots: await value('loots'), mixed: await value('mixed') }), + ); + + // 55f. Make struct type on a list of two shapes: every field either has, at its first value. + await select({ kind: 'asset', assetId: 'tl55-chest' }); + await sleep(200); + await press_((await all('Mixed')).at(-1), 'Items of bags'); + await pick('Make struct type'); + const bags = await variable('bags'); + const made = await asset(bags.itemType ?? ''); + check( + 'Make struct type makes "bags" of Gold and Gems and types the list with it', + made?.type === 'struct' && + made.name === 'bags' && + JSON.stringify(made.props) === '{"Gold":5,"Gems":1}' && + JSON.stringify(await value('bags')) === + JSON.stringify([ + { Gold: 5, Gems: 1, [MARK]: made.id }, + { Gold: 2, Gems: 1, [MARK]: made.id }, + ]), + JSON.stringify({ bags, made, value: await value('bags') }), + ); + await shot('03-make-type'); + + // 55h. An item keeps an asset id as a plain text – its type says it is an asset: drawn with the + // asset mark and picked from the asset menu, the id stays a plain text in the item. + const drops = list('drops'); + const kindsDrawn = ((await json("__gm.values.lists('inspector')")) ?? []).find( + (l) => l.title === drops, + )?.fieldKinds; + check( + 'the fields of a typed item are drawn as the kinds its type declares: Name a text, Drop an asset', + JSON.stringify(kindsDrawn) === JSON.stringify([{ Name: 'text', Drop: 'asset' }]), + JSON.stringify(kindsDrawn), + ); + const dropField = await inView( + t, + '__gm.inspectorRect()', + `__gm.values.fieldAt('inspector', ${JSON.stringify(drops)}, 0, 'Drop')`, + ); + await press_(dropField, 'the Drop field of drops[0]'); + const offered = ((await json('__gm.menuRows()')) ?? []).filter((row) => row && row !== 'None'); + if (offered[0]) await pick(offered[0]); + const dropped = (await value('drops'))[0]; + const target = await asset(typeof dropped?.Drop === 'string' ? dropped.Drop : ''); + check( + 'clicking the Drop field opens the asset menu, and the picked asset lands as its id', + offered.length > 0 && target !== null && offered[0].startsWith(target.name) && dropped.Name === 'Gold', + JSON.stringify({ offered: offered.slice(0, 3), dropped }), + ); + await shot('04-asset-field'); + + // 55g. In a run the items arrive without the mark: Add item typed Loot 55 takes item 0 of loots + // and the packed Bow, and turns the 9 away with a line in the console – so does Set item. + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Chest 55]')) + .map((line) => line.split(']').pop().trim()); + check( + 'a typed Add item takes a Loot of the list and a packed one, and reports the 9 without adding it', + logged.includes('Add item: 9 is no Loot 55 – not added') && logged.at(-1) === '5', + JSON.stringify(logged), + ); + check( + 'a typed Set item reports the 9 too and leaves the item it would replace', + JSON.stringify(logged) === + JSON.stringify(['Add item: 9 is no Loot 55 – not added', 'Set item: 9 is no Loot 55 – not set', '5']), + JSON.stringify(logged), + ); + + // Clean up. + await evaluate('__gm.state.select(null)'); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj'); + const made = ${JSON.stringify(made?.id ?? '')}; + p.assets = p.assets.filter( + (a) => a.id !== 'tl55-chest' && a.id !== '${LOOT}' && a.id !== '${DROP}' && a.id !== made, + ); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index e8821341..09616030 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -57,6 +57,7 @@ import { clipboard } from './51-clipboard.mjs'; import { hierarchyGroups } from './52-hierarchy-groups.mjs'; import { curves } from './53-curves.mjs'; import { structTypes } from './54-struct-types.mjs'; +import { typedLists } from './55-typed-lists.mjs'; export const STEPS = [ hierarchy, @@ -113,4 +114,5 @@ export const STEPS = [ hierarchyGroups, curves, structTypes, + typedLists, ];