Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+5049 -666

No files matched your search

+4 -1
View File
@@ -6,7 +6,10 @@ export class CommandStack {
private readonly undoStack: Command[] = [];
private readonly redoStack: Command[] = [];
constructor(private readonly project: Project, private readonly limit = 200) {}
constructor(
private readonly project: Project,
private readonly limit = 200,
) {}
execute(command: Command): void {
command.execute(this.project);
+4 -1
View File
@@ -7,7 +7,10 @@ import type { Command } from './Command';
* so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(readonly label: string, private readonly commands: readonly Command[]) {}
constructor(
readonly label: string,
private readonly commands: readonly Command[],
) {}
execute(project: Project): void {
const done: Command[] = [];
+4 -1
View File
@@ -15,7 +15,10 @@ export abstract class SetBagValueCommand<T> implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
protected constructor(protected readonly key: string, label: string) {
protected constructor(
protected readonly key: string,
label: string,
) {
this.label = label;
}
+42 -8
View File
@@ -55,9 +55,18 @@ interface SlotMemo<T> {
* with the template along – copies the user changed on their own keep their value. Returns
* the memos in application order; `restoreSlots` undoes them.
*/
function applyToTemplate<T>(project: Project, link: TemplateLink, slots: readonly Slot<T>[], values: readonly T[]): SlotMemo<T>[] {
function applyToTemplate<T>(
project: Project,
link: TemplateLink,
slots: readonly Slot<T>[],
values: readonly T[],
): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! }));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({
object: link.template,
slot,
previous: before[i]!,
}));
for (const { object } of instancesOf(project, link.template.id)) {
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
@@ -84,7 +93,11 @@ export class ApplyOverrideToAssetCommand implements Command {
private previousOverride: PropertyValue | undefined;
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to asset`;
}
@@ -116,7 +129,11 @@ export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to container`;
}
@@ -136,7 +153,11 @@ export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Revert ${key}`;
}
@@ -160,7 +181,11 @@ export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<number>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
}
@@ -168,7 +193,12 @@ export class ApplyPositionToContainerCommand implements Command {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const axes = this.axis ? [this.axis] : ALL_AXES;
this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a]));
this.memos = applyToTemplate(
project,
link,
axes.map(positionSlot),
axes.map((a) => object.position[a]),
);
}
undo(): void {
@@ -181,7 +211,11 @@ export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Revert ${axis}` : 'Revert position';
}
+14 -3
View File
@@ -104,7 +104,10 @@ export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
constructor(private readonly assetId: string, private readonly name: string) {}
constructor(
private readonly assetId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
@@ -119,7 +122,11 @@ export class RenameAssetCommand implements Command {
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(private readonly assetId: string, key: string, label: string) {
protected constructor(
private readonly assetId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -134,7 +141,11 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string, private readonly value: PropertyValue) {
constructor(
assetId: string,
key: string,
private readonly value: PropertyValue,
) {
super(assetId, key, `Set ${key}`);
}
+9 -2
View File
@@ -18,7 +18,10 @@ export class AddColliderCommand implements Command {
readonly assetId = newId();
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly targetId: string) {}
constructor(
private readonly sceneId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -26,7 +29,11 @@ export class AddColliderCommand implements Command {
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
const asset = buildAsset('collider', nextAssetName(project, `${target.name || targetAsset.name} collider`), this.assetId);
const asset = buildAsset(
'collider',
nextAssetName(project, `${target.name || targetAsset.name} collider`),
this.assetId,
);
if (targetAsset.type === 'sprite') {
const props = resolveSceneObjectProps(targetAsset, target);
asset.props.width = Number(props.width);
+42 -9
View File
@@ -1,6 +1,13 @@
import { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset';
import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy';
import {
findObject,
localPositionFor,
subtreeIds,
subtreeRoots,
treeOrder,
worldPosition,
} from '../model/hierarchy';
import type { Position } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
@@ -25,7 +32,10 @@ export class CreateContainerCommand implements Command {
readonly objectId = newId();
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
constructor(
private readonly sceneId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -41,14 +51,25 @@ export class CreateContainerCommand implements Command {
};
// The subtrees that move into the container, in tree order (parents first).
const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id)));
const roots = new Set(
subtreeRoots(
scene,
selected.map((o) => o.id),
),
);
const memberIds = subtreeIds(scene, roots);
const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id));
const members = treeOrder(scene)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
// Template: same structure with private ids, roots relative to the centre.
const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset;
const asset = buildAsset(
'container',
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) {
const isRoot = roots.has(object.id);
@@ -57,18 +78,30 @@ export class CreateContainerCommand implements Command {
id: templateIds.get(object.id)!,
assetId: object.assetId,
name: object.name,
position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
// A view-only offset belongs to the placed object, not to the template (see container.ts).
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
templateId: '',
});
}
// Scene: the container takes the roots' place, the members become its instance.
this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId }));
const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId);
this.previous = members.map((o) => ({
id: o.id,
parentId: o.parentId,
position: { ...o.position },
templateId: o.templateId,
}));
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(scene, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
const world = worldPosition(scene, object);
+55 -10
View File
@@ -1,4 +1,11 @@
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import {
edgesInto,
edgesOfNode,
findNode,
type GraphEdge,
type GraphNode,
portsCompatible,
} from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -14,11 +21,23 @@ export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
constructor(
private readonly assetId: string,
type: string,
x: number,
y: number,
params: PropertyBag = {},
) {
// An unknown type is rejected in `execute`, not here: constructors run outside the
// editor's error handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } };
this.node = {
id: newId(),
type,
x: Math.round(x),
y: Math.round(y),
params: { ...defaultsOf(def?.params ?? []), ...params },
};
this.label = def ? `Add ${def.label}` : `Add ${type}`;
}
@@ -38,7 +57,10 @@ export class RemoveNodeCommand implements Command {
readonly label = 'Remove node';
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
constructor(private readonly assetId: string, private readonly nodeId: string) {}
constructor(
private readonly assetId: string,
private readonly nodeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -65,7 +87,12 @@ export class MoveNodeCommand implements Command {
readonly label = 'Move node';
private previous = { x: 0, y: 0 };
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number) {}
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly x: number,
private readonly y: number,
) {}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
@@ -83,7 +110,12 @@ export class MoveNodeCommand implements Command {
/** Sets a node param or the inline value of an unconnected data input. */
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
key: string,
private readonly value: PropertyValue,
) {
super(key, `Set ${key}`);
}
@@ -111,7 +143,11 @@ export class ConnectCommand implements Command {
readonly edge: GraphEdge;
private replaced: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
constructor(
private readonly assetId: string,
from: { node: string; port: string },
to: { node: string; port: string },
) {
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
}
@@ -124,7 +160,8 @@ export class ConnectCommand implements Command {
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (!portsCompatible(out.type, inp.type))
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
@@ -143,7 +180,10 @@ export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, private readonly edgeId: string) {}
constructor(
private readonly assetId: string,
private readonly edgeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -157,5 +197,10 @@ export class DisconnectCommand implements Command {
}
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port;
return (
a.from.node === b.from.node &&
a.from.port === b.from.port &&
a.to.node === b.to.node &&
a.to.port === b.to.port
);
}
+49 -10
View File
@@ -2,7 +2,13 @@ import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
@@ -53,7 +59,10 @@ export class RemoveSceneObjectsCommand implements Command {
readonly label: string;
private removed: Removed<SceneObject>[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {
constructor(
private readonly sceneId: string,
private readonly objectIds: readonly string[],
) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
}
@@ -83,7 +92,11 @@ export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly position: Position) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly position: Position,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -105,7 +118,11 @@ export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly offset: Vec2) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly offset: Vec2,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -122,7 +139,11 @@ export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly name: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -137,7 +158,12 @@ export class RenameSceneObjectCommand implements Command {
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) {
protected constructor(
private readonly sceneId: string,
private readonly objectId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -152,7 +178,12 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
/** Sets a per-object override of an asset property. */
export class SetOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) {
constructor(
sceneId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
super(sceneId, objectId, key, `Override ${key}`);
}
@@ -180,9 +211,16 @@ export class ClearOverrideCommand extends OverrideCommand {
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } };
private previous: { parentId: string; position: Position } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
};
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly parentId: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -190,7 +228,8 @@ export class SetParentCommand implements Command {
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children');
if (isSelfOrAncestor(scene, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object);
+16 -4
View File
@@ -10,7 +10,11 @@ export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
constructor(
private readonly assetId: string,
key: string,
kind: VariableKind,
) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
@@ -18,7 +22,8 @@ export class AddVariableCommand implements Command {
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`);
if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
asset.props[this.def.key] = this.def.default;
}
@@ -36,7 +41,10 @@ export class RemoveVariableCommand implements Command {
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
constructor(private readonly assetId: string, private readonly key: string) {
constructor(
private readonly assetId: string,
private readonly key: string,
) {
this.label = `Remove variable ${key}`;
}
@@ -75,7 +83,11 @@ export class RemoveVariableCommand implements Command {
export class RenameVariableCommand implements Command {
readonly label: string;
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename variable ${from}`;
}
+10 -3
View File
@@ -41,7 +41,12 @@ export class GraphInterpreter {
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
fire(
type: string,
ctx: NodeContext,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
@@ -66,7 +71,8 @@ export class GraphInterpreter {
}
private execute(node: GraphNode, ctx: NodeContext): void {
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const inputs = this.collectInputs(node, ctx);
@@ -99,7 +105,8 @@ export class GraphInterpreter {
const def = requireNodeDef(source.type);
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.outputCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
+15 -3
View File
@@ -22,12 +22,20 @@ export interface EntityHandle {
/** World position of a handle: its local position plus those of all ancestors. */
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
return sumAlongChain(handle, (h) => h.parent, (h) => h);
return sumAlongChain(
handle,
(h) => h.parent,
(h) => h,
);
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(handle, (h) => h.props, (h) => h.parent);
return enabledAlongChain(
handle,
(h) => h.props,
(h) => h.parent,
);
}
/** Where the mouse is (world units) and what it did this frame. */
@@ -83,7 +91,11 @@ export interface NodeContext {
* local to `parent` (world when none). Returns null when the asset does not exist or cannot
* be placed (a scene). Spawned behaviors run their `On start` on the next tick.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: EntityHandle | null,
): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
+7 -1
View File
@@ -88,7 +88,13 @@ export function inlineInputDef(port: PortDef): PropertyDef {
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
default:
return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 };
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
}
+8 -1
View File
@@ -10,7 +10,14 @@ export const Move: NodeDef = {
"Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " +
'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' +
'other solid colliders and slide along them; blocked x/y tell which axes hit.',
inputs: [exec(), obj('object'), num('dx'), num('dy'), withDefault(num('scale'), 1), withDefault(num('dt'), 1)],
inputs: [
exec(),
obj('object'),
num('dx'),
num('dy'),
withDefault(num('scale'), 1),
withDefault(num('dt'), 1),
],
outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')],
params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }],
run: (ctx, inputs, params) => {
+2 -1
View File
@@ -5,7 +5,8 @@ export const RestartGame: NodeDef = {
type: 'RestartGame',
label: 'Restart game',
category: 'action',
description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.',
description:
'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.',
inputs: [exec()],
outputs: [],
params: [],
+3 -1
View File
@@ -28,7 +28,9 @@ export const Compare: NodeDef = {
},
],
run: (_ctx, inputs, params) => {
const compare = OPERATORS[String(params.op)] ?? OPERATORS['==']!;
// The fallback matches the param's declared default, so a node whose setting is missing
// (an old document, a hand-written graph) behaves like a freshly created one.
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
},
};
+67 -10
View File
@@ -73,23 +73,80 @@ import { SetVariable } from './variable/SetVariable';
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
// events
OnStart, OnUpdate, OnKeyDown, OnKeyUp, OnKeyHeld, OnTimer,
OnCollisionEnter, OnCollisionStay, OnCollisionExit, OnClick,
OnStart,
OnUpdate,
OnKeyDown,
OnKeyUp,
OnKeyHeld,
OnTimer,
OnCollisionEnter,
OnCollisionStay,
OnCollisionExit,
OnClick,
// flow
Branch, Sequence,
Branch,
Sequence,
// variables
GetVariable, SetVariable,
GetVariable,
SetVariable,
// data: references and properties
Self, Owner, Parent, ObjectByName, NameOf, IsAsset, IsEnabled, GetProperty, GetPosition,
Self,
Owner,
Parent,
ObjectByName,
NameOf,
IsAsset,
IsEnabled,
GetProperty,
GetPosition,
// data: literals and math
NumberLiteral, BooleanLiteral, StringLiteral,
Add, Subtract, Multiply, Divide, Min, Max, Abs, Sqrt, Clamp, Floor, Ceil, Round, Modulo, Random, Time,
NumberLiteral,
BooleanLiteral,
StringLiteral,
Add,
Subtract,
Multiply,
Divide,
Min,
Max,
Abs,
Sqrt,
Clamp,
Floor,
Ceil,
Round,
Modulo,
Random,
Time,
// data: text and comparison
ToText, Join, Equals, Compare, And, Or, Not,
ToText,
Join,
Equals,
Compare,
And,
Or,
Not,
// data: vectors and input
Length, Distance, Normalize, AxisInput, KeyIsDown, MousePosition, MouseButton, IsHovered, IsTouching,
Length,
Distance,
Normalize,
AxisInput,
KeyIsDown,
MousePosition,
MouseButton,
IsHovered,
IsTouching,
// actions
Move, SetPosition, SetProperty, SetEnabled, SetText, Spawn, Destroy, Log, StopGame, RestartGame,
Move,
SetPosition,
SetProperty,
SetEnabled,
SetText,
Spawn,
Destroy,
Log,
StopGame,
RestartGame,
];
for (const def of BUILT_IN_NODES) registerNode(def);
+25 -4
View File
@@ -16,17 +16,34 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
/** Outputs of the three collision events: the other body, its collider and the own collider. */
export const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')];
export const collisionOutputs = (): PortDef[] => [
exec(),
obj('other'),
obj('otherCollider', 'other collider'),
obj('collider'),
];
// --- params ----------------------------------------------------------------------------------
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */
export const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
export const variableParam: PropertyDef = {
key: 'variable',
label: 'Variable',
kind: 'enum',
default: '',
options: [],
};
/** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] };
export const assetParam: PropertyDef = {
key: 'asset',
label: 'Asset',
kind: 'enum',
default: '',
options: [],
};
// --- node factories --------------------------------------------------------------------------
@@ -59,7 +76,11 @@ export function unaryMath(type: string, description: string, fn: (value: number)
}
/** A data node combining the booleans `a` and `b`. */
export function binaryLogic(type: string, description: string, fn: (a: boolean, b: boolean) => boolean): NodeDef {
export function binaryLogic(
type: string,
description: string,
fn: (a: boolean, b: boolean) => boolean,
): NodeDef {
return {
type,
label: type,
+3 -1
View File
@@ -9,5 +9,7 @@ export const GetVariable: NodeDef = {
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
run: (ctx, _inputs, params) => ({
outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null },
}),
};
+2 -1
View File
@@ -65,7 +65,8 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export type Asset =
SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+9 -1
View File
@@ -1,5 +1,13 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const;
export const ASSET_TYPES = [
'scene',
'camera',
'sprite',
'text',
'behavior',
'collider',
'container',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
+5 -3
View File
@@ -1,5 +1,4 @@
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
import { isSceneAsset } from './Asset';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */
@@ -49,7 +48,10 @@ export interface SceneObjectRef {
}
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
+6 -1
View File
@@ -37,7 +37,12 @@ export interface SceneObject {
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject {
export function createSceneObject(
id: string,
assetId: string,
position: Vec2 | Position,
parentId = '',
): SceneObject {
return {
id,
assetId,
+14 -3
View File
@@ -16,7 +16,11 @@ export interface TemplateLink {
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
*/
export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined {
export function templateLinkOf(
project: Project,
tree: ObjectTree,
object: SceneObject,
): TemplateLink | undefined {
if (!object.templateId) return undefined;
for (const containerObject of chainOf(tree, object).slice(1)) {
const asset = findAsset(project, containerObject.assetId);
@@ -46,12 +50,19 @@ const MAX_NESTING = 8;
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
*/
export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] {
export function instantiateContainer(
project: Project,
asset: ContainerAsset,
containerObjectId: string,
depth = 0,
): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
const parentId = template.parentId
? (ids.get(template.parentId) ?? containerObjectId)
: containerObjectId;
result.push({
id,
assetId: template.assetId,
+16 -4
View File
@@ -31,7 +31,11 @@ function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefine
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
return descendantsBelow(
id,
(parentId) => childrenOf(tree, parentId),
(o) => o.id,
);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
@@ -58,7 +62,11 @@ export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
const object = findObject(tree, id);
return object ? chainOf(tree, object).slice(1).map((o) => o.id) : [];
return object
? chainOf(tree, object)
.slice(1)
.map((o) => o.id)
: [];
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
@@ -80,7 +88,10 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
(o) =>
withEditorOffset
? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z }
: o.position,
);
}
@@ -128,7 +139,8 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object))
result.push({ object, depth: 0 });
}
return result;
}
+15 -3
View File
@@ -13,7 +13,11 @@ import type { Position } from './Properties';
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
for (
let current: T | null | undefined = start;
current && !seen.has(current);
current = parentOf(current)
) {
seen.add(current);
yield current;
}
@@ -31,7 +35,11 @@ export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | un
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
export function sumAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
positionOf: (item: T) => Position,
): Position {
const total: Position = { x: 0, y: 0, z: 0 };
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
@@ -47,7 +55,11 @@ export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | und
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
export function descendantsBelow<T, K>(
rootKey: K,
childrenOf: (key: K) => readonly T[],
keyOf: (item: T) => K,
): T[] {
const result: T[] = [];
const visit = (key: K) => {
for (const child of childrenOf(key)) {
+7 -2
View File
@@ -28,7 +28,9 @@ export type VariableKind = (typeof VARIABLE_KINDS)[number];
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
@@ -91,7 +93,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
/** Type schema with the common properties in front – the effective schema of an asset type. */
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
]),
) as Record<AssetType, PropertySchema>;
/** Schema shared by every asset of a type (common properties first). */
+4 -1
View File
@@ -25,7 +25,10 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT };
return {
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
h: lines.length * size * LINE_HEIGHT,
};
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
+4 -1
View File
@@ -17,7 +17,10 @@ export interface WorldRect {
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom));
return { w: Number(props.width ?? DEFAULTS.width) / zoom, h: Number(props.height ?? DEFAULTS.height) / zoom };
return {
w: Number(props.width ?? DEFAULTS.width) / zoom,
h: Number(props.height ?? DEFAULTS.height) / zoom,
};
}
export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect {
+22 -4
View File
@@ -19,8 +19,20 @@ export type PlaneAxis = 'x' | 'y';
/** The shape described by collider properties, centred on `centre` (a world position). */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) };
return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)) };
if (props.shape === 'circle')
return {
kind: 'circle',
x: centre.x,
y: centre.y,
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)),
};
return {
kind: 'box',
x: centre.x,
y: centre.y,
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
};
}
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
@@ -36,7 +48,8 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
@@ -86,7 +99,12 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
* `mover` approached from the far side, i.e. it did not overlap before its move.
*/
export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: PlaneAxis, dir: 1 | -1): number {
export function axisCorrection(
mover: ColliderShape,
obstacle: ColliderShape,
axis: PlaneAxis,
dir: 1 | -1,
): number {
if (!shapesOverlap(mover, obstacle)) return 0;
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
}
+5 -1
View File
@@ -16,7 +16,11 @@ function propsEnabled(props: PropertyBag): boolean {
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
export function enabledAlongChain<T>(
start: T,
propsOf: (item: T) => PropertyBag,
parentOf: (item: T) => T | null,
): boolean {
for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false;
return true;
}
+19 -7
View File
@@ -1,5 +1,10 @@
import type { Project } from '../model/Project';
import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError } from './ProjectStore';
import {
type LoadedProject,
type ProjectStore,
type ProjectSummary,
StaleRevisionError,
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
@@ -14,15 +19,22 @@ export class HttpProjectStore implements ProjectStore {
}
async load(id: string): Promise<LoadedProject> {
const body = await this.request<{ revision: number; project: unknown }>('GET', `projects/${encodeURIComponent(id)}`);
const body = await this.request<{ revision: number; project: unknown }>(
'GET',
`projects/${encodeURIComponent(id)}`,
);
return { project: deserializeProject(body.project, id), revision: body.revision };
}
async save(project: Project, revision: number): Promise<number> {
const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, {
revision,
project: serializeProject(project),
});
const body = await this.request<{ revision: number }>(
'PUT',
`projects/${encodeURIComponent(project.id)}`,
{
revision,
project: serializeProject(project),
},
);
return body.revision;
}
@@ -46,7 +58,7 @@ export class HttpProjectStore implements ProjectStore {
});
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown = null;
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
+37 -7
View File
@@ -6,7 +6,17 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { hasParentCycle } from '../model/walk';
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
import {
coerceValue,
COMMON_PROPS,
isCommonProp,
type PropertyDef,
type PropertySchema,
schemaFor,
VARIABLE_KINDS,
variableDef,
type VariableKind,
} from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -44,7 +54,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (type === 'scene' || type === 'container') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
assets.push({
id,
type,
name,
props,
variables: variables.get(id) ?? [],
graph: normalizeGraph(a.graph),
});
} else {
assets.push({ id, type, name, props });
}
@@ -107,12 +124,14 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
for (const item of Array.isArray(raw) ? raw : []) {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
? (v.kind as VariableKind)
: 'number';
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
}
return defs;
@@ -130,7 +149,8 @@ function normalizeGraph(raw: unknown): Graph {
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')
params[key] = value;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
graph.nodes.push(node);
@@ -142,10 +162,20 @@ function normalizeGraph(raw: unknown): Graph {
const to = asRecord(e.to);
const id = typeof e.id === 'string' ? e.id : '';
if (!id || edgeIds.has(id)) continue;
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
if (
typeof from.node !== 'string' ||
typeof from.port !== 'string' ||
typeof to.node !== 'string' ||
typeof to.port !== 'string'
)
continue;
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
edgeIds.add(id);
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
const edge: GraphEdge = {
id,
from: { node: from.node, port: from.port },
to: { node: to.node, port: to.port },
};
graph.edges.push(edge);
}
return graph;