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

+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 },
}),
};