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');