From f60358c951d9a8807cb035141e1e9d5a01d41cbc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 14:01:56 +0200 Subject: [PATCH] Let a list hold structs, and read or write variables in bundles A list item may now be a struct. The depth is bounded in the declaration rather than by a comment: FieldKind (number|text|boolean|color) says what a struct field holds, ListItemKind adds struct on top and says what a list holds, so list -> struct -> primitive is the deepest storable shape and sameValue stays finite. A struct item renders inline in the List tab, which is why it needs no path in the slot address. Four paths silently assumed a list item was a primitive: sameValue compared items with ===, textOf would have printed [object Object], asListItem turned a struct into text on Set item and Fill, and Index of used indexOf, which is reference equality and could never have found a struct. Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose numbers, piece and the candidate are structs, so Next move hands back a whole candidate and each strand packs only the field it changes, since mergeStruct leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges. Get variables and Set variables declare their ports from a list of variable names, the way Pack and Expand already do, so one node can read or write several at once. Renaming a variable rewires them, because there the port key is the variable name. Co-Authored-By: Claude Opus 5 --- README.md | 32 ++- src/core/commands/portCommands.ts | 66 +++++ src/core/commands/variableCommands.ts | 2 + src/core/graph/nested.ts | 11 + src/core/graph/nodes/data/IndexOf.ts | 9 +- src/core/graph/nodes/index.ts | 4 + src/core/graph/nodes/variable/GetVariables.ts | 24 ++ src/core/graph/nodes/variable/SetVariables.ts | 25 ++ src/core/graph/values.ts | 17 +- src/core/model/Properties.ts | 15 +- src/core/properties/PropertySchema.ts | 53 +++- src/core/store/serialize.ts | 44 ++-- src/editor/debugHook.ts | 2 +- src/editor/inspector/assetSection.ts | 10 +- src/editor/inspector/fieldFactory.ts | 9 +- src/editor/inspector/nodeSection.ts | 6 +- src/editor/inspector/portSection.ts | 74 +++++- src/editor/inspector/previewHandlers.ts | 4 +- src/editor/list/ListEditor.ts | 51 ++-- src/editor/list/slot.ts | 39 ++- tests/core/list-of-structs.test.ts | 126 +++++++++ tests/core/node-catalog.test.ts | 41 ++- tests/core/variable-ports.test.ts | 122 +++++++++ todo-notes/expand-und-pack-node.md | 2 + todo-notes/struct-und-listen-restposten.md | 26 +- todo-notes/tetris-mit-structs-neu-bauen.md | 1 + tools/e2e/21-list.mjs | 78 +++++- tools/e2e/23-variable-nodes.mjs | 162 ++++++++++++ tools/e2e/index.mjs | 2 + tools/lib/builders.mjs | 11 +- tools/seed-tetris.mjs | 246 ++++++++---------- tools/smoke-tetris.mjs | 22 +- 32 files changed, 1083 insertions(+), 253 deletions(-) create mode 100644 src/core/graph/nodes/variable/GetVariables.ts create mode 100644 src/core/graph/nodes/variable/SetVariables.ts create mode 100644 tests/core/list-of-structs.test.ts create mode 100644 tests/core/variable-ports.test.ts create mode 100644 todo-notes/expand-und-pack-node.md create mode 100644 tools/e2e/23-variable-nodes.mjs diff --git a/README.md b/README.md index d45d740d..8362612b 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,20 @@ shown on the node and editable in the inspector. - **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they appear in the inspector, can be overridden per placed instance, and each running instance - gets its own mutable copy. `Get variable` / `Set variable` read and write them. A variable is a - **number, a yes/no, a text, a list or a struct**; a list holds numbers, texts, yes/nos or colours (its - item kind decides which widget an item gets) and is + gets its own mutable copy. `Get variable` / `Set variable` read and write them, and their plurals + `Get variables` / `Set variables` do the same for several at once: their settings are a *list* of + variables, one port per entry, so reading a handful is one node instead of a column of five. Those + ports are picked from the behavior's variables rather than named, they take their type from the + declaration, and renaming a variable carries them (and their edges) along. A variable is a + **number, a yes/no, a text, a list or a struct**; a list holds numbers, texts, yes/nos, colours or + **structs** (its item kind decides which widget an item gets) and is compared by its items, never by identity – otherwise every reload would look like an override. There is no list *of objects*: a list is stored in the project file and an object is a live handle that no reload survives. A set of objects is a list of their names, walked with `Repeat` and `Object by name`. A list property is shown as a button saying how many items it holds; clicking it opens the **List** tab, which gives each item a row with the same widget the - inspector would use for that kind. The inspector stays visible beside the tab, so what the list + inspector would use for that kind – for a list of structs that is the inspector's own struct + block, so a row is the bundle's fields under each other. The inspector stays visible beside the tab, so what the list belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object override or node setting) on the editor state, because no tab may reach into the `Editor` to switch itself on. A **struct** variable is the other way round: few fields, so they fit under @@ -152,10 +157,12 @@ shown on the node and editable in the inspector. behavior port may be declared `struct` – which is how a bundle crosses a call – and a **variable** may be one, which is how a bundle is stored. Writing a struct variable keeps the fields it declares: what arrives fills the ones it names, the rest stay put (`mergeStruct`), so - one node can set part of a bundle without erasing it. Fields are - primitives or lists, never another struct, for the same reason a list is flat; a *declared* - field is a primitive – a field holding a list would need a nested editor, which is the same - step "a list of structs" waits for. A live entity is + one node can set part of a bundle without erasing it – "the same piece, one row lower" is a + `Pack` of that one field. Depth is bounded by the *declaration*, not by convention: a list says + what its items are (`ListItemKind`, struct included), a struct field says what it holds + (`FieldKind`, primitives only), so `list → struct → primitive` is as deep as a stored value + goes and the deep compare and the editor stay finite. A declared field holding a list is the + one step still missing. A live entity is an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead), or a struct would read as `[object Object]`. A struct port has no inline value (its parts come @@ -580,7 +587,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 450 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 456 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end @@ -663,6 +670,13 @@ above and below it. What the player sees is a pool of objects the graphs address destroyed while playing. Every frame tries exactly **one** move (turning beats moving beats falling), which is why the game needs only a single "does it fit?" test instead of one per key. +What is *not* loose numbers is the piece. `piece` and the candidate `next` are **structs** of +shape/rot/x/y, and the shape table is a **list of `{x, y}` cells** – so `Next move` hands back a +whole candidate for `Fits` to judge and the master to accept in one write, rather than three +parallel variables copied back and forth, and each strand packs only the field it changes +(`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that +list instead of the same index doubled and read twice. + It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index af3df5ab..1f7ce28f 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -1,6 +1,7 @@ import { BEHAVIOR_PARAM, BEHAVIOR_START, + declaresVariablePorts, type DeclaredPort, findNode, type Graph, @@ -190,6 +191,71 @@ export class RenamePortCommand implements Command { } } +/** Renames one declared port on one node, taking its edges and its typed-in value along. */ +function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void { + const ports = node.ports ?? []; + if (from === to || !ports.some((p) => p.key === from)) return; + const site: PortSite = { + graph, + nodeId: node.id, + asInput: requireNodeDef(node.type).declaresPorts === 'inputs', + }; + node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p)); + for (const edge of graph.edges.filter(edgesOnPort(site, from))) { + if (site.asInput) edge.to.port = to; + else edge.from.port = to; + } + if (site.asInput) renameParam(site, from, to); +} + +/** + * Points one declared port at another variable. Key and kind change together, because on a + * Get/Set variables node the kind is the variable's rather than a choice of its own; the edges + * hanging on the port come along, so correcting a pick is not a rewiring. + */ +export class SetVariablePortCommand implements Command { + readonly label: string; + private previous?: DeclaredPort; + + constructor( + private readonly assetId: string, + private readonly nodeId: string, + private readonly from: string, + private readonly port: DeclaredPort, + ) { + this.label = `Point ${from} at ${port.key}`; + } + + execute(project: Project): void { + const node = requirePortNode(project, this.assetId, this.nodeId); + const ports = node.ports ?? []; + const current = ports.find((p) => p.key === this.from); + if (!current) throw new CommandError(`Port "${this.from}" not found`); + if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key)) + throw new CommandError(`"${this.port.key}" already exists`); + this.previous = { ...current }; + this.swap(project, node, this.from, this.port); + } + + undo(project: Project): void { + if (!this.previous) return; + this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous); + } + + private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void { + renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key); + node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p)); + } +} + +/** + * Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in + * values move with it – the port key *is* the variable name there. + */ +export function renameVariablePorts(graph: Graph, from: string, to: string): void { + for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to); +} + /** "in1", "in2", … – the first port name the terminal node does not use yet. */ export function nextPortName(node: GraphNode, base: string): string { const taken = new Set((node.ports ?? []).map((p) => p.key)); diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index b99839cf..b913fef8 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -18,6 +18,7 @@ import { } from '../properties/PropertySchema'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; +import { renameVariablePorts } from './portCommands'; import { requireBehavior } from './require'; /** The variable's default value is written to `props` as well. */ @@ -133,6 +134,7 @@ export class RenameVariableCommand implements Command { } // Every node that picks a variable stores it under the param key `variable`. for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to; + renameVariablePorts(asset.graph, from, to); } } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 8a21ae5e..250098a9 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -6,9 +6,20 @@ import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; export const BEHAVIOR_START = 'BehaviorStart'; export const BEHAVIOR_END = 'BehaviorEnd'; export const RUN_BEHAVIOR = 'RunBehavior'; +export const GET_VARIABLES = 'GetVariables'; +export const SET_VARIABLES = 'SetVariables'; export const BEHAVIOR_PARAM = 'behavior'; +/** + * Nodes whose declared ports are not names of their own but variables of the behavior around them. + * Renaming a variable therefore has to reach them, and the inspector picks their ports from a list + * instead of letting them be typed. + */ +export function declaresVariablePorts(type: string): boolean { + return type === GET_VARIABLES || type === SET_VARIABLES; +} + /** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */ export function isValidPortName(name: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec'; diff --git a/src/core/graph/nodes/data/IndexOf.ts b/src/core/graph/nodes/data/IndexOf.ts index 27752a35..d8bf648e 100644 --- a/src/core/graph/nodes/data/IndexOf.ts +++ b/src/core/graph/nodes/data/IndexOf.ts @@ -1,3 +1,4 @@ +import { sameValue } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; import { asList, asListItem } from '../../values'; import { any, list, num } from '../shared'; @@ -10,7 +11,9 @@ export const IndexOf: NodeDef = { inputs: [list('list'), any('value')], outputs: [num('index')], params: [], - run: (_ctx, inputs) => ({ - outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) }, - }), + // Compared by content, not by identity: two equal structs are never the same object. + run: (_ctx, inputs) => { + const wanted = asListItem(inputs.value); + return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } }; + }, }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 5afa9583..56ca86c1 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -82,7 +82,9 @@ import { Repeat } from './flow/Repeat'; import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; import { GetVariable } from './variable/GetVariable'; +import { GetVariables } from './variable/GetVariables'; import { SetVariable } from './variable/SetVariable'; +import { SetVariables } from './variable/SetVariables'; /** * Importing this module registers the built-in nodes. The palette sorts by category and label, so @@ -109,7 +111,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ BehaviorEnd, // variables GetVariable, + GetVariables, SetVariable, + SetVariables, // data: references and properties Self, Owner, diff --git a/src/core/graph/nodes/variable/GetVariables.ts b/src/core/graph/nodes/variable/GetVariables.ts new file mode 100644 index 00000000..535f42bf --- /dev/null +++ b/src/core/graph/nodes/variable/GetVariables.ts @@ -0,0 +1,24 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { GET_VARIABLES } from '../../nested'; +import type { Value } from '../../values'; + +/** + * The plural of Get variable: one output per variable picked, so a graph reading a handful of them + * is one node instead of a column of five. The ports are declared on the node, the way Expand + * declares the fields of a struct. + */ +export const GetVariables: NodeDef = { + type: GET_VARIABLES, + label: 'Get variables', + category: 'variable', + description: "Reads several of this behavior's variables at once, one output per variable.", + inputs: [], + outputs: [], + params: [], + declaresPorts: 'outputs', + run: (ctx, _inputs, _params, def) => { + const outputs: Record = {}; + for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null; + return { outputs }; + }, +}; diff --git a/src/core/graph/nodes/variable/SetVariables.ts b/src/core/graph/nodes/variable/SetVariables.ts new file mode 100644 index 00000000..365b4f78 --- /dev/null +++ b/src/core/graph/nodes/variable/SetVariables.ts @@ -0,0 +1,25 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { SET_VARIABLES } from '../../nested'; +import { writeTyped } from '../../values'; +import { exec } from '../shared'; + +/** + * The plural of Set variable: one input per variable picked. An input left unconnected writes the + * value typed on the node, exactly as it does on the singular node – the node writes every variable + * it lists, every time it runs. + */ +export const SetVariables: NodeDef = { + type: SET_VARIABLES, + label: 'Set variables', + category: 'variable', + description: "Writes several of this behavior's variables at once, one input per variable.", + inputs: [exec()], + outputs: [exec()], + params: [], + declaresPorts: 'inputs', + run: (ctx, inputs, _params, def) => { + for (const port of def.inputs) + if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index b624d324..03e0ed3b 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -39,16 +39,21 @@ export function asList(value: Value | undefined): ListValue { return [value]; } -/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */ -export function asListItem(value: Value | undefined): number | string | boolean { - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value; - if (value === null || value === undefined) return 0; - return asString(value); +/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */ +export function asListItem(value: Value | undefined): number | string | boolean | StructValue { + if (isStructValue(value)) return value; + return asPrimitive(value); } /** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */ export function asField(value: Value | undefined): FieldValue { - return isList(value) ? value : asListItem(value); + return isList(value) ? value : asPrimitive(value); +} + +function asPrimitive(value: Value | undefined): number | string | boolean { + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value; + if (value === null || value === undefined) return 0; + return asString(value); } export function asBoolean(value: Value | undefined): boolean { diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 52dfdad7..b6f9b499 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -1,10 +1,12 @@ /** - * A list is deliberately flat – a list of lists would need a nested editor and a deep compare on - * every override, and nothing so far wants one: a board is addressed as `row · width + column`. + * A list holds primitives or structs. Depth stops there and it stops in the declaration, not by + * convention: a list says what its items are (`ListItemKind`, struct included), a struct field + * says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest + * a stored value goes, which is what keeps the deep compare and the editor finite. */ -export type ListValue = readonly (number | string | boolean)[]; +export type ListValue = readonly (number | string | boolean | StructValue)[]; -/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */ +/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */ export type FieldValue = number | string | boolean | ListValue; /** Several named values travelling as one, so a bundle needs one port instead of one per part. */ @@ -30,7 +32,7 @@ export const isStruct = (value: unknown): value is StructValue => */ export function textOf(value: PropertyValue | undefined): string { if (value === undefined) return ''; - if (isList(value)) return value.join(', '); + if (isList(value)) return value.map((item) => textOf(item)).join(', '); if (isStruct(value)) return Object.entries(value) .map(([field, item]) => `${field}: ${textOf(item)}`) @@ -42,12 +44,11 @@ export function textOf(value: PropertyValue | undefined): string { export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean { if (isList(a) || isList(b)) { if (!isList(a) || !isList(b) || a.length !== b.length) return false; - return a.every((item, i) => item === b[i]); + return a.every((item, i) => sameValue(item, b[i])); } if (isStruct(a) || isStruct(b)) { if (!isStruct(a) || !isStruct(b)) return false; const keys = Object.keys(a); - // Fields are never structs, so this recurses exactly one level deep. return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key])); } return a === b; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 6666a1bf..e2a8ae5a 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -49,20 +49,32 @@ export type PropertyDef = * list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the * project file, and an object is a live handle that no reload survives. */ - | { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind } + | { + key: string; + label: string; + kind: 'list'; + default: ListValue; + items: ListItemKind; + /** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */ + fields?: readonly StructFieldDef[]; + } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). */ | { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] }; -export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const; +/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */ +export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const; +export type FieldKind = (typeof FIELD_KINDS)[number]; + +/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */ +export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const; export type ListItemKind = (typeof LIST_ITEM_KINDS)[number]; -/** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */ export interface StructFieldDef { key: string; - kind: ListItemKind; + kind: FieldKind; } export type StructDef = Extract; @@ -200,14 +212,19 @@ export function variableDef( return { key, label: key, kind, default: Boolean(defaultValue ?? false) }; case 'text': return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) }; - case 'list': + case 'list': { + const item = items ?? 'number'; + const shape = item === 'struct' ? (fields ?? []) : undefined; + const raw = isList(defaultValue) ? defaultValue : []; return { key, label: key, kind, - default: isList(defaultValue) ? defaultValue : [], - items: items ?? 'number', + default: raw.map((entry) => coerceListItem(entry, item, shape)), + items: item, + ...(shape ? { fields: shape } : {}), }; + } case 'struct': { const declared = fields ?? []; return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) }; @@ -216,14 +233,14 @@ export function variableDef( } /** The empty value of a kind, so a field that is not there yet still has one. */ -const EMPTY_FIELD: Record = { +const EMPTY_FIELD: Record = { number: 0, text: '', boolean: false, color: 0, }; -export function emptyField(kind: ListItemKind): number | string | boolean { +export function emptyField(kind: FieldKind): number | string | boolean { return EMPTY_FIELD[kind]; } @@ -236,7 +253,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue const value: Record = {}; for (const field of fields) value[field.key] = - field.key in source ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind); + field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind); return value; } @@ -266,14 +283,24 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal return def.options.some((o) => o.value === s) ? s : def.default; } case 'list': - return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default; + return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default; case 'struct': return structOf(def.fields, value); } } -export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean { - if (items === 'text') return isList(value) ? value.join(', ') : textOf(value); +/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */ +export function coerceListItem( + value: PropertyValue, + items: ListItemKind, + fields?: readonly StructFieldDef[], +): number | string | boolean | StructValue { + return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items); +} + +/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */ +export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean { + if (items === 'text') return textOf(value); if (items === 'boolean') { if (typeof value === 'boolean') return value; // A list is typed as text, so the spelling has to count: Boolean('false') would be true. diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index dc523259..06bded27 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -13,7 +13,7 @@ import { import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; -import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties'; +import type { FieldValue, PropertyBag, PropertyValue, StructValue } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile, normalizeFrameRect } from '../model/texture'; @@ -21,6 +21,8 @@ import { hasParentCycle } from '../model/walk'; import { coerceValue, COMMON_PROPS, + FIELD_KINDS, + type FieldKind, isCommonProp, LIST_ITEM_KINDS, type ListItemKind, @@ -192,9 +194,9 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] { const f = asRecord(item); const key = typeof f.key === 'string' ? f.key : ''; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue; - if (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue; + if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue; seen.add(key); - fields.push({ key, kind: f.kind as ListItemKind }); + fields.push({ key, kind: f.kind as FieldKind }); } return fields; } @@ -317,27 +319,35 @@ function asRecord(value: unknown): Record { * flattened one level, so nothing deeper than the model allows can enter behind the type's back. */ function asStoredValue(value: unknown): PropertyValue | undefined { - const field = asFieldValue(value); - if (field !== undefined) return field; - if (!value || typeof value !== 'object') return undefined; + if (isPrimitive(value)) return value; + if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined); + return asStoredStruct(value, asStoredField); +} + +const isPrimitive = (value: unknown): value is number | string | boolean => + typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean'; + +/** A list item: a primitive or a struct of primitives. */ +const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined => + isPrimitive(value) ? value : asStoredStruct(value, (item) => (isPrimitive(item) ? item : undefined)); + +/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */ +const asStoredField = (value: unknown): FieldValue | undefined => + isPrimitive(value) ? value : Array.isArray(value) ? value.filter(isPrimitive) : undefined; + +function asStoredStruct( + value: unknown, + ofField: (item: unknown) => FieldValue | undefined, +): StructValue | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const fields: Record = {}; for (const [key, item] of Object.entries(value)) { - const stored = asFieldValue(item); + const stored = ofField(item); if (stored !== undefined) fields[key] = stored; } return fields; } -function asFieldValue(value: unknown): FieldValue | undefined { - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value; - if (Array.isArray(value)) - return value.filter( - (item): item is number | string | boolean => - typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean', - ); - return undefined; -} - function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 46c1d3a3..e7ede923 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -226,7 +226,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E list: { info: () => editor.listEditor.debugInfo(), /** Where an item's widget sits, so a check can click it – a number field has no findable text. */ - fieldAt: (index: number) => editor.listEditor.fieldAt(index), + fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field), }, world: () => editor.worldSnapshot(), playInfo: () => editor.playInfo(), diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 19452e07..5f8d90ca 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -16,8 +16,8 @@ import type { Asset, BehaviorAsset } from '../../core/model/Asset'; import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import { - LIST_ITEM_KINDS, - type ListItemKind, + FIELD_KINDS, + type FieldKind, type PropertyDef, schemaForAsset, type StructDef, @@ -62,7 +62,7 @@ const VARIABLE_KIND_MARKS: Partial> = { struct: '⊞', }; -const FIELD_KIND_LABELS: Record = { +const FIELD_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text', @@ -71,7 +71,7 @@ const FIELD_KIND_LABELS: Record = { const FIELD_INDENT = 12; -const FIELD_KIND_MARKS: Record = { +const FIELD_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa', @@ -264,7 +264,7 @@ function buildStructVariable( head.add(new Spacer(), 1); const add = new Dropdown('+ Field', { variant: 'ghost' }); add.onClick = () => { - const items = LIST_ITEM_KINDS.map((kind) => ({ + const items = FIELD_KINDS.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => host.state.execute( diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 98776b99..e1022d06 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -8,7 +8,7 @@ import { } from '../../core/properties/assetRef'; import { emptyField, - type ListItemKind, + type FieldKind, type PropertyDef, type StructFieldDef, } from '../../core/properties/PropertySchema'; @@ -179,10 +179,12 @@ interface Field { /** Whether the widget should take the remaining row width. */ grow: boolean; setValue(value: PropertyValue): void; + /** The per-field widgets of a struct, so a caller can address one of them. */ + parts?: ReadonlyMap; } /** A primitive as a property definition, so one widget set serves a list item and a struct field. */ -export function primitiveDef(key: string, kind: ListItemKind, label = key): PrimitiveDef { +export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef { switch (kind) { case 'number': return { key, label, kind: 'number', default: 0 }; @@ -195,7 +197,7 @@ export function primitiveDef(key: string, kind: ListItemKind, label = key): Prim } } -export type PrimitiveDef = Extract; +export type PrimitiveDef = Extract; /** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */ export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue { @@ -265,6 +267,7 @@ export function createField( setValue: (v) => { for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field)); }, + parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])), }; } case 'boolean': { diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 070854b9..a2332c8d 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -1,6 +1,7 @@ import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; import { callProblem, + declaresVariablePorts, type Graph, edgesInto, type GraphNode, @@ -16,7 +17,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, Label } from '../../ui'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; -import { buildPortSection } from './portSection'; +import { buildPortSection, buildVariablePortSection } from './portSection'; import { listOpener, previewingHandlers } from './previewHandlers'; /** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ @@ -51,7 +52,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s addNote(host, def.description); const problem = callProblem(node, behaviorLookup(host.state.project), assetId); if (problem) addNote(host, problem, 'warning'); - if (def.declaresPorts) buildPortSection(host, assetId, node); + if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables); + else if (def.declaresPorts) buildPortSection(host, assetId, node); if (def.params.length > 0) addTitle(host, 'SETTINGS'); for (const param of def.params) { diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index de7a8b6f..a1a4be18 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -3,15 +3,18 @@ import { nextPortName, RemovePortCommand, RenamePortCommand, + SetVariablePortCommand, } from '../../core/commands/portCommands'; import { BEHAVIOR_START, DECLARED_PORT_KINDS, + type DeclaredPort, type DeclaredPortKind, type GraphNode, isTerminalNode, } from '../../core/graph'; -import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui'; import { addNote, type InspectorHost } from './host'; const KIND_LABELS: Record = { @@ -32,6 +35,75 @@ const KIND_MARKS: Record = { struct: '⊞', }; +/** + * The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked + * from that list rather than named, and its kind follows the variable's declaration. + */ +export function buildVariablePortSection( + host: InspectorHost, + assetId: string, + node: GraphNode, + variables: readonly PropertyDef[], +): void { + const ports = node.ports ?? []; + const taken = new Set(ports.map((p) => p.key)); + const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) }); + const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] => + variables.map((v) => ({ + label: v.key, + disabled: taken.has(v.key) && v.key !== own, + onSelect: () => onPick(portOf(v)), + })); + + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(new Label('VARIABLES', { style: 'title' }), 1); + const add = new Dropdown('+ Variable', { variant: 'ghost' }); + add.onClick = () => { + const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port))); + new Menu(items).openBelow(add); + }; + add.setEnabled(variables.length > taken.size); + header.add(add); + host.body.add(header); + + if (variables.length === 0) addNote(host, 'This behavior has no variables yet.'); + else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.'); + + for (const port of ports) { + const known = variables.some((v) => v.key === port.key); + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' }); + mark.minWidth = 18; + row.add(mark); + const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' }); + choose.onClick = () => { + const items = pick(port.key, (next) => + host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)), + ); + new Menu(items).openBelow(choose); + }; + row.add(choose, 1); + row.add(new Spacer()); + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)), + }), + ); + host.body.add(row); + if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning'); + } +} + +/** Every variable kind is a port kind too; the fallback is for a project file that says otherwise. */ +function variablePortKind(def: PropertyDef): DeclaredPortKind { + return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind) + ? (def.kind as DeclaredPortKind) + : 'text'; +} + /** * The ports a node declares for itself: the signature of a behavior seen as a node, or the fields * of a struct. A start node's are named from the caller's side – what it hands into the graph is diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 9d81ae88..5ea96fc6 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -1,5 +1,5 @@ import { - coerceListItem, + coerceField, type PropertyDef, type StructDef, type StructFieldDef, @@ -45,7 +45,7 @@ export function structFieldsOf( ...spec, toModel: (input, target) => ({ ...structOf(def.fields, spec.read(target)), - [field.key]: coerceListItem(input, field.kind), + [field.key]: coerceField(input, field.kind), }), }); } diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 2976569c..3fed941c 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -4,9 +4,11 @@ * tab, so what the list belongs to is still on screen while it is edited. */ import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui'; -import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory'; +import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties'; +import { type StructDef, structOf } from '../../core/properties/PropertySchema'; +import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory'; import type { EditorState } from '../state'; -import { type ListAccess, resolveList } from './slot'; +import { type ListAccess, type ListDef, resolveList } from './slot'; const INDEX_WIDTH = 40; @@ -19,8 +21,8 @@ export class ListEditor extends VStack { private rows = new VStack({ padding: Theme.padding, gap: Theme.gap }); /** The structure the rows were built for; only a change here may destroy a row being typed in. */ private signature = ''; - private setters: ((value: number | string | boolean) => void)[] = []; - private fields: Widget[] = []; + private setters: ((value: PropertyValue) => void)[] = []; + private fields: { widget: Widget; parts?: ReadonlyMap }[] = []; constructor(private readonly state: EditorState) { super({ gap: 0 }); @@ -48,8 +50,9 @@ export class ListEditor extends VStack { } /** For the check scripts: where an item's widget sits, since a number field carries no findable text. */ - fieldAt(index: number): { x: number; y: number } | null { - const widget = this.fields[index]; + fieldAt(index: number, field = ''): { x: number; y: number } | null { + const found = this.fields[index]; + const widget = field ? found?.parts?.get(field) : found?.widget; if (!widget) return null; const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; @@ -61,7 +64,7 @@ export class ListEditor extends VStack { if (!access) return { title: '', items: [], kind: '' }; return { title: access.title, - items: access.value.map((item) => String(item)), + items: access.value.map((item) => textOf(item)), kind: access.def.items, }; } @@ -71,9 +74,11 @@ export class ListEditor extends VStack { return slot ? resolveList(this.state, slot) : null; } - /** Everything that decides how many rows there are and of what kind. */ + /** Everything that decides how many rows there are and of what kind – a struct's fields included. */ private signatureOf(access: ListAccess | null): string { - return access ? `${access.title}|${access.def.items}|${access.value.length}` : ''; + if (!access) return ''; + const fields = (access.def.fields ?? []).map((f) => `${f.key}:${f.kind}`).join(','); + return `${access.title}|${access.def.items}|${fields}|${access.value.length}`; } private refresh(): void { @@ -109,18 +114,16 @@ export class ListEditor extends VStack { this.relayout(); } - private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; + private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack { + // A struct item is a stack of field rows: its row grows with it and aligns to the top. + const tall = access.def.items === 'struct'; + const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' }); + if (!tall) row.minHeight = Theme.rowHeight; const label = new Label(String(index), { style: 'small', align: 'right' }); label.minWidth = INDEX_WIDTH; + if (tall) label.minHeight = Theme.rowHeight; row.add(label); - const field = createField( - this.ctx, - primitiveDef(String(index), access.def.items), - item, - access.itemHandlers(index), - ); + const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index)); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); row.add( @@ -131,12 +134,20 @@ export class ListEditor extends VStack { }), ); this.setters.push(field.setValue); - this.fields.push(field.widget); + this.fields.push({ widget: field.widget, parts: field.parts }); return row; } private addItem(): void { const access = this.access(); - if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]); + if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]); } } + +/** Item `index` as a definition, so the inspector's own widgets render a row of the list. */ +function itemDef(def: ListDef, index: number): PrimitiveDef | StructDef { + const key = String(index); + return def.items === 'struct' + ? { key, label: key, kind: 'struct', fields: def.fields ?? [], default: structOf(def.fields ?? [], {}) } + : primitiveDef(key, def.items); +} diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index 8ac4c270..962af48f 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -12,13 +12,16 @@ import { resolveDef } from '../../core/graph/nested'; import type { Asset } from '../../core/model/Asset'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; import type { ObjectRef } from '../../core/model/instance'; -import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties'; +import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { + coerceField, coerceListItem, type PropertyDef, type PropertySchema, schemaForAsset, + structOf, + type StructFieldDef, } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import type { FieldHandlers } from '../inspector/fieldFactory'; @@ -53,9 +56,13 @@ const listDefIn = (schema: PropertySchema, key: string): ListDef | null => { return def?.kind === 'list' ? def : null; }; -const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue => +const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue => list.map((value, i) => (i === index ? item : value)); +/** The item at `index` in the shape the declaration promises, so a field write never invents one. */ +const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue => + structOf(def.fields ?? [], list[index]); + /** * `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so * the next keystroke has to build on what is in there now, and the commit on what the restore @@ -72,11 +79,33 @@ function access( def, title, value: current(), - itemHandlers: (index) => - previewingHandlers( + itemHandlers: (index) => ({ + ...previewingHandlers( { state }, - { ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) }, + { + ...spec, + toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)), + }, ), + // A struct item is edited field by field, and each write rebuilds the whole item from what + // is in the list at that moment -- the same rule as a row, one level deeper. + ...(def.items === 'struct' + ? { + structFieldHandlers: (field: StructFieldDef) => + previewingHandlers( + { state }, + { + ...spec, + toModel: (input) => + withItem(current(), index, { + ...structItemAt(current(), index, def), + [field.key]: coerceField(input, field.kind), + }), + }, + ), + } + : {}), + }), // No coercion here: every command behind `commit` runs the value through `coerceValue`. // A preview does not, which is why `toModel` above has to. replace: (next) => state.execute(spec.commit(next)), diff --git a/tests/core/list-of-structs.test.ts b/tests/core/list-of-structs.test.ts new file mode 100644 index 00000000..3ddd3f25 --- /dev/null +++ b/tests/core/list-of-structs.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { Fill } from '../../src/core/graph/nodes/data/Fill'; +import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf'; +import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt'; +import { SetItem } from '../../src/core/graph/nodes/data/SetItem'; +import { sameValue, textOf } from '../../src/core/model/Properties'; +import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; +import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers'; + +const XY = [ + { key: 'x', kind: 'number' }, + { key: 'y', kind: 'number' }, +] as const; + +const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y })); + +/** A `list` variable whose items are structs, declared the way a seeder writes it. */ +const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never, 'struct', XY); + +/** The node interface only needs `run`; nothing here reads the context. */ +const run = (def: { run?: unknown }, inputs: Record) => + (def.run as (ctx: unknown, i: Record) => { outputs: Record })({}, inputs) + .outputs; + +describe('a list whose items are structs', () => { + it('forces every item into the declared shape, filling and dropping fields', () => { + const def = shapesDef([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]); + expect(def.default).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 0 }, + { x: 4, y: 5 }, + ]); + }); + + it('coerces the field values themselves, so text out of a file becomes numbers', () => { + expect(coerceValue(shapesDef([]), [{ x: '3', y: '4' }])).toEqual([{ x: 3, y: 4 }]); + }); + + it('makes a non-struct item into the empty shape rather than dropping the item', () => { + expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 2 }, + ]); + }); + + it('survives a save and load with its items intact', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + const asset = f.project.assets.find((a) => a.id === behavior.id); + if (asset?.type !== 'behavior') throw new Error('behavior expected'); + asset.variables = [shapesDef(cells([0, 1], [1, 1]))]; + asset.props.shapes = cells([2, 3], [4, 5]); + + const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id); + if (loaded?.type !== 'behavior') throw new Error('behavior expected'); + const def = loaded.variables.find((v) => v.key === 'shapes'); + expect(def?.kind === 'list' ? def.items : '').toBe('struct'); + expect(def?.kind === 'list' ? def.fields : []).toEqual([...XY]); + expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5])); + }); + + it('compares by content, so an unchanged list is not reported as an override', () => { + expect(sameValue(cells([1, 2]), cells([1, 2]))).toBe(true); + expect(sameValue(cells([1, 2]), cells([1, 3]))).toBe(false); + expect(sameValue(cells([1, 2]), cells([1, 2], [3, 4]))).toBe(false); + }); + + it('reads as its items rather than as "[object Object]"', () => { + expect(textOf(cells([1, 2], [3, 4]))).toBe('x: 1, y: 2, x: 3, y: 4'); + }); +}); + +/** + * Node params have no schema to coerce against, so the loader alone decides how deep a value may + * be. The bound has to be the model's own: a list of structs of primitives, and no further. + */ +describe('an untyped slot on load', () => { + const paramsAfterLoad = (params: Record) => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + const asset = f.project.assets.find((a) => a.id === behavior.id); + if (asset?.type !== 'behavior') throw new Error('behavior expected'); + asset.graph.nodes.push({ id: 'n1', type: 'List', x: 0, y: 0, params: params as never }); + + const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id); + if (loaded?.type !== 'behavior') throw new Error('behavior expected'); + return loaded.graph.nodes.find((n) => n.id === 'n1')?.params ?? {}; + }; + + it('keeps a list of structs', () => { + expect(paramsAfterLoad({ items: cells([1, 2], [3, 4]) }).items).toEqual(cells([1, 2], [3, 4])); + }); + + it('still keeps a struct holding a flat list, which it always did', () => { + expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 }); + }); + + it('drops a struct inside a struct, which no declaration can ask for', () => { + expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 }); + }); + + it('drops a list inside a list item, the level below what a field may hold', () => { + expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]); + }); +}); + +describe('the list nodes with struct items', () => { + it('hands a whole item out of the list', () => { + expect(run(ItemAt, { list: cells([1, 2], [3, 4]), index: 1 })).toEqual({ item: { x: 3, y: 4 } }); + }); + + it('puts a struct back into a list instead of turning it into text', () => { + expect(run(SetItem, { list: cells([1, 2], [3, 4]), index: 0, value: { x: 9, y: 9 } })).toEqual({ + result: cells([9, 9], [3, 4]), + }); + }); + + it('finds a struct by its content, since two equal structs are never the same object', () => { + expect(run(IndexOf, { list: cells([1, 2], [3, 4]), value: { x: 3, y: 4 } })).toEqual({ index: 1 }); + expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 }); + }); + + it('fills a list with a struct', () => { + expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) }); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e08add2f..79e97eb9 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -4,7 +4,16 @@ */ import { describe, expect, it } from 'vitest'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; -import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph'; +import { + createEmptyGraph, + type DeclaredPort, + NO_BEHAVIORS, + type NodeContext, + requireNodeDef, + resolveDef, + type RunResult, + type Value, +} from '../../src/core/graph'; import type { PropertyBag } from '../../src/core/model/Properties'; import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers'; @@ -14,6 +23,8 @@ interface Case { inputs?: Record; params?: PropertyBag; context?: Partial; + /** Ports the node declares for itself – for the types whose ports are variables it names. */ + ports?: DeclaredPort[]; /** Expected data outputs, compared exactly. */ outputs?: Record; /** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */ @@ -30,7 +41,8 @@ interface Case { const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => { const ctx = fakeContext(c.context); - const registered = requireNodeDef(type); + const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports }; + const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type); const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const })); const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered; return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx }; @@ -98,6 +110,31 @@ const CASES: Record = { { name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } }, { name: 'no variable chosen reads as null', outputs: { value: null } }, ], + GetVariables: [ + { + name: 'reads one output per variable it names', + ports: [ + { key: 'speed', kind: 'number' }, + { key: 'nope', kind: 'number' }, + ], + outputs: { speed: 100, nope: null }, + }, + { name: 'without a variable it reads nothing', outputs: {} }, + ], + SetVariables: [ + { + name: 'writes every variable it names, each keeping its declared type', + ports: [{ key: 'speed', kind: 'number' }], + inputs: { speed: '250' }, + next: 'exec', + after: (ctx) => expect(ctx.variables.speed).toBe(250), + }, + { + name: 'without a variable it writes nothing', + next: 'exec', + after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }), + }, + ], SetVariable: [ { name: 'writes a variable, keeping the declared type', diff --git a/tests/core/variable-ports.test.ts b/tests/core/variable-ports.test.ts new file mode 100644 index 00000000..b2f67fd0 --- /dev/null +++ b/tests/core/variable-ports.test.ts @@ -0,0 +1,122 @@ +/** + * Get/Set variables: the two nodes whose declared ports are variables of the behavior around them, + * rather than names of their own. What that costs is the wiring – a port has to follow the variable + * it names when that is renamed or swapped, and take its edges along. + */ +import { describe, expect, it } from 'vitest'; +import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands'; +import { + AddVariableCommand, + RemoveVariableCommand, + RenameVariableCommand, +} from '../../src/core/commands/variableCommands'; +import { + GET_VARIABLES, + type GraphNode, + GraphInterpreter, + resolveDef, + SET_VARIABLES, +} from '../../src/core/graph'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; +import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; + +/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */ +function bothWays() { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']); + const edit = graphEditor(fixture, behavior); + const start = edit.addNode('OnStart'); + const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 }); + const get = edit.addNode(GET_VARIABLES); + const add = edit.addNode('Add'); + const store = edit.addNode('SetVariable', { variable: 'sum' }); + for (const node of [set, get]) + for (const key of ['px', 'py']) + fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' })); + edit.wire([ + [start, 'exec', set, 'exec'], + [set, 'exec', store, 'exec'], + [get, 'px', add, 'a'], + [get, 'py', add, 'b'], + [add, 'result', store, 'value'], + ]); + return { fixture, behavior, set, get, edit }; +} + +function run( + behavior: BehaviorAsset, + fixture: ReturnType, + variables: Record = { px: 0, py: 0, sum: 0 }, +) { + const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)); + const ctx = fakeContext({ variables }); + interpreter.fire('OnStart', ctx); + return ctx.variables; +} + +const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') => + resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`); + +describe('a node that reads or writes several variables at once', () => { + it('writes each variable it names and reads each one back', () => { + const { fixture, behavior } = bothWays(); + expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); + }); + + it('takes the port type from the variable, so a number output stays a number', () => { + const { fixture, behavior, get, set } = bothWays(); + fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean')); + fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' })); + expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']); + // The exec port is the node's own; only the variables come from the declaration. + expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']); + }); + + it('follows a renamed variable, edge and all', () => { + const { fixture, behavior, get } = bothWays(); + fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column')); + expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']); + expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true); + expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 }); + + fixture.stack.undo(); + expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']); + expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); + }); + + it('keeps the edge when a port is pointed at another variable', () => { + const { fixture, behavior, get } = bothWays(); + fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean')); + fixture.stack.execute( + new SetVariablePortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }), + ); + expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']); + expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true); + + fixture.stack.undo(); + expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']); + expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true); + }); + + it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => { + const { fixture, behavior, get } = bothWays(); + fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px')); + // Left standing rather than dropped: taking the port would have to take its edge too, and + // undo could not put either back. The inspector marks it as naming no variable. + expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']); + + fixture.stack.undo(); + expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']); + expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); + }); + + it('survives a save and load, which is what makes the pick more than a session', () => { + const { fixture, behavior } = bothWays(); + const loaded = roundTrip(fixture.project); + const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset; + const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!; + expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']); + expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 }); + }); +}); diff --git a/todo-notes/expand-und-pack-node.md b/todo-notes/expand-und-pack-node.md new file mode 100644 index 00000000..53cb980c --- /dev/null +++ b/todo-notes/expand-und-pack-node.md @@ -0,0 +1,2 @@ +beim expand und pack node habe ich mir eigentlich auch vorgestellt, dass man sich eine struct variable auswählt und automatisch die felder erzeugt werden. +wir können das so lassen wie es ist (eigene felder definieren), aber eine helfer auswahl von structs das die felder erstellt wäre gut. diff --git a/todo-notes/struct-und-listen-restposten.md b/todo-notes/struct-und-listen-restposten.md index 6df1d268..00059673 100644 --- a/todo-notes/struct-und-listen-restposten.md +++ b/todo-notes/struct-und-listen-restposten.md @@ -4,6 +4,13 @@ Drei Punkte, die beim Bau von List-Tab und Struct-Variablen bewusst liegen gebli standen am Ende von `done/nodes-mit-dynamischer-port-zahl.md` und sind hierher gezogen, damit sie nicht mit der erledigten Notiz verschwinden. +**Punkt 3 ist inzwischen gebaut** (siehe unten). Offen sind noch 1 und 2. + +Fehlender Nebenpunkt, der dabei aufgefallen ist: eine Listen-Variable kann ihre **Item-Art im +Editor gar nicht wählen** — `+ Variable ▸ List` legt immer `items: 'number'` an, und für +`items: 'struct'` gibt es keinen Feld-Editor. Eine Liste aus Structs entsteht deshalb heute nur +aus einer Projektdatei (Seeder); bearbeiten lässt sie sich danach normal. + ## 1. Der unverbundene Eingang zeigt das falsche Feld `inlineInputDef` (`NodeRegistry.ts:131`) kennt nur `boolean`, `string`/`any` und *alles andere* — @@ -21,15 +28,14 @@ Ein deklariertes Struct-Feld ist heute ein Primitiv (Zahl, Text, Ja/Nein, Farbe) einen **Pfad in der Slot-Adresse** (`ListSlot` in `state.ts` hat bewusst keinen). Die Naht ist die eine Funktion `resolveList` — eine Signaturänderung, kein Umbau. -## 3. Liste aus Structs +## 3. Liste aus Structs — ERLEDIGT -Der teuerste der drei. `ListValue` ist heute `readonly (number | string | boolean)[]` und -`FieldValue` hält fest, dass kein Feld selbst ein Struct ist (`Properties.ts:5-8`); `sameValue` -verlässt sich darauf ("recurses exactly one level deep", `Properties.ts:50`), und -`LIST_ITEM_KINDS` macht eine Liste homogen. +Gebaut. Zwei Annahmen dieser Notiz waren dabei **falsch**, und beide haben es billiger gemacht: -Structs in Listen machen `ListValue` und `StructValue` **gegenseitig rekursiv**: beliebige -Schachtelungstiefe, echter Deep-Compare, und die Items brauchen eine gemeinsame deklarierte -Feldform — die im Typmodell keine Heimat hat, solange sie nur an *einer* Property hängt. - -Setzt Punkt 2 voraus (Pfad in der Adresse). +- Es wird **nichts gegenseitig rekursiv**. Die Tiefe steckt in der *Deklaration*, nicht in einer + Konvention: `FieldKind` (Primitive) sagt, was ein Struct-**Feld** hält, `ListItemKind` + (= `FieldKind` + `struct`) sagt, was eine **Liste** hält. Damit ist `list → struct → primitiv` + die tiefste gespeicherte Form — kein beliebiges Schachteln, `sameValue` bleibt endlich. +- Es setzt **Punkt 2 nicht voraus**. Ein Struct-Item braucht keinen Pfad in der Adresse, weil es + **inline** gerendert wird — dieselbe Begründung, aus der ein Struct im Inspector ein Block ist + und keine eigene Navigation bekommt. `structFieldHandlers` trägt die Feld-Adresse. diff --git a/todo-notes/tetris-mit-structs-neu-bauen.md b/todo-notes/tetris-mit-structs-neu-bauen.md index b46ebaee..6e6c3559 100644 --- a/todo-notes/tetris-mit-structs-neu-bauen.md +++ b/todo-notes/tetris-mit-structs-neu-bauen.md @@ -1 +1,2 @@ Tetris Game mit Structs neu bauen +...und mit neuen `Get variables` / `Set variables` node diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 5a6fb7d5..25e6a1c1 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -110,8 +110,84 @@ export async function listTab(t) { check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title); await shot('04-empty'); + // 8. A list whose items are structs: each row is the bundle's fields under each other, built by + // the inspector's own struct renderer. Values are two digits so no row's index can match one. + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'celly', + type: 'behavior', + name: 'Celly', + props: { enabled: true, runInEditor: false, cells: [{ x: 30, y: 11 }, { x: 44, y: 55 }] }, + variables: [ + { + key: 'cells', + label: 'cells', + kind: 'list', + default: [], + items: 'struct', + fields: [{ key: 'x', kind: 'number' }, { key: 'y', kind: 'number' }], + }, + ], + graph: { nodes: [], edges: [] }, + }); + __gm.state.select({ kind: 'asset', assetId: 'celly' }); + __gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' }); + `); + await sleep(250); + info = await json('__gm.list.info()'); + check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info)); + check( + 'and a row reads as its fields, not as an object', + info.items[0] === 'x: 30, y: 11', + JSON.stringify(info.items), + ); + check('the tab knows its items are structs', info.kind === 'struct', info.kind); + check('the field labels of a bundle are on screen', (await find('x')) !== null); + await shot('05-struct-rows'); + + // Editing one field must leave the others and the neighbouring item alone – the whole item is + // rewritten on every keystroke, so a snapshot anywhere would silently revive an old value. + const firstField = await json("__gm.list.fieldAt(0, 'x')"); + await click(firstField.x, firstField.y); + await typeText('77'); + await press('Enter'); + await sleep(250); + const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); + check( + 'editing one field of one item leaves every other value untouched', + JSON.stringify(cells) === + JSON.stringify([ + { x: 77, y: 11 }, + { x: 44, y: 55 }, + ]), + JSON.stringify(cells), + ); + await undo(); + await sleep(200); + const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); + check( + 'and undo takes back just that field', + JSON.stringify(back) === + JSON.stringify([ + { x: 30, y: 11 }, + { x: 44, y: 55 }, + ]), + JSON.stringify(back), + ); + + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'celly'); + __gm.state.select(null); + __gm.state.preview(); + `); + await sleep(200); + await mutate('__gm.state.openList(null)'); await sleep(200); - const left = await json('__gm.state.project.assets.filter((a) => a.id === "listy").length'); + const left = await json( + '__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length', + ); check('the step leaves the fixture as it found it', left === 0, String(left)); } diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs new file mode 100644 index 00000000..799cff95 --- /dev/null +++ b/tools/e2e/23-variable-nodes.mjs @@ -0,0 +1,162 @@ +import { edge, node, port, variable } from '../lib/builders.mjs'; + +/** + * Get variables / Set variables: one node for several of them. Their ports are the behavior's + * variables, so the step checks all three sides of that – the run, the picking of a port from the + * variable list, and pointing an existing port at another variable without losing its edge. + * + * The step brings its own box and behavior; everything it adds is removed at the end. + */ + +const SCALE = 100; + +/** + * `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two + * numbers reach Move through the *variables* – one node writes both, one node reads both back. + * Neither speed is on the Move node, so a node that dropped a port would leave the box still. + */ +const logicGraph = { + nodes: [ + node('vp-update', 'OnUpdate', 0, 0), + node('vp-owner', 'Owner', 0, 300), + node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]), + node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]), + node('vp-move', 'Move', 560, 0, { scale: SCALE }), + ], + edges: [ + edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'), + edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'), + edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'), + edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'), + edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'), + edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'), + ], +}; + +export async function variableNodes(t) { + const { check, sleep, evaluate, json, shot, click, locate, undo, entity, asset, mutate, stop } = t; + const portAt = (nodeId, key, isOutput) => + json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); + const portsOf = async (nodeId) => + (await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []; + const select = async (nodeId) => { + await evaluate( + `__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`, + ); + await sleep(300); + }; + /** The inspector is drawn over the graph, so the last match is the row rather than the port. */ + const clickLast = async (text) => { + const p = await locate(text, true); + await click(p.x, p.y); + await sleep(200); + }; + + // 23a. A box wearing a behavior whose two speeds travel through its variables. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const mover = p.assets.find((a) => a.id === 'mover'); + const a = structuredClone(mover); + Object.assign(a, { + id: 'vp-logic', + name: 'Speeds', + props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 }, + variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])}, + graph: ${JSON.stringify(logicGraph)}, + }); + p.assets.unshift(a); + const make = (id, assetId, parentId, name) => { + const o = structuredClone(proto); + Object.assign(o, { + id, assetId, name, + position: { x: 0, y: 900, z: 0 }, + parentId, overrides: {}, instanceEdits: {}, + }); + return o; + }; + scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds')); + `); + await sleep(400); + + // 23b. The run: both variables are written by one node and read back by another, and the two + // speeds stay apart – 2 to the right for every 3 down. + await t.play(); + await sleep(300); + const before = await entity('vp-box'); + await sleep(1000); + const after = await entity('vp-box'); + const dx = after.x - before.x; + const dy = after.y - before.y; + check( + 'one node writes both variables, another reads both back, and the run uses them', + dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6, + `moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`, + ); + await stop(); + await sleep(300); + + // 23c. The node draws one port per variable it names, on the side its type says. + await select('vp-set'); + const vxIn = await portAt('vp-set', 'vx', false); + const vyIn = await portAt('vp-set', 'vy', false); + const vxOut = await portAt('vp-get', 'vx', true); + check( + 'the picked variables are ports: inputs on Set, outputs on Get', + Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut), + JSON.stringify({ vxIn, vyIn, vxOut }), + ); + + // 23d. The inspector picks a port from the behavior's variables instead of having it typed. + const add = await locate('+ Variable'); + check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add)); + await shot('01-variable-ports'); + await click(add.x, add.y); + await sleep(200); + await clickLast('vz'); + await sleep(300); + const grown = await portsOf('vp-set'); + check( + 'picking one adds it as a port, typed from the variable', + grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number', + JSON.stringify(grown), + ); + check('and the node grew that input', Boolean(await portAt('vp-set', 'vz', false))); + await shot('02-variable-added'); + await undo(); + await sleep(300); + check('undo takes it back', (await portsOf('vp-set')).length === 2); + + // 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a + // rewiring. `vp-e3` runs from this port to Move's dx. + await select('vp-get'); + await clickLast('vx'); + await sleep(200); + await clickLast('vz'); + await sleep(300); + const swapped = await portsOf('vp-get'); + const edgeStill = await json( + `(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`, + ); + check( + 'a port pointed at another variable keeps its edge', + swapped[0].key === 'vz' && edgeStill.port === 'vz', + JSON.stringify({ swapped, edgeStill }), + ); + await undo(); + await sleep(300); + check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx'); + + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'vp-logic'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain'); + __gm.state.select(null); + __gm.state.preview(); + `); + await sleep(300); + const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length'); + check('the step leaves the fixture as it found it', left === 0, String(left)); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index a9a7ab52..c68ea996 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -25,6 +25,7 @@ import { nestedBehavior } from './19-nested-behavior.mjs'; import { keyRepeat } from './20-key-repeat.mjs'; import { listTab } from './21-list.mjs'; import { structVariable } from './22-struct.mjs'; +import { variableNodes } from './23-variable-nodes.mjs'; export const STEPS = [ hierarchy, @@ -49,4 +50,5 @@ export const STEPS = [ keyRepeat, listTab, structVariable, + variableNodes, ]; diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index c904ddaf..8b90c6b1 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -46,11 +46,18 @@ export const node = (id, type, x, y, params = {}, ports) => ({ /** A port a behavior's start or end node declares. */ export const port = (key, kind) => ({ key, kind }); -/** A behavior variable declaration. `items` is the item kind of a `list`, ignored otherwise. */ -export const variable = (key, kind, value, items) => ({ +/** + * A behavior variable declaration. `items` is the item kind of a `list`, `fields` the shape of a + * `struct` – or of the items of a `list` whose items are structs. + */ +export const variable = (key, kind, value, items, fields) => ({ key, label: key, kind, default: value, ...(items ? { items } : {}), + ...(fields ? { fields } : {}), }); + +/** A struct field declaration, and the same shape a list of structs declares for its items. */ +export const field = (key, kind = 'number') => ({ key, kind }); diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index 3512d8a4..22bddf7f 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -7,6 +7,11 @@ * empty row joined in front of everything above and below it – with no text encoding in between, * so the numbers in the inspector are the numbers the game means. * + * What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one + * move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}` + * cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the + * master to accept in a single write, and it halves the index arithmetic in `Piece cell`. + * * The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the * work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`, * `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` @@ -19,7 +24,7 @@ * * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl] */ -import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; +import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); @@ -123,21 +128,25 @@ const SHAPES = [ const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]); /** - * All 28 rotations in one list, eight numbers each (x and y of the four cells), so one rotation - * starts at (shape·4 + rotation)·8 and its n-th cell two numbers further along. + * All 28 rotations in one list, four cells each, so one rotation starts at (shape·4 + rotation)·4 + * and its n-th cell is one entry further along. */ function shapeTable() { const table = []; for (const shape of SHAPES) { let cells = shape.cells; for (let rotation = 0; rotation < 4; rotation++) { - for (const [x, y] of cells) table.push(x, y); + for (const [x, y] of cells) table.push({ x, y }); cells = turned(cells, shape.size); } } return table; } +/** The four numbers that say which piece is where. */ +const PIECE_FIELDS = [field('shape'), field('rot'), field('x'), field('y')]; +const NO_PIECE = { shape: 0, rot: 0, x: 0, y: 0 }; + /** The column a shape starts in, so every piece appears centred over the well. */ const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)); @@ -224,41 +233,36 @@ function pieceCellGraph() { const g = graphBuilder(); g.at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), - port('shape', 'number'), - port('rot', 'number'), + port('piece', 'struct'), port('corner', 'number'), - port('baseX', 'number'), - port('baseY', 'number'), ]); - g.at('x4', 'Multiply', 1, 2, { b: 4 }); - g.at('plus', 'Add', 2, 2); - g.at('x8', 'Multiply', 3, 2, { b: 8 }); - g.at('c2', 'Multiply', 1, 5, { b: 2 }); - g.at('ix', 'Add', 4, 2); - g.at('iy', 'Add', 4, 5, { b: 1 }); - g.at('ax', 'ItemAt', 5, 1); - g.at('ay', 'ItemAt', 5, 5); - g.at('sx', 'Add', 6, 1); - g.at('sy', 'Add', 6, 5); - g.at('end', 'BehaviorEnd', 7, 0, {}, [port('x', 'number'), port('y', 'number')]); + // The whole piece arrives as one bundle and is taken apart here, where its parts are needed. + g.at('of', 'ExpandStruct', 1, 2, {}, PIECE_FIELDS); + g.at('x4', 'Multiply', 2, 2, { b: 4 }); + g.at('plus', 'Add', 3, 2); + g.at('x4b', 'Multiply', 4, 2, { b: 4 }); + g.at('index', 'Add', 5, 2); + g.at('cell', 'ItemAt', 6, 2); + g.at('at', 'ExpandStruct', 7, 2, {}, [field('x'), field('y')]); + g.at('sx', 'Add', 8, 1); + g.at('sy', 'Add', 8, 4); + g.at('end', 'BehaviorEnd', 9, 0, {}, [port('x', 'number'), port('y', 'number')]); - g.link('start', 'shape', 'x4', 'a'); + g.link('start', 'piece', 'of', 'value'); + g.link('of', 'shape', 'x4', 'a'); g.link('x4', 'result', 'plus', 'a'); - g.link('start', 'rot', 'plus', 'b'); - g.link('plus', 'result', 'x8', 'a'); - g.link('start', 'corner', 'c2', 'a'); - g.link('x8', 'result', 'ix', 'a'); - g.link('c2', 'result', 'ix', 'b'); - g.link('ix', 'result', 'iy', 'a'); - g.link('start', 'shapes', 'ax', 'list'); - g.link('ix', 'result', 'ax', 'index'); - g.link('start', 'shapes', 'ay', 'list'); - g.link('iy', 'result', 'ay', 'index'); - g.link('ax', 'item', 'sx', 'a'); - g.link('start', 'baseX', 'sx', 'b'); - g.link('ay', 'item', 'sy', 'a'); - g.link('start', 'baseY', 'sy', 'b'); + g.link('of', 'rot', 'plus', 'b'); + g.link('plus', 'result', 'x4b', 'a'); + g.link('x4b', 'result', 'index', 'a'); + g.link('start', 'corner', 'index', 'b'); + g.link('start', 'shapes', 'cell', 'list'); + g.link('index', 'result', 'cell', 'index'); + g.link('cell', 'item', 'at', 'value'); + g.link('at', 'x', 'sx', 'a'); + g.link('of', 'x', 'sx', 'b'); + g.link('at', 'y', 'sy', 'a'); + g.link('of', 'y', 'sy', 'b'); g.link('sx', 'result', 'end', 'x'); g.link('sy', 'result', 'end', 'y'); g.flow('start', 'end'); @@ -274,10 +278,7 @@ function fitsGraph() { g.at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), port('grid', 'list'), - port('shape', 'number'), - port('rot', 'number'), - port('x', 'number'), - port('y', 'number'), + port('piece', 'struct'), ]); g.set('ok', 'ok', 1, 0, true); g.at('loop', 'Repeat', 2, 0, { times: 4 }); @@ -285,10 +286,8 @@ function fitsGraph() { g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' }); g.link('loop', 'body', 'cell', 'exec'); - for (const key of ['shapes', 'shape', 'rot']) g.link('start', key, 'cell', key); + for (const key of ['shapes', 'piece']) g.link('start', key, 'cell', key); g.link('loop', 'index', 'cell', 'corner'); - g.link('start', 'x', 'cell', 'baseX'); - g.link('start', 'y', 'cell', 'baseY'); g.at('left', 'Compare', 4, 3, { op: '<', b: 0 }); g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 }); @@ -446,12 +445,16 @@ function spawnPieceGraph() { g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length }); g.at('shape', 'Floor', 2, 2); g.at('column', 'ItemAt', 3, 2); - g.at('end', 'BehaviorEnd', 4, 0, {}, [port('shape', 'number'), port('x', 'number')]); + // Handed back as a finished piece rather than as parts the caller has to assemble: the two + // fields it does not choose are the ones a new piece is defined to start at. + g.at('piece', 'PackStruct', 4, 2, { rot: 0, y: 0 }, PIECE_FIELDS); + g.at('end', 'BehaviorEnd', 5, 0, {}, [port('piece', 'struct')]); g.link('random', 'result', 'shape', 'value'); g.link('start', 'spawnX', 'column', 'list'); g.link('shape', 'result', 'column', 'index'); - g.link('shape', 'result', 'end', 'shape'); - g.link('column', 'item', 'end', 'x'); + g.link('shape', 'result', 'piece', 'shape'); + g.link('column', 'item', 'piece', 'x'); + g.link('piece', 'value', 'end', 'piece'); g.flow('start', 'end'); return g.done(); } @@ -466,9 +469,7 @@ function nextMoveGraph() { const { at, link, flow, get, set } = g; at('start', 'BehaviorStart', 0, 0, {}, [ - port('px', 'number'), - port('py', 'number'), - port('rot', 'number'), + port('piece', 'struct'), port('wantDx', 'number'), port('wantRot', 'number'), port('fall', 'number'), @@ -476,21 +477,21 @@ function nextMoveGraph() { port('stepTime', 'number'), ]); - // The candidate starts out as where the piece already is, and no wish is spent yet. - set('i-nx', 'nx', 1, 0); - set('i-ny', 'ny', 2, 0); - set('i-nrot', 'nrot', 3, 0); - set('i-kind', 'kind', 4, 0, ''); - set('i-fall', 'outFall', 5, 0); - set('i-dx', 'outDx', 6, 0); - set('i-rot', 'outRot', 7, 0); - link('start', 'px', 'i-nx', 'value'); - link('start', 'py', 'i-ny', 'value'); - link('start', 'rot', 'i-nrot', 'value'); + // The candidate starts out as the piece itself – one write, because it is one value. Each + // strand below then packs only the field it changes: writing a bundle keeps the fields it + // does not name, so "the same piece, one row lower" needs no copying. + at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS); + link('start', 'piece', 'of', 'value'); + set('i-next', 'next', 1, 0); + set('i-kind', 'kind', 2, 0, ''); + set('i-fall', 'outFall', 3, 0); + set('i-dx', 'outDx', 4, 0); + set('i-rot', 'outRot', 5, 0); + link('start', 'piece', 'i-next', 'value'); link('start', 'fall', 'i-fall', 'value'); link('start', 'wantDx', 'i-dx', 'value'); link('start', 'wantRot', 'i-rot', 'value'); - flow('start', 'i-nx', 'i-ny', 'i-nrot', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br'); + flow('start', 'i-next', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br'); // Turning wins: a quarter turn clockwise is one step around the four rotations of the shape. g.strand(); @@ -498,15 +499,17 @@ function nextMoveGraph() { at('t-br', 'Branch', 0, 0); at('t-plus1', 'Add', 1, 3, { b: 1 }); at('t-wrap', 'Modulo', 2, 3, { b: 4 }); - set('t-set', 'nrot', 1, 0); + at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]); + set('t-set', 'next', 1, 0); set('t-kind', 'kind', 2, 0, 'turn'); set('t-used', 'outRot', 3, 0, 0); link('start', 'wantRot', 't-want', 'a'); link('t-want', 'result', 't-br', 'condition'); link('t-br', 'then', 't-set', 'exec'); - link('start', 'rot', 't-plus1', 'a'); + link('of', 'rot', 't-plus1', 'a'); link('t-plus1', 'result', 't-wrap', 'a'); - link('t-wrap', 'result', 't-set', 'value'); + link('t-wrap', 'result', 't-pack', 'rot'); + link('t-pack', 'value', 't-set', 'value'); flow('t-set', 't-kind', 't-used', 'end'); // Then sideways, one column per key press. @@ -514,7 +517,8 @@ function nextMoveGraph() { at('m-moving', 'Not', 5, 3); at('m-br', 'Branch', 5, 0); at('m-shift', 'Add', 6, 3); - set('m-set', 'nx', 6, 0); + at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]); + set('m-set', 'next', 6, 0); set('m-kind', 'kind', 7, 0, 'move'); set('m-used', 'outDx', 8, 0, 0); link('t-br', 'else', 'm-br', 'exec'); @@ -522,9 +526,10 @@ function nextMoveGraph() { link('m-still', 'result', 'm-moving', 'value'); link('m-moving', 'result', 'm-br', 'condition'); link('m-br', 'then', 'm-set', 'exec'); - link('start', 'px', 'm-shift', 'a'); + link('of', 'x', 'm-shift', 'a'); link('start', 'wantDx', 'm-shift', 'b'); - link('m-shift', 'result', 'm-set', 'value'); + link('m-shift', 'result', 'm-pack', 'x'); + link('m-pack', 'value', 'm-set', 'value'); flow('m-set', 'm-kind', 'm-used', 'end'); // And otherwise the clock: holding the down key lets it run ten times as fast. @@ -550,7 +555,8 @@ function nextMoveGraph() { at('f-minus', 'Subtract', 7, 3); set('f-rest', 'outFall', 6, 0); at('f-down1', 'Add', 7, 5, { b: 1 }); - set('f-ny', 'ny', 7, 0); + at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]); + set('f-ny', 'next', 7, 0); set('f-kind', 'kind', 8, 0, 'fall'); flow('f-set', 'f-br'); link('f-fall', 'value', 'f-due', 'a'); @@ -560,8 +566,9 @@ function nextMoveGraph() { link('f-fall', 'value', 'f-minus', 'a'); link('start', 'stepTime', 'f-minus', 'b'); link('f-minus', 'result', 'f-rest', 'value'); - link('start', 'py', 'f-down1', 'a'); - link('f-down1', 'result', 'f-ny', 'value'); + link('of', 'y', 'f-down1', 'a'); + link('f-down1', 'result', 'f-pack', 'y'); + link('f-pack', 'value', 'f-ny', 'value'); flow('f-rest', 'f-ny', 'f-kind', 'end'); // Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing. link('f-br', 'else', 'end', 'exec'); @@ -569,18 +576,14 @@ function nextMoveGraph() { // Whatever the frame decided is handed back – the caller owns the variables, not this behavior. g.strand(); at('end', 'BehaviorEnd', 2, 0, {}, [ - port('nx', 'number'), - port('ny', 'number'), - port('nrot', 'number'), + port('next', 'struct'), port('kind', 'text'), port('fall', 'number'), port('wantDx', 'number'), port('wantRot', 'number'), ]); const back = [ - ['e-nx', 'nx', 'nx'], - ['e-ny', 'ny', 'ny'], - ['e-nrot', 'nrot', 'nrot'], + ['e-next', 'next', 'next'], ['e-kind', 'kind', 'kind'], ['e-fall', 'outFall', 'fall'], ['e-dx', 'outDx', 'wantDx'], @@ -604,20 +607,18 @@ function drawPieceGraph() { at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), - port('shape', 'number'), - port('rot', 'number'), - port('px', 'number'), - port('py', 'number'), + port('piece', 'struct'), port('colors', 'list'), ]); at('loop', 'Repeat', 1, 0, { times: 4 }); at('cell', 'RunBehavior', 2, 0, { behavior: 'piece-cell' }); flow('start', 'loop'); link('loop', 'body', 'cell', 'exec'); - for (const key of ['shapes', 'shape', 'rot']) link('start', key, 'cell', key); + for (const key of ['shapes', 'piece']) link('start', key, 'cell', key); link('loop', 'index', 'cell', 'corner'); - link('start', 'px', 'cell', 'baseX'); - link('start', 'py', 'cell', 'baseY'); + // Only the shape number is wanted here, for the colour. + at('of', 'ExpandStruct', 2, 10, {}, [field('shape')]); + link('start', 'piece', 'of', 'value'); const object = objectOfRound(g, 'p', 'loop', 'p', 3, 12); at('x', 'Multiply', 3, 4, { b: CELL }); @@ -630,7 +631,7 @@ function drawPieceGraph() { link('cell', 'y', 'y', 'a'); link('y', 'result', 'aty', 'a'); link('start', 'colors', 'color', 'list'); - link('start', 'shape', 'color', 'index'); + link('of', 'shape', 'color', 'index'); at('show', 'SetEnabled', 6, 0, { enabled: true }); at('place', 'SetPosition', 7, 0); @@ -756,26 +757,20 @@ function tetrisGraph() { // 3a) No piece: ask for one and write down what came back. at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' }); get('n-columns', 'spawnX', 2, 8); - set('n-shape', 'shape', 3, 5); - set('n-rot', 'rot', 4, 5, 0); - set('n-px', 'px', 5, 5); - set('n-py', 'py', 6, 5, 0); - set('n-fall', 'fall', 7, 5, 0); - set('n-has', 'hasPiece', 8, 5, true); + set('n-piece', 'piece', 3, 5); + set('n-fall', 'fall', 4, 5, 0); + set('n-has', 'hasPiece', 5, 5, true); link('u-br', 'else', 'n-spawn', 'exec'); link('n-columns', 'value', 'n-spawn', 'spawnX'); - link('n-spawn', 'shape', 'n-shape', 'value'); - link('n-spawn', 'x', 'n-px', 'value'); - flow('n-spawn', 'n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has'); + link('n-spawn', 'piece', 'n-piece', 'value'); + flow('n-spawn', 'n-piece', 'n-fall', 'n-has'); // 3b) A piece is falling: one behavior decides which single move this frame attempts. strand(); at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' }); link('u-br', 'then', 'c-move', 'exec'); const moveIn = [ - ['px', 'px'], - ['py', 'py'], - ['rot', 'rot'], + ['piece', 'piece'], ['wantDx', 'wantDx'], ['wantRot', 'wantRot'], ['fall', 'fall'], @@ -788,9 +783,7 @@ function tetrisGraph() { link('u-update', 'dt', 'c-move', 'dt'); const moveOut = [ - ['nx', 'nx'], - ['ny', 'ny'], - ['nrot', 'nrot'], + ['next', 'next'], ['kind', 'kind'], ['fall', 'fall'], ['wantDx', 'wantDx'], @@ -807,56 +800,45 @@ function tetrisGraph() { at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' }); get('t-shapes', 'shapes', 0, 3); get('t-grid', 'grid', 0, 5); - get('t-shape', 'shape', 0, 7); - get('t-nrot', 'nrot', 0, 9); - get('t-nx', 'nx', 0, 11); - get('t-ny', 'ny', 0, 13); + get('t-next', 'next', 0, 7); flow('c-out-wantRot', 't-fits'); link('t-shapes', 'value', 't-fits', 'shapes'); link('t-grid', 'value', 't-fits', 'grid'); - link('t-shape', 'value', 't-fits', 'shape'); - link('t-nrot', 'value', 't-fits', 'rot'); - link('t-nx', 'value', 't-fits', 'x'); - link('t-ny', 'value', 't-fits', 'y'); + link('t-next', 'value', 't-fits', 'piece'); // 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the // piece has landed (or, still at the very top, the well is full). at('m-br', 'Branch', 1, 0); - get('m-nx', 'nx', 2, 3); - get('m-ny', 'ny', 3, 3); - get('m-nrot', 'nrot', 4, 3); - set('m-px', 'px', 2, 0); - set('m-py', 'py', 3, 0); - set('m-rot', 'rot', 4, 0); + get('m-next', 'next', 2, 3); + set('m-piece', 'piece', 2, 0); flow('t-fits', 'm-br'); link('t-fits', 'ok', 'm-br', 'condition'); - link('m-br', 'then', 'm-px', 'exec'); - link('m-nx', 'value', 'm-px', 'value'); - link('m-ny', 'value', 'm-py', 'value'); - link('m-nrot', 'value', 'm-rot', 'value'); - flow('m-px', 'm-py', 'm-rot'); + link('m-br', 'then', 'm-piece', 'exec'); + link('m-next', 'value', 'm-piece', 'value'); get('m-kind', 'kind', 2, 7); at('m-landed', 'Equals', 3, 7, { b: 'fall' }); at('m-brland', 'Branch', 3, 5); - get('m-py2', 'py', 4, 8); - at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 }); + get('m-py2', 'piece', 4, 8); + at('m-pieceY', 'ExpandStruct', 5, 10, {}, [field('y')]); + at('m-attop', 'Compare', 6, 8, { op: '<=', b: 0 }); at('m-brover', 'Branch', 5, 5); at('m-stop', 'StopGame', 6, 5); link('m-br', 'else', 'm-brland', 'exec'); link('m-kind', 'value', 'm-landed', 'a'); link('m-landed', 'result', 'm-brland', 'condition'); link('m-brland', 'then', 'm-brover', 'exec'); - link('m-py2', 'value', 'm-attop', 'a'); + link('m-py2', 'value', 'm-pieceY', 'value'); + link('m-pieceY', 'y', 'm-attop', 'a'); link('m-attop', 'result', 'm-brover', 'condition'); link('m-brover', 'then', 'm-stop', 'exec'); // 6) Wherever the piece ended up – newly spawned or moved – it is drawn there. strand(); at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' }); - flow('m-rot', 'd-draw'); + flow('m-piece', 'd-draw'); flow('n-has', 'd-draw'); - ['shapes', 'shape', 'rot', 'px', 'py', 'colors'].forEach((key, i) => { + ['shapes', 'piece', 'colors'].forEach((key, i) => { get(`d-${key}`, key, 0, i * 2 + 3); link(`d-${key}`, 'value', 'd-draw', key); }); @@ -865,11 +847,13 @@ function tetrisGraph() { strand(); at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' }); get('l-grid', 'grid', 0, 3); - get('l-shape', 'shape', 0, 5); + get('l-piece', 'piece', 0, 5); + at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]); set('l-setgrid', 'grid', 1, 0); link('m-brover', 'else', 'l-lock', 'exec'); link('l-grid', 'value', 'l-lock', 'grid'); - link('l-shape', 'value', 'l-lock', 'shape'); + link('l-piece', 'value', 'l-shape', 'value'); + link('l-shape', 'shape', 'l-lock', 'shape'); link('l-lock', 'grid', 'l-setgrid', 'value'); flow('l-lock', 'l-setgrid'); @@ -910,16 +894,12 @@ function behavior(id, name, variables, graph) { const tetrisVariables = [ variable('grid', 'list', EMPTY_GRID, 'number'), - variable('shapes', 'list', shapeTable(), 'number'), + variable('shapes', 'list', shapeTable(), 'struct', [field('x'), field('y')]), variable('colors', 'list', SHAPE_COLORS, 'color'), variable('spawnX', 'list', spawnColumns(), 'number'), - variable('shape', 'number', 0), - variable('rot', 'number', 0), - variable('px', 'number', 3), - variable('py', 'number', 0), - variable('nx', 'number', 0), - variable('ny', 'number', 0), - variable('nrot', 'number', 0), + // The falling piece and the one move being tried on it – four numbers each, and never apart. + variable('piece', 'struct', NO_PIECE, undefined, PIECE_FIELDS), + variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS), variable('kind', 'text', ''), variable('hasPiece', 'boolean', false), variable('fall', 'number', 0), @@ -984,9 +964,7 @@ const project = { 'next-move', 'Next move', [ - variable('nx', 'number', 0), - variable('ny', 'number', 0), - variable('nrot', 'number', 0), + variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS), variable('kind', 'text', ''), variable('outFall', 'number', 0), variable('outDx', 'number', 0), diff --git a/tools/smoke-tetris.mjs b/tools/smoke-tetris.mjs index db3ad8a7..5674ba15 100644 --- a/tools/smoke-tetris.mjs +++ b/tools/smoke-tetris.mjs @@ -27,16 +27,16 @@ const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4; /** The master graph and the nine behaviors the game is split into. */ const GRAPHS = { - tetris: { nodes: 84, edges: 94 }, - 'spawn-piece': { nodes: 5, edges: 6 }, - 'next-move': { nodes: 44, edges: 63 }, - fits: { nodes: 18, edges: 29 }, - 'draw-piece': { nodes: 14, edges: 26 }, + tetris: { nodes: 69, edges: 76 }, + 'spawn-piece': { nodes: 6, edges: 7 }, + 'next-move': { nodes: 44, edges: 61 }, + fits: { nodes: 18, edges: 26 }, + 'draw-piece': { nodes: 15, edges: 24 }, 'lock-piece': { nodes: 20, edges: 23 }, 'clear-lines': { nodes: 29, edges: 36 }, 'show-lines': { nodes: 5, edges: 5 }, 'draw-board': { nodes: 11, edges: 17 }, - 'piece-cell': { nodes: 12, edges: 19 }, + 'piece-cell': { nodes: 11, edges: 17 }, }; /** The steps the master calls. `piece-cell` is missing on purpose: three of these call it. */ @@ -82,6 +82,8 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy /** The variables of the Tetris behavior, live from the running world. */ const vars = async () => (await entity('ogame')).props; + /** The falling piece as the game now keeps it: one bundle, not four loose numbers. */ + const piece = async () => (await vars()).piece; const filledCells = (grid) => grid.filter((c) => c !== EMPTY).length; const pieceCells = async () => { const w = await world(); @@ -151,7 +153,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy check( 'playing puts a piece into the well', spawned.hasPiece === true && cells.every((c) => c && c.props.enabled === true), - JSON.stringify({ hasPiece: spawned.hasPiece, shape: spawned.shape, px: spawned.px }), + JSON.stringify({ hasPiece: spawned.hasPiece, piece: spawned.piece }), ); const start = await piecePosition(); check( @@ -184,10 +186,10 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy back.every((c, i) => c.column === beforeMove[i].column), back.map((c) => c.column).join(','), ); - const beforeTurn = (await vars()).rot; + const beforeTurn = (await piece()).rot; await press('ArrowUp'); await sleep(150); - const afterTurn = (await vars()).rot; + const afterTurn = (await piece()).rot; check( 'the up arrow turns the piece', afterTurn === (beforeTurn + 1) % 4, @@ -266,7 +268,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy over ? JSON.stringify({ mode, - py: over.py, + piece: over.piece, hasPiece: over.hasPiece, kind: over.kind, lines: over.lines,