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

+52 -28
View File
@@ -1,4 +1,11 @@
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
import {
type EntityHandle,
GraphInterpreter,
isHandleEnabled,
type NodeContext,
type TraceListener,
} from '../../core/graph';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -56,7 +63,12 @@ export class BehaviorSystem implements System {
private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
for (const entity of world.entities) {
if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
if (
entity.type !== 'behavior' ||
!isHandleEnabled(entity) ||
this.instances.some((i) => i.entity === entity)
)
continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
@@ -73,34 +85,38 @@ export class BehaviorSystem implements System {
update(world: World, dt: number): void {
this.syncInstances(world);
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
this.run(instance, (i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt;
while (state.elapsed >= interval) {
state.elapsed -= interval;
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
this.run(
instance,
(i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt;
while (state.elapsed >= interval) {
state.elapsed -= interval;
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
}
}
}
this.fireCollisions(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
}, dt);
this.fireCollisions(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
},
dt,
);
}
}
@@ -140,14 +156,20 @@ export class BehaviorSystem implements System {
}
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void {
private run(
instance: Instance,
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
dt: number,
): void {
const world = this.world;
if (!world) return;
const ctx = this.context(instance.entity, world, dt);
try {
body(instance.interpreter, ctx);
} catch (error) {
world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
world.log.push(
`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
);
world.stopRequested = true;
}
}
@@ -184,6 +206,8 @@ export class BehaviorSystem implements System {
},
log: (message) => {
world.log.push(message);
// The `Log` node's whole purpose: the author wants this in the browser console.
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
+45 -8
View File
@@ -1,6 +1,13 @@
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
import { parentChain } from '../../core/model/walk';
import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider';
import {
axisCorrection,
colliderShape,
colliderSize,
isTrigger,
type PlaneAxis,
shapesOverlap,
} from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
@@ -79,22 +86,52 @@ export class CollisionSystem implements System {
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity));
return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) };
const obstacles =
own.length === 0
? []
: world.entities.filter(
(c) =>
c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity),
);
return {
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
};
}
private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private slideAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
);
for (let i = 0; i < steps; i++)
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private stepAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
+18 -7
View File
@@ -8,18 +8,29 @@ export class Input {
private readonly released = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
this.down.add(e.key);
this.press(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => {
if (this.down.delete(e.key)) this.released.add(e.key);
};
private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key);
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
private readonly onBlur = () => {
private readonly onBlur = () => this.releaseAll();
// The state machine below is what the DOM handlers above drive – and what tests drive
// directly, since a headless run has no window to dispatch key events on.
press(key: string): void {
if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat
this.down.add(key);
}
release(key: string): void {
if (this.down.delete(key)) this.released.add(key);
}
releaseAll(): void {
for (const key of this.down) this.released.add(key);
this.down.clear();
};
}
attach(): void {
window.addEventListener('keydown', this.onKeyDown);