From 38e0f656be8b01cecaafc8567ebe0b8ddf0b13f3 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 28 Sep 2026 11:33:38 +0200 Subject: [PATCH] Split a struct output into one pin per field (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading one field of a struct needed its own Expand node, even where the graph already knows the struct type. A split output shows the fields directly on the node, so a Find … Match test becomes a single Equals and the wires keep running left to right. - Right-clicking an output pin offers "Split into fields" when the type is known and "Split as …" for every other struct asset. The output is replaced by one pin per field with the field's kind. - The type is known for typed struct variables, typed Pack/Expand, fields of another split and item of For each, Find, Match and Item at over a typed list (portSplits.ts). - Right-clicking a field pin offers "Recombine". Splitting drops the edges of the whole output, recombining drops the field edges. One undo step brings them back. - The split is stored as GraphNode.splits and saved with the graph. Copy and paste carry it along. - Field pins follow the struct asset like a typed Expand. A new field gets a pin, a removed field loses pin and edge, a renamed field keeps its edge. Deleting the asset keeps the last fields. The delete dialog counts splits, so it now says "node(s)" instead of "Pack/Expand node(s)". - Renaming a variable carries its split and the field edges along. - At run time the node still produces the whole value. A field pin reads its field like Expand, a missing field reads as empty. - Limits: outputs only, one level, behavior graphs only. Tests: - ./check.sh: 185 files, 2706 tests, CHECK EXIT 0 - e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks - New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks using a real mouse: split Find.item, wire item.Name into the test and Match.item.Amount into the log (logs 7), recombine. - Counter-checks: without field reads in the interpreter, without rename in the split, without moving edges on rename and without cleanup on field removal one unit test fails each. In the browser, without field reads nothing is logged and without type inference the split entry is missing. - Check total in README: 1520. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/portCommands.ts | 7 +- src/core/commands/structTypeCommands.ts | 60 ++++++- src/core/graph/Graph.ts | 13 ++ src/core/graph/GraphInterpreter.ts | 16 +- src/core/graph/index.ts | 1 + src/core/graph/nested.ts | 16 +- src/core/graph/portSplits.ts | 62 +++++++ src/core/model/structTypes.ts | 130 +++++++++++++- src/core/store/serialize.ts | 16 ++ src/editor/actions.ts | 2 +- src/editor/graph/GraphCanvas.ts | 16 +- src/editor/graph/graphMenus.ts | 4 +- src/editor/graph/splitMenu.ts | 35 ++++ tests/core/port-splits.test.ts | 218 ++++++++++++++++++++++++ tools/e2e/06-graph.mjs | 127 ++++++++++++++ tools/e2e/54-struct-types.mjs | 2 +- 17 files changed, 705 insertions(+), 22 deletions(-) create mode 100644 src/core/graph/portSplits.ts create mode 100644 src/editor/graph/splitMenu.ts create mode 100644 tests/core/port-splits.test.ts diff --git a/README.md b/README.md index 61581599..c739b057 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, 1517 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1520 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/portCommands.ts b/src/core/commands/portCommands.ts index 60b7d446..1110539d 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -79,11 +79,12 @@ function flowEdgesOnceSilent(graph: Graph, node: GraphNode): EdgeTest[] { return [{ graph, test: (edge) => onFlow(edge.to) || onFlow(edge.from) }]; } +/** An output's edges include those on its field ports, while it is split. */ function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean { return (edge) => site.asInput ? edge.to.node === site.nodeId && edge.to.port === key - : edge.from.node === site.nodeId && edge.from.port === key; + : edge.from.node === site.nodeId && (edge.from.port === key || edge.from.port.startsWith(`${key}.`)); } function nodeAt(site: PortSite): GraphNode | undefined { @@ -94,8 +95,10 @@ function nodeAt(site: PortSite): GraphNode | undefined { function carryPort(site: PortSite, from: string, to: string): void { for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) { if (site.asInput) edge.to.port = to; - else edge.from.port = to; + else edge.from.port = `${to}${edge.from.port.slice(from.length)}`; } + const split = site.asInput ? undefined : nodeAt(site)?.splits?.find((s) => s.port === from); + if (split) split.port = to; const node = site.asInput ? nodeAt(site) : undefined; const value = node?.params[from]; if (!node || value === undefined) return; diff --git a/src/core/commands/structTypeCommands.ts b/src/core/commands/structTypeCommands.ts index 8d8b728b..deebf1bd 100644 --- a/src/core/commands/structTypeCommands.ts +++ b/src/core/commands/structTypeCommands.ts @@ -3,8 +3,10 @@ * those writes are recorded as they happen (`Recorder`) and undone in one go with the edit itself. */ import { newId } from '../ids'; -import type { GraphNode } from '../graph/Graph'; -import { STRUCT_TYPE_PARAM } from '../graph/nested'; +import type { GraphEdge, GraphNode, PortSplit } from '../graph/Graph'; +import { resolveDef, STRUCT_TYPE_PARAM } from '../graph/nested'; +import { canSplit, splitOf } from '../graph/portSplits'; +import { graphLookup } from '../model/graphLookup'; import type { StructAsset } from '../model/Asset'; import { nextAssetName } from './naming'; import { @@ -41,7 +43,7 @@ import { valueAt } from '../properties/valuePath'; import { removeById } from '../util/list'; import { Recorder } from '../util/recorder'; import { type Command, CommandError } from './Command'; -import { requireNode, requireVariableOwner } from './require'; +import { requireGraph, requireNode, requireVariableOwner } from './require'; /** * Runs an edit of a Struct asset's fields and then carries it to every place of that type. A @@ -129,6 +131,58 @@ export class SetStructTypeCommand implements Command { } } +/** + * Takes an output apart into one output per field of a Struct asset, or with `''` puts it together + * again. The edges on what goes away go with it, in the same undo step. + */ +export class SplitOutputCommand implements Command { + readonly label: string; + private rec = new Recorder(); + + constructor( + private readonly assetId: string, + private readonly nodeId: string, + private readonly port: string, + private readonly typeId: string, + ) { + this.label = typeId ? `Split ${port}` : `Recombine ${port}`; + } + + execute(project: Project): void { + const graph = requireGraph(project, this.assetId); + const node = requireNode(project, this.assetId, this.nodeId); + const { port } = this; + this.rec = new Recorder(); + const leaving = (key: string) => (this.typeId ? key === port : key.startsWith(`${port}.`)); + if (this.typeId) { + const type = structAssetOf(project, this.typeId); + if (!type) throw new CommandError('That struct is gone'); + const output = resolveDef(node, graph, graphLookup(project))?.outputs.find((p) => p.key === port); + if (!output || !canSplit(node, output)) throw new CommandError(`"${port}" cannot be split`); + const split: PortSplit = { port, structType: type.id, fields: [] }; + this.rec.set(node, 'splits', [...(node.splits ?? []), split]); + } else { + if (!splitOf(node, port)) throw new CommandError(`"${port}" is not split`); + const rest = (node.splits ?? []).filter((s) => s.port !== port); + if (rest.length > 0) this.rec.set(node, 'splits', rest); + else this.rec.delete(node, 'splits'); + } + const gone = (e: GraphEdge) => e.from.node === node.id && leaving(e.from.port); + if (graph.edges.some(gone)) + this.rec.set( + graph, + 'edges', + graph.edges.filter((e) => !gone(e)), + ); + // The fields come in the way every later edit of the type brings them: through the sync. + syncStructTypes(project, this.rec); + } + + undo(): void { + this.rec.revert(); + } +} + /** A struct field as a declared variable of a Struct asset, its default taken from `value`. */ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined): PropertyDef { const def = variableDef( diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 7f3580e2..dd55b5c5 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -31,10 +31,23 @@ export interface GraphNode { * behavior its signature; an Expand/Pack node is what gives a struct its fields. */ ports?: DeclaredPort[]; + /** Struct outputs shown as one output per field instead of a whole (see `portSplits.ts`). */ + splits?: PortSplit[]; /** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */ collapsed?: boolean; } +/** + * One output taken apart: `port` stands in the node as `port.field` for each of `fields`, a copy of + * the fields of the Struct asset `structType`, kept in step by `syncStructTypes` like the ports of a + * typed `Expand`. A type that is gone leaves the copy as it was. + */ +export interface PortSplit { + port: string; + structType: string; + fields: DeclaredPort[]; +} + /** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */ export interface DeclaredPort { key: string; diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index ac3db3a6..11e2a2fc 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,7 +1,8 @@ import { blockProblem, MATCH_INPUT, matchOf } from './findBlock'; import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; -import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested'; +import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested'; +import { fieldValue, splitField, withSplitValues } from './portSplits'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { NodeContext } from './NodeContext'; import { @@ -294,7 +295,11 @@ export class GraphInterpreter { private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value { const edge = edgesInto(this.frame.graph, node.id, port.key)[0]; - return edge ? this.readOutput(edge.from.node, edge.from.port, ctx) : inlineValue(node, port); + if (!edge) return inlineValue(node, port); + const source = this.node(edge.from.node); + const split = source && splitField(source, edge.from.port); + if (!split) return this.readOutput(edge.from.node, edge.from.port, ctx); + return fieldValue(this.readOutput(edge.from.node, split.split.port, ctx), split.field); } /** Value of one output port: from the pulse cache, or by evaluating a pure data node. */ @@ -338,11 +343,14 @@ export class GraphInterpreter { } private record(nodeId: string, values: Record): void { - this.trace?.onValues?.(this.frame.graph, nodeId, values); + if (!this.trace?.onValues) return; + const node = this.node(nodeId); + this.trace.onValues(this.frame.graph, nodeId, node ? withSplitValues(node, values) : values); } + /** Without split field ports: a node produces its wholes, and `inputValue` picks the fields. */ private def(node: GraphNode) { - return resolveDef(node, this.frame.graph, this.lookup); + return runDef(node, this.frame.graph, this.lookup); } private node(id: string): GraphNode | undefined { diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 919dddd8..1394adf1 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -8,6 +8,7 @@ export * from './findBlock'; export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; +export * from './portSplits'; export * from './portTypes'; export * from './propertyPorts'; export * from './savedValues'; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index eed70695..57f6b1de 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -14,6 +14,7 @@ import { import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; import { lerpProblem } from './nodes/data/Lerp'; +import { withSplits } from './portSplits'; import { agreedType, type SharedPortGroup } from './portTypes'; export const BEHAVIOR_START = 'BehaviorStart'; @@ -144,6 +145,19 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] * type declares ports per node (`declaresPorts`) or computes them (`derivePorts`). */ export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined { + const def = shapedDef(node, graph, lookup, true); + return def && splitDef(def, node); +} + +function splitDef(def: NodeDef, node: GraphNode): NodeDef { + return withSplits(def, node, (key, field) => ({ ...portDefOf(field), key, label: key })); +} + +/** + * The definition a run works with: `resolveDef` without the field ports of split outputs. The node + * produces its wholes as always; reading a field port picks the field out of one (`splitField`). + */ +export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined { return shapedDef(node, graph, lookup, true); } @@ -209,7 +223,7 @@ function feedingType( if (!edge) return undefined; const from = findNode(graph, edge.from.node); const def = from && shapedDef(from, graph, lookup, inherit); - return def?.outputs.find((p) => p.key === edge.from.port)?.type; + return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type; } /** diff --git a/src/core/graph/portSplits.ts b/src/core/graph/portSplits.ts new file mode 100644 index 00000000..11d6a677 --- /dev/null +++ b/src/core/graph/portSplits.ts @@ -0,0 +1,62 @@ +/** + * Split outputs: a struct output shown as one output per field, so reading a field needs no `Expand` + * of its own. Only the drawing and the wiring see the field ports – `resolveDef` puts them in place of + * the whole. The node itself still produces the whole value, and the interpreter picks the field out + * of it when a field port is read (`fieldOfSplit`). + */ +import type { GraphNode, PortSplit } from './Graph'; +import type { NodeDef, PortDef } from './NodeRegistry'; +import { asStruct, unplaced, type Value } from './values'; + +/** Declared keys are identifiers, so the dot never clashes with a port of the node's own. */ +export function splitPortKey(port: string, field: string): string { + return `${port}.${field}`; +} + +export function splitOf(node: GraphNode, port: string): PortSplit | undefined { + return node.splits?.find((s) => s.port === port); +} + +/** Which split output a key names, and which field of it – undefined for any other key. */ +export function splitField(node: GraphNode, key: string): { split: PortSplit; field: string } | undefined { + const dot = key.indexOf('.'); + if (dot < 0) return undefined; + const split = splitOf(node, key.slice(0, dot)); + const field = key.slice(dot + 1); + return split?.fields.some((f) => f.key === field) ? { split, field } : undefined; +} + +/** Whether an output can be taken apart: a value that may hold a struct, and not already split. */ +export function canSplit(node: GraphNode, port: PortDef): boolean { + return (port.type === 'struct' || port.type === 'any') && !port.ghost && !splitOf(node, port.key); +} + +/** The outputs as drawn and wired: each split one replaced, where it stood, by its field ports. */ +export function withSplits( + def: NodeDef, + node: GraphNode, + portOf: (key: string, kind: PortSplit['fields'][number]) => PortDef, +): NodeDef { + if (!node.splits?.length) return def; + const outputs = def.outputs.flatMap((port) => { + const split = port.type === 'exec' ? undefined : splitOf(node, port.key); + return split ? split.fields.map((f) => portOf(splitPortKey(port.key, f.key), f)) : [port]; + }); + return { ...def, outputs }; +} + +/** One field of a whole output, read the way `Expand` reads it: a field the value lacks is empty. */ +export function fieldValue(whole: Value | undefined, field: string): Value { + return unplaced(asStruct(whole)[field]) ?? null; +} + +/** What a node produced, with the value of every field port of its splits beside the wholes. */ +export function withSplitValues(node: GraphNode, values: Record): Record { + if (!node.splits?.length) return values; + const out = { ...values }; + for (const split of node.splits) + if (split.port in values) + for (const f of split.fields) + out[splitPortKey(split.port, f.key)] = fieldValue(values[split.port], f.key); + return out; +} diff --git a/src/core/model/structTypes.ts b/src/core/model/structTypes.ts index e1081159..2daac6c6 100644 --- a/src/core/model/structTypes.ts +++ b/src/core/model/structTypes.ts @@ -5,10 +5,26 @@ * 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. + * list, and any struct item given one in a mixed list. The same sync keeps marked items in shape, + * and the field ports of split outputs, which keep a copy of their type's fields like `Expand` does. */ -import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph'; -import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested'; +import { FIND, findsOf, MATCH } from '../graph/findBlock'; +import { + type DeclaredPort, + edgesInto, + findNode, + type Graph, + type GraphNode, + type PortSplit, +} from '../graph/Graph'; +import { + fixedPortKeys, + isValidPortName, + STRUCT_TYPE_PARAM, + VARIABLES, + variablesSourceOf, +} from '../graph/nested'; +import { splitField, splitPortKey } from '../graph/portSplits'; import { nodeDef } from '../graph/NodeRegistry'; import { isKindWith } from '../properties/kinds'; import { @@ -35,7 +51,7 @@ import { textOf, withItemType, } from './Properties'; -import { findAssetOf, type Project } from './Project'; +import { findAsset, findAssetOf, type Project } from './Project'; export const PACK_STRUCT = 'PackStruct'; export const EXPAND_STRUCT = 'ExpandStruct'; @@ -384,6 +400,7 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void if (value !== undefined) holder.write(renamedItems(project, value, rename)); } if (rename.path.length > 0) return; + renameSplitEdges(project, rec, rename); for (const { graph, node } of typedNodes(project)) { if (nodeStructType(node) !== rename.structType) continue; const ports = node.ports ?? []; @@ -405,6 +422,50 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void } } +/** Every split output in the project, with the node and graph it sits in. */ +function splitOutputs(project: Project): { graph: Graph; node: GraphNode; split: PortSplit }[] { + return project.assets.flatMap((asset) => + isGraphAsset(asset) + ? asset.graph.nodes.flatMap((node) => + (node.splits ?? []).map((split) => ({ graph: asset.graph, node, split })), + ) + : [], + ); +} + +/** A renamed field's edges move to its new field port, before the sync would drop them as gone. */ +function renameSplitEdges(project: Project, rec: Recorder, { structType, from, to }: FieldRename): void { + for (const { graph, node, split } of splitOutputs(project)) { + if (split.structType !== structType) continue; + const old = splitPortKey(split.port, from); + for (const edge of graph.edges) + if (edge.from.node === node.id && edge.from.port === old) + rec.set(edge.from, 'port', splitPortKey(split.port, to)); + } +} + +/** A split's fields follow its type; an edge on a field that is gone goes with it. */ +function syncSplit(graph: Graph, node: GraphNode, split: PortSplit, type: StructAsset, rec: Recorder): void { + const fields: DeclaredPort[] = typeFields(type) + .filter((f) => isValidPortName(f.key)) + .map((f) => ({ key: f.key, kind: f.kind })); + if (JSON.stringify(fields) === JSON.stringify(split.fields)) return; + rec.set( + node, + 'splits', + (node.splits ?? []).map((s) => (s === split ? { ...s, fields } : s)), + ); + const kept = new Set(fields.map((f) => splitPortKey(split.port, f.key))); + const gone = (e: { from: { node: string; port: string } }) => + e.from.node === node.id && e.from.port.startsWith(`${split.port}.`) && !kept.has(e.from.port); + if (graph.edges.some(gone)) + rec.set( + graph, + 'edges', + graph.edges.filter((e) => !gone(e)), + ); +} + function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] { return project.assets.flatMap((asset) => isGraphAsset(asset) @@ -474,10 +535,67 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR const type = structAssetOf(project, nodeStructType(node)); if (type) syncNodePorts(graph, node, typeFields(type), rec); } + for (const { graph, node, split } of splitOutputs(project)) { + const type = structAssetOf(project, split.structType); + if (type) syncSplit(graph, node, split, type, rec); + } if (rec.count === before) return; } } +/** Nodes whose `item` output is an element of the list fed into their `list` input. */ +const LIST_ELEMENT_NODES = new Set(['ForEach', FIND, 'ItemAt']); + +/** + * What an output is declared as, where the graph says so: a variable of a `Variables` node, the + * value of a typed `Pack`, a field of a typed `Expand` or of a split output. Undefined elsewhere. + */ +function outputDecl( + project: Project, + ownerId: string, + node: GraphNode, + port: string, +): StructFieldDef | undefined { + const split = splitField(node, port); + if (split) { + const type = structAssetOf(project, split.split.structType); + return type && typeFields(type).find((f) => f.key === split.field); + } + if (node.type === VARIABLES) { + const owner = findAsset(project, variablesSourceOf(node.params) || ownerId); + const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === port) : undefined; + return def && fieldOfVariable(def); + } + const typeId = nodeStructType(node); + if (!typeId) return undefined; + if (node.type === PACK_STRUCT) return { key: port, kind: 'struct', fields: [], structType: typeId }; + const type = structAssetOf(project, typeId); + return type && typeFields(type).find((f) => f.key === port); +} + +/** + * The Struct asset a value leaving this output is known to be, or `''`. A struct declared with a + * type is one; the `item` of `For each`, `Find`, `Match` and `Item at` is one of a typed list fed + * into the loop. What flows in through any other way is not followed – the user picks the type. + */ +export function outputStructType( + project: Project, + graph: Graph, + ownerId: string, + node: GraphNode, + port: string, +): string { + const loop = node.type === MATCH ? findsOf(graph, node)[0] : node; + if (loop && port === 'item' && LIST_ELEMENT_NODES.has(loop.type)) { + const edge = edgesInto(graph, loop.id, 'list')[0]; + const from = edge && findNode(graph, edge.from.node); + const decl = from && outputDecl(project, ownerId, from, edge.from.port); + return decl?.kind === 'list' ? (decl.itemType ?? '') : ''; + } + const decl = outputDecl(project, ownerId, node, port); + return decl?.kind === 'struct' ? (decl.structType ?? '') : ''; +} + /** How many places name the type – what deleting it would cut loose. */ export function structTypeUsages( project: Project, @@ -487,7 +605,9 @@ export function structTypeUsages( 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, + nodes: + typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length + + splitOutputs(project).filter(({ split }) => split.structType === typeId).length, }; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 7318d05a..fc91ff9d 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -10,6 +10,7 @@ import { type Graph, type GraphEdge, type GraphNode, + type PortSplit, isValidPortName, fixedPortKeys, type NodeDef, @@ -456,6 +457,19 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea return ports; } +/** One split per output; its fields are checked like declared ports, the type is kept even when gone. */ +function normalizeSplits(raw: unknown): PortSplit[] { + const splits: PortSplit[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const s = asRecord(item); + const port = typeof s.port === 'string' ? s.port : ''; + if (!port || splits.some((other) => other.port === port)) continue; + const structType = typeof s.structType === 'string' ? s.structType : ''; + splits.push({ port, structType, fields: normalizePorts(s.fields, [], false) }); + } + return splits; +} + /** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { const g = asRecord(raw); @@ -477,6 +491,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; const ports = normalizePorts(n.ports, fixedPortKeys(def), def.declaresPorts === 'both'); if (ports.length > 0) node.ports = ports; + const splits = normalizeSplits(n.splits); + if (splits.length > 0) node.splits = splits; if (n.collapsed === true) node.collapsed = true; graph.nodes.push(node); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index c682abde..52c77345 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -143,7 +143,7 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { : 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.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.` + ? `${typed.places} struct(s)${lists} and ${typed.nodes} 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/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index e650063a..977bd4c8 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -55,6 +55,7 @@ import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; import type { GraphCanvasHost } from './graphHost'; import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette, type PaletteAction } from './NodePalette'; +import { splitEntries } from './splitMenu'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; import { drawGraphGrid, FIT_MARGIN, ZOOM_LIMITS, zoomedAbout } from './graphView'; @@ -490,7 +491,12 @@ export class GraphCanvas extends Widget { const { id } = view.node; if (hit) { const key = hit.port.key; - this.openLinks((g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global); + const extras = hit.isOutput ? splitEntries(this.state, this.assetId, view.node, hit.port) : []; + this.openLinks( + (g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), + e.global, + extras, + ); } else { // Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id])); @@ -743,9 +749,13 @@ export class GraphCanvas extends Widget { if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); } - private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void { + private openLinks( + pick: (graph: Graph) => Graph['edges'], + at: { x: number; y: number }, + extras: readonly MenuItem[] = [], + ): void { const graph = this.asset()?.graph; - if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y); + if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y, extras); } /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */ diff --git a/src/editor/graph/graphMenus.ts b/src/editor/graph/graphMenus.ts index 08f7203e..026b5cbe 100644 --- a/src/editor/graph/graphMenus.ts +++ b/src/editor/graph/graphMenus.ts @@ -76,8 +76,9 @@ export function openLinkMenu( edges: readonly GraphEdge[], x: number, y: number, + extras: readonly MenuItem[] = [], ): void { - if (edges.length === 0) return; + if (edges.length === 0 && extras.length === 0) return; const item = ( verb: string, single: string, @@ -93,6 +94,7 @@ export function openLinkMenu( const shown = edges.filter((e) => !e.folded); const hidden = edges.filter((e) => e.folded); new Menu([ + ...extras, ...item('Hide', 'Hide link', shown, (id) => new FoldEdgeCommand(assetId, id, true)), ...item('Show', 'Show link', hidden, (id) => new FoldEdgeCommand(assetId, id, false)), ...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true), diff --git a/src/editor/graph/splitMenu.ts b/src/editor/graph/splitMenu.ts new file mode 100644 index 00000000..0c7fa667 --- /dev/null +++ b/src/editor/graph/splitMenu.ts @@ -0,0 +1,35 @@ +import { SplitOutputCommand } from '../../core/commands/structTypeCommands'; +import { canSplit, type GraphNode, type PortDef, splitField } from '../../core/graph'; +import { findGraphAsset } from '../../core/model/Project'; +import { outputStructType, structAssetOf } from '../../core/model/structTypes'; +import type { MenuItem } from '../../ui'; +import type { EditorState } from '../state'; + +/** + * What the menu of an output offers besides its links: a field port puts its output together again, + * a struct output is taken apart – at once into the type it is known to carry, else into any Struct + * asset picked from the list. Only behavior graphs carry structs. + */ +export function splitEntries( + state: EditorState, + assetId: string, + node: GraphNode, + port: PortDef, +): MenuItem[] { + const asset = findGraphAsset(state.project, assetId); + if (asset?.type !== 'behavior') return []; + const run = (portKey: string, typeId: string) => () => + state.execute(new SplitOutputCommand(assetId, node.id, portKey, typeId)); + const field = splitField(node, port.key); + if (field) return [{ label: `Recombine ${field.split.port}`, onSelect: run(field.split.port, '') }]; + if (!canSplit(node, port)) return []; + const known = structAssetOf( + state.project, + outputStructType(state.project, asset.graph, assetId, node, port.key), + ); + const others = state.project.assets.filter((a) => a.type === 'struct' && a.id !== known?.id); + return [ + ...(known ? [{ label: `Split into ${known.name} fields`, onSelect: run(port.key, known.id) }] : []), + ...others.map((type) => ({ label: `Split as ${type.name}`, onSelect: run(port.key, type.id) })), + ]; +} diff --git a/tests/core/port-splits.test.ts b/tests/core/port-splits.test.ts new file mode 100644 index 00000000..27d8ff06 --- /dev/null +++ b/tests/core/port-splits.test.ts @@ -0,0 +1,218 @@ +/** + * Split outputs: a struct output shown as one output per field of a Struct asset. The fields follow + * the asset like a typed `Expand` does, the node still produces the whole value, and reading a field + * port picks the field out of it. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + DeleteAssetCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { + SetListItemTypeCommand, + SetStructTypeCommand, + SplitOutputCommand, + WithStructTypes, +} from '../../src/core/commands/structTypeCommands'; +import { + AddVariableCommand, + RemoveVariableCommand, + RenameVariableCommand, +} from '../../src/core/commands/variableCommands'; +import { edgesOutOf, FIND, GraphInterpreter, type GraphNode, resolveDef } from '../../src/core/graph'; +import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; + +/** `Loot` = { Name: "Coin", Amount: 1 } */ +function lootType(f: ProjectFixture): 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')); + return create.asset as StructAsset; +} + +/** + * A behavior with a list `bag` of `Loot` (typed unless told otherwise) and On start → Find over it, + * the test `item == "Gem"` wired from the Find's `item` through an Equals into the Match. + */ +function finder(typed = true) { + const f = makeProject(); + const loot = lootType(f); + const behavior = makeBehavior(f, 'Seeker'); + f.stack.execute(new AddVariableCommand(behavior.id, 'bag', 'list')); + if (typed) + f.stack.execute(new SetListItemTypeCommand({ ownerId: behavior.id, key: 'bag', path: [] }, loot.id)); + const edit = graphEditor(f, behavior); + const add = new AddNodeCommand(behavior.id, FIND, 0, 0); + f.stack.execute(add); + const find = add.node; + const match = add.closing!; + const start = edit.addNode('OnStart'); + const get = edit.variables(['bag']); + const equals = edit.addNode('Equals', { b: 'Gem' }); + edit.wire([ + [start, 'exec', find, 'exec'], + [get, 'bag', find, 'list'], + [find, 'item', equals, 'a'], + [equals, 'result', match, 'match'], + ]); + return { f, loot, behavior, edit, find, match, equals }; +} + +const split = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode, port: string, typeId: string) => + f.stack.execute(new SplitOutputCommand(b.id, node.id, port, typeId)); + +const outputs = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode) => + resolveDef(node, b.graph, graphLookup(f.project))!.outputs.map((p) => `${p.key}:${p.type}`); + +describe('splitting an output', () => { + it('knows the type an item of a typed list carries, at Find and at Match', () => { + const { f, behavior, find, match, loot } = finder(); + expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe(loot.id); + expect(outputStructType(f.project, behavior.graph, behavior.id, match, 'item')).toBe(loot.id); + expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'index')).toBe(''); + }); + + it('knows nothing of an item of a mixed list', () => { + const { f, behavior, find } = finder(false); + expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe(''); + }); + + it('knows a struct variable declared with a type, and the value of a typed Pack', () => { + const f = makeProject(); + const loot = lootType(f); + const behavior = makeBehavior(f); + f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct')); + const target = { kind: 'variable', ownerId: behavior.id, key: 'drop', path: [] } as const; + f.stack.execute(new SetStructTypeCommand(target, loot.id)); + const edit = graphEditor(f, behavior); + const get = edit.variables(['drop']); + const pack = edit.addNode('PackStruct', { structType: loot.id }); + expect(outputStructType(f.project, behavior.graph, behavior.id, get, 'drop')).toBe(loot.id); + expect(outputStructType(f.project, behavior.graph, behavior.id, pack, 'value')).toBe(loot.id); + }); + + it('puts one port per field where the output stood, typed as the field, and drops its edges', () => { + const { f, behavior, find, loot } = finder(); + expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); + split(f, behavior, find, 'item', loot.id); + expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']); + expect(edgesOutOf(behavior.graph, find.id, 'item')).toEqual([]); + f.stack.undo(); + expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); + expect(edgesOutOf(behavior.graph, find.id, 'item')).toHaveLength(1); + }); + + it('reads a field port as that field of the value the node produced', () => { + const { f, behavior, edit, find, match, equals, loot } = finder(); + split(f, behavior, find, 'item', loot.id); + split(f, behavior, match, 'item', loot.id); + const log = edit.addNode('Log'); + edit.wire([ + [find, 'item.Name', equals, 'a'], + [match, 'found', log, 'exec'], + [match, 'item.Amount', log, 'message'], + ]); + const bag = [ + { Name: 'Coin', Amount: 1 }, + { Name: 'Gem', Amount: 7 }, + ]; + const ctx = fakeContext({ variables: { bag } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.logs).toEqual(['7']); + }); + + it('refuses an output that holds no struct, and one split already', () => { + const { f, behavior, find, loot } = finder(); + expect(() => split(f, behavior, find, 'index', loot.id)).toThrow('cannot be split'); + split(f, behavior, find, 'item', loot.id); + expect(() => split(f, behavior, find, 'item', loot.id)).toThrow('cannot be split'); + }); + + it('recombines: the whole output comes back, the edges on its fields go', () => { + const { f, behavior, find, equals, edit, loot } = finder(); + split(f, behavior, find, 'item', loot.id); + edit.connect(find, 'item.Name', equals, 'a'); + split(f, behavior, find, 'item', ''); + expect(find.splits).toBeUndefined(); + expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']); + expect(behavior.graph.edges.some((e) => e.from.port.startsWith('item.'))).toBe(false); + f.stack.undo(); + expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1); + }); +}); + +describe('a split output following its Struct asset', () => { + it('moves the edge of a renamed field to the new field port, in one undo step', () => { + const { f, behavior, find, equals, edit, loot } = finder(); + split(f, behavior, find, 'item', loot.id); + edit.connect(find, 'item.Name', equals, 'a'); + f.stack.execute( + new WithStructTypes(new RenameVariableCommand(loot.id, 'Name', 'Title'), { + structType: loot.id, + path: [], + from: 'Name', + to: 'Title', + }), + ); + expect(outputs(f, behavior, find)).toEqual(['item.Title:string', 'item.Amount:number', 'index:number']); + expect(edgesOutOf(behavior.graph, find.id, 'item.Title')).toHaveLength(1); + f.stack.undo(); + expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1); + expect(outputs(f, behavior, find)[0]).toBe('item.Name:string'); + }); + + it('gets a new field and loses a removed one together with its edge', () => { + const { f, behavior, find, equals, edit, loot } = finder(); + split(f, behavior, find, 'item', loot.id); + edit.connect(find, 'item.Name', equals, 'a'); + f.stack.execute(new WithStructTypes(new AddVariableCommand(loot.id, 'Rare', 'boolean'))); + expect(outputs(f, behavior, find)).toContain('item.Rare:boolean'); + f.stack.execute(new WithStructTypes(new RemoveVariableCommand(loot.id, 'Name'))); + expect(outputs(f, behavior, find)).toEqual(['item.Amount:number', 'item.Rare:boolean', 'index:number']); + expect(behavior.graph.edges.some((e) => e.from.port === 'item.Name')).toBe(false); + }); + + it('keeps its last fields when the type is deleted, and the delete dialog counts it', () => { + const { f, behavior, find, loot } = finder(); + split(f, behavior, find, 'item', loot.id); + expect(structTypeUsages(f.project, loot.id).nodes).toBe(1); + f.stack.execute(new DeleteAssetCommand(loot.id)); + expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']); + }); + + it('survives saving and loading', () => { + const { f, find, loot } = finder(); + const behavior = f.project.assets.find((a) => a.name === 'Seeker') as BehaviorAsset; + split(f, behavior, find, 'item', loot.id); + const loaded = deserializeProject(serializeProject(f.project)); + const again = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset; + expect(again.graph.nodes.find((n) => n.id === find.id)?.splits).toEqual(find.splits); + }); + + it('follows its variable when that is renamed, field edges included', () => { + const f = makeProject(); + const loot = lootType(f); + const behavior = makeBehavior(f); + f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct')); + const edit = graphEditor(f, behavior); + const get = edit.variables(['drop']); + const log = edit.addNode('Log'); + split(f, behavior, get, 'drop', loot.id); + edit.connect(get, 'drop.Name', log, 'message'); + f.stack.execute(new RenameVariableCommand(behavior.id, 'drop', 'loot')); + expect(get.splits?.[0]?.port).toBe('loot'); + expect(edgesOutOf(behavior.graph, get.id, 'loot.Name')).toHaveLength(1); + f.stack.undo(); + expect(get.splits?.[0]?.port).toBe('drop'); + expect(edgesOutOf(behavior.graph, get.id, 'drop.Name')).toHaveLength(1); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index eb1b6d44..bae4a55b 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1103,6 +1103,7 @@ export async function graph(t) { await lerpNode(); await forEachNode(); await findMatchNodes(); + await splitOutputs(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1370,4 +1371,130 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder'); `); } + // 6z-vi. Split outputs: the item of a Find over a typed list is split with a right-click on its + // pin, straight into the fields of the list's type. A field port is dragged into the test, + // the Match's item is split as well, and a run logs the amount of the loot found. A right- + // click on a field port puts the output together again. + async function splitOutputs() { + const loots = [ + { Name: 'Axe', Amount: 1 }, + { Name: 'Gold', Amount: 7 }, + ]; + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'sp-loot', + type: 'struct', + name: 'Loot 6', + props: { Name: '', Amount: 0 }, + variables: [ + { key: 'Name', label: 'Name', kind: 'text', default: '' }, + { key: 'Amount', label: 'Amount', kind: 'number', default: 0 }, + ], + }); + p.assets.push({ + id: 'splity', + type: 'behavior', + name: 'Splity', + props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} }, + variables: [ + { key: 'loots', label: 'loots', kind: 'list', itemType: 'sp-loot', default: ${JSON.stringify(loots)} }, + ], + graph: { nodes: [], edges: [] }, + }); + const behavior = p.assets.find((a) => a.id === 'splity'); + 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 } }); + behavior.graph.nodes.push( + node('sp-start', 'OnStart', -240, -60), + node('sp-loots', 'Variables', -240, 80, {}, [{ key: 'loots', kind: 'list' }]), + node('sp-find', 'Find', 0, 0), + node('sp-equals', 'Equals', 240, 120, { b: 'Gold' }), + node('sp-match', 'Match', 480, 0), + node('sp-log', 'Log', 760, 0), + ); + behavior.graph.edges.push( + edge('sp-e1', 'sp-start', 'exec', 'sp-find', 'exec'), + edge('sp-e2', 'sp-loots', 'loots', 'sp-find', 'list'), + edge('sp-e3', 'sp-equals', 'result', 'sp-match', 'match'), + edge('sp-e4', 'sp-match', 'found', 'sp-log', 'exec'), + ); + const scene = __gm.state.activeScene; + const holder = structuredClone(scene.objects[0]); + Object.assign(holder, { id: 'sp-holder', assetId: 'splity', parentId: '', name: 'Split holder', + position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete holder.groupId; + scene.objects.push(holder); + __gm.state.select({ kind: 'asset', assetId: 'splity' }); + `); + await showTab('graph'); + await clickText('Fit'); + const outputsOf = async (id) => + Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {}); + const splitPin = async (id, port, entry) => { + const pin = await json(`__gm.graph.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, true)`); + if (!pin) return false; + await click(pin.x, pin.y, 'right'); + await sleep(150); + await clickText(entry); + await sleep(150); + return true; + }; + const opened = await splitPin('sp-find', 'item', 'Split into Loot 6 fields'); + const types = await json(`__gm.graph.portTypes('sp-find')`); + check( + 'right-click on the item of a Find over a typed list splits it into the fields of that type', + opened && + JSON.stringify(Object.keys(types?.outputs ?? {})) === + JSON.stringify(['item.Name', 'item.Amount', 'index']) && + types.outputs['item.Name'] === 'string' && + types.outputs['item.Amount'] === 'number', + JSON.stringify({ opened, types }), + ); + await clickText('Fit'); + const from = await json(`__gm.graph.portAt('sp-find', 'item.Name', true)`); + const to = await json(`__gm.graph.portAt('sp-equals', 'a', false)`); + if (from && to) await drag(from.x, from.y, to.x, to.y, 16); + await splitPin('sp-match', 'item', 'Split into Loot 6 fields'); + await clickText('Fit'); + const amount = await json(`__gm.graph.portAt('sp-match', 'item.Amount', true)`); + const message = await json(`__gm.graph.portAt('sp-log', 'message', false)`); + if (amount && message) await drag(amount.x, amount.y, message.x, message.y, 16); + const wired = (await asset('splity')).graph.edges; + await shot('25-split-outputs'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Split holder]')) + .map((line) => line.split(']').pop().trim()); + check( + 'field ports dragged into the test and the log: a run logs the Amount of the loot named "Gold"', + wired.some((e) => e.from.port === 'item.Name' && e.to.node === 'sp-equals') && + wired.some((e) => e.from.port === 'item.Amount' && e.to.node === 'sp-log') && + JSON.stringify(logged) === JSON.stringify(['7']), + JSON.stringify({ from, to, amount, message, logged }), + ); + // Play shows the Game tab; the pin is only there to be clicked with the graph in front again. + await showTab('graph'); + await clickText('Fit'); + await splitPin('sp-find', 'item.Name', 'Recombine item'); + const back = await outputsOf('sp-find'); + const left = (await asset('splity')).graph.edges; + check( + 'right-click on a field port recombines the output, the edge on the field goes with it', + JSON.stringify(back) === JSON.stringify(['item', 'index']) && + !left.some((e) => e.from.node === 'sp-find' && e.from.port.startsWith('item.')), + JSON.stringify({ back, left }), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'splity' && a.id !== 'sp-loot'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder'); + `); + } } diff --git a/tools/e2e/54-struct-types.mjs b/tools/e2e/54-struct-types.mjs index e57e8a87..7a07d286 100644 --- a/tools/e2e/54-struct-types.mjs +++ b/tools/e2e/54-struct-types.mjs @@ -249,7 +249,7 @@ export async function structTypes(t) { await click(lootRow.x, lootRow.y, 'right'); await sleep(150); await pick('Delete…'); - const detail = '1 struct(s) and 1 Pack/Expand node(s) use it and keep its fields as their own.'; + const detail = '1 struct(s) and 1 node(s) use it and keep its fields as their own.'; check('the delete dialog counts the places using the type', await t.has(detail)); await shot('04-delete-dialog'); await pick('Delete');