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

+92 -19
View File
@@ -1,10 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
@@ -47,7 +59,12 @@ export class GraphEditor extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
@@ -78,7 +95,10 @@ export class GraphEditor extends Widget {
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('load', () => {
this.assetId = null;
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
@@ -183,11 +203,13 @@ export class GraphEditor extends Widget {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
@@ -205,13 +227,21 @@ export class GraphEditor extends Widget {
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
@@ -261,7 +291,13 @@ export class GraphEditor extends Widget {
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
@@ -282,7 +318,8 @@ export class GraphEditor extends Widget {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
@@ -290,15 +327,37 @@ export class GraphEditor extends Widget {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
@@ -320,7 +379,10 @@ export class GraphEditor extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -362,7 +424,11 @@ export class GraphEditor extends Widget {
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
@@ -371,7 +437,14 @@ export class GraphEditor extends Widget {
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
+12 -2
View File
@@ -47,8 +47,18 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
.filter(
(d) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
);
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
this.scroll.relayoutContent();
}
+112 -29
View File
@@ -1,7 +1,20 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import {
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputs,
inlineValue,
type NodeCategory,
type NodeDef,
type PortDef,
type PortType,
type Value,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
@@ -22,23 +35,34 @@ const PAD = 6;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
switch (category) {
case 'event': return c.categoryEvent;
case 'flow': return c.categoryFlow;
case 'data': return c.categoryData;
case 'variable': return c.categoryVariable;
case 'action': return c.categoryAction;
case 'event':
return c.categoryEvent;
case 'flow':
return c.categoryFlow;
case 'data':
return c.categoryData;
case 'variable':
return c.categoryVariable;
case 'action':
return c.categoryAction;
}
}
export function portColor(type: PortType): number {
const c = Theme.colors;
switch (type) {
case 'exec': return c.portExec;
case 'number': return c.portNumber;
case 'boolean': return c.portBoolean;
case 'string': return c.portString;
case 'object': return c.portObject;
case 'any': return c.portAny;
case 'exec':
return c.portExec;
case 'number':
return c.portNumber;
case 'boolean':
return c.portBoolean;
case 'string':
return c.portString;
case 'object':
return c.portObject;
case 'any':
return c.portAny;
}
}
@@ -62,7 +86,10 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
constructor(
readonly node: GraphNode,
readonly def: NodeDef,
) {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
@@ -77,9 +104,16 @@ export class NodeView extends Widget {
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height =
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -103,8 +137,12 @@ export class NodeView extends Widget {
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
] as const) {
for (let i = 0; i < list.length; i++)
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
}
}
@@ -119,7 +157,14 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const add = (
text: string,
style: TextStyleToken,
x: number,
y: number,
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
@@ -141,9 +186,24 @@ export class NodeView extends Widget {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
add(
label,
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
rowTop,
'left',
NODE_WIDTH / 2 + 20,
);
}
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
if (output)
add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
);
}
}
@@ -153,16 +213,27 @@ export class NodeView extends Widget {
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
this.ports
.moveTo(x - r, y - r)
.lineTo(x + 1, y - r)
.lineTo(x + r + 1, y)
.lineTo(x + 1, y + r)
.lineTo(x - r, y + r)
.closePath()
.fill(portColor('exec'));
} else {
this.ports.circle(x, y, r).fill(portColor(port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
@@ -172,21 +243,32 @@ export class NodeView extends Widget {
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
function formatParam(
param: PropertyDef,
value: PropertyValue | undefined,
asset: BehaviorAsset | null,
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'enum' && param.key === 'asset') {
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
if (
param.kind === 'enum' &&
param.key === 'variable' &&
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${value}?`;
return formatValue(value);
}
function formatValue(value: unknown): string {
function formatValue(value: Value): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
return asString(value);
}
/** Truncates with an ellipsis so the text fits `maxWidth`. */
@@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
cut = cut.slice(0, -1);
return cut + '…';
}