Check the declared type when a graph writes a typed variable (#271)
A list variable with an item type only kept its shape in the editor. A graph could write any list into it at runtime, and the List node had no type at all. Now the declaration decides what a write may store. - Writing a list into a typed list variable leaves out every item that does not have exactly the fields of the type. The rest is written and the console names each item that was left out, like Add item does. - The same rule holds for typed list fields inside a struct variable and for lists inside typed items, at any depth. - A typed struct variable refuses a struct with a field the type does not have, and a value that is no struct. A struct with missing fields still merges in as before, so a partial Pack keeps working. - The check runs in the Variables node (own variables and Variables assets) and in the Properties node when it sets a behavior variable of another object. - Ports learn their declaration through GraphLookup: what a variable declares about its type, and which behavior owns a graph. The rule itself lives in the new src/core/graph/typedValues.ts, fitsType moved there from SetItem. - The List node gets a Type setting. With a type its items are shaped and marked, follow the struct asset, count when the type is deleted and offer Split into fields in For each. The mark never reaches the running game. - A type on a List node holding something other than structs is refused with a console line. - Setting the List node back to None removes the marks, unlike a list variable. A node list has no per-item type row, so marked items could never be freed there. Undo brings everything back. - tests/core/kinds.test.ts looked for the value as the first parameter of a literal. Type now comes first on the List node, so the test finds the value by its key. Tests: - Gate: 198 files, 2869 tests (2856 + 13), CHECK EXIT 0 - e2e 55: 19 checks (+55j write at runtime, +2 for 55k List node) - e2e 54: 16 checks, smokes 24 / 19 / 21 - Five mutations, each fails between 1 and 4 tests - e2e total 1606 is computed (1603 + 3), no full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2282a81fb
commit
0f6e628616
19 files changed
+682
-52
No files matched your search
@@ -2,9 +2,10 @@ import type { GraphLookup } from '../graph/lookup';
|
||||
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { pictureSize } from '../properties/aspect';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { isGraphAsset, isVariableOwner } from './Asset';
|
||||
import { bagWithoutItemTypes } from './Properties';
|
||||
import { typeFields } from './structTypes';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { fieldTypings, typeFields, typingOf } from './structTypes';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { resolvePicture } from './texture';
|
||||
|
||||
/**
|
||||
@@ -36,11 +37,19 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
|
||||
structType: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'struct');
|
||||
return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null;
|
||||
if (!asset) return null;
|
||||
const fields = typeFields(asset);
|
||||
return { name: asset.name, fields: fields.map((f) => f.key), inner: fieldTypings(fields) ?? {} };
|
||||
},
|
||||
textureSize: (pictureId) => {
|
||||
const ref = resolvePicture(project, pictureId);
|
||||
return ref ? pictureSize(ref) : null;
|
||||
},
|
||||
ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '',
|
||||
variableTyping: (assetId, key) => {
|
||||
const owner = findAsset(project, assetId);
|
||||
const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === key) : undefined;
|
||||
return def && typingOf(def);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
VARIABLES,
|
||||
variablesSourceOf,
|
||||
} from '../graph/nested';
|
||||
import type { ValueTyping } from '../graph/lookup';
|
||||
import { splitField, splitPortKey } from '../graph/portSplits';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
@@ -56,6 +57,8 @@ import { findAsset, findAssetOf, type Project } from './Project';
|
||||
|
||||
const PACK_STRUCT = 'PackStruct';
|
||||
const EXPAND_STRUCT = 'ExpandStruct';
|
||||
const LIST_NODE = 'List';
|
||||
export const LIST_VALUE = 'value';
|
||||
|
||||
/** Deeper than this only a cycle written by hand can go; it is cut there. */
|
||||
const MAX_DEPTH = 32;
|
||||
@@ -72,6 +75,33 @@ export function nodeStructType(node: GraphNode): string {
|
||||
return isTypedNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
|
||||
}
|
||||
|
||||
/** A `List` node's items may all be one Struct asset (#271), picked in its Type setting. */
|
||||
export function isListNode(node: GraphNode): boolean {
|
||||
return node.type === LIST_NODE;
|
||||
}
|
||||
|
||||
export function listNodeItemType(node: GraphNode): string {
|
||||
return isListNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
|
||||
}
|
||||
|
||||
/** What a declaration says about the structs its value holds – what a run holds a write to (#271). */
|
||||
export function typingOf(def: PropertyDef | StructFieldDef): ValueTyping | undefined {
|
||||
if (def.kind === 'list') return def.itemType ? { itemType: def.itemType } : undefined;
|
||||
if (def.kind !== 'struct') return undefined;
|
||||
const fields = fieldTypings(def.fields);
|
||||
if (!def.structType && !fields) return undefined;
|
||||
return { ...(def.structType ? { structType: def.structType } : {}), ...(fields ? { fields } : {}) };
|
||||
}
|
||||
|
||||
/** `typingOf` for each field that declares anything, by key; undefined when none does. */
|
||||
export function fieldTypings(fields: readonly StructFieldDef[]): Record<string, ValueTyping> | undefined {
|
||||
const typed = fields.flatMap((f) => {
|
||||
const typing = typingOf(f);
|
||||
return typing ? [[f.key, typing] as const] : [];
|
||||
});
|
||||
return typed.length > 0 ? Object.fromEntries(typed) : undefined;
|
||||
}
|
||||
|
||||
/** A declared variable as the struct field it becomes in every value of the type. */
|
||||
function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
|
||||
if (def.kind === 'struct')
|
||||
@@ -394,6 +424,10 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
|
||||
const value = holder.read();
|
||||
if (value !== undefined) holder.write(renamedItems(project, value, rename));
|
||||
}
|
||||
for (const node of typedListNodes(project)) {
|
||||
const value = node.params[LIST_VALUE];
|
||||
if (value !== undefined) rec.set(node.params, LIST_VALUE, renamedItems(project, value, rename));
|
||||
}
|
||||
if (rename.path.length > 0) return;
|
||||
renameSplitEdges(project, rec, rename);
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
@@ -469,6 +503,12 @@ function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
|
||||
);
|
||||
}
|
||||
|
||||
function typedListNodes(project: Project): GraphNode[] {
|
||||
return project.assets.flatMap((asset) =>
|
||||
isGraphAsset(asset) ? asset.graph.nodes.filter((node) => listNodeItemType(node) !== '') : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** The ports follow the fields; an edge on a port that is gone goes with it, as its typed-in value does. */
|
||||
function syncNodePorts(
|
||||
graph: Graph,
|
||||
@@ -499,9 +539,9 @@ function syncNodePorts(
|
||||
|
||||
/**
|
||||
* Brings every typed place in line with its Struct asset: the copied fields, every value kept for
|
||||
* it, the ports of typed `Pack`/`Expand` nodes. A type holding another type is only right once that
|
||||
* one is, so the passes repeat until one changes nothing. Every write goes through `rec`, so the
|
||||
* whole reach of an edit is undone in one go.
|
||||
* it, the ports of typed `Pack`/`Expand` nodes, the items of a typed `List` node. A type holding
|
||||
* another type is only right once that one is, so the passes repeat until one changes nothing.
|
||||
* Every write goes through `rec`, so the whole reach of an edit is undone in one go.
|
||||
*/
|
||||
export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldRename): void {
|
||||
if (rename) applyRename(project, rec, rename);
|
||||
@@ -530,6 +570,12 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
|
||||
const type = structAssetOf(project, nodeStructType(node));
|
||||
if (type) syncNodePorts(graph, node, typeFields(type), rec);
|
||||
}
|
||||
for (const node of typedListNodes(project)) {
|
||||
const type = structAssetOf(project, listNodeItemType(node));
|
||||
const value = node.params[LIST_VALUE];
|
||||
if (type && isList(value))
|
||||
rec.set(node.params, LIST_VALUE, shapedItems(project, markedItems(value, type.id)));
|
||||
}
|
||||
for (const { graph, node, split } of splitOutputs(project)) {
|
||||
const type = structAssetOf(project, split.structType);
|
||||
if (type) syncSplit(graph, node, split, type, rec);
|
||||
@@ -561,6 +607,10 @@ function outputDecl(
|
||||
const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === port) : undefined;
|
||||
return def && fieldOfVariable(def);
|
||||
}
|
||||
if (isListNode(node)) {
|
||||
const itemType = listNodeItemType(node);
|
||||
return itemType ? { key: port, kind: 'list', itemType } : undefined;
|
||||
}
|
||||
const typeId = nodeStructType(node);
|
||||
if (!typeId) return undefined;
|
||||
if (node.type === PACK_STRUCT) return { key: port, kind: 'struct', fields: [], structType: typeId };
|
||||
@@ -602,6 +652,7 @@ export function structTypeUsages(
|
||||
lists: lists.filter((id) => id === typeId).length,
|
||||
nodes:
|
||||
typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length +
|
||||
typedListNodes(project).filter((node) => listNodeItemType(node) === typeId).length +
|
||||
splitOutputs(project).filter(({ split }) => split.structType === typeId).length,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user