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}`;
}