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 -12
View File
@@ -33,7 +33,14 @@ export class World {
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
readonly pointer: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
};
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
@@ -41,7 +48,10 @@ export class World {
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
private structureChanged = false;
private constructor(readonly project: Project, readonly sceneId: string) {}
private constructor(
readonly project: Project,
readonly sceneId: string,
) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string): World {
@@ -75,11 +85,12 @@ export class World {
if (!asset || !isPlaceableType(asset.type)) continue;
keep.add(this.materialize(object, asset));
}
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
for (let i = this.entities.length - 1; i >= 0; i--)
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const entity of this.entities) {
const object = findObject(scene, entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
}
}
@@ -87,7 +98,18 @@ export class World {
private materialize(object: SceneObject, asset: Asset): Entity {
let entity = this.findByObjectId(object.id);
if (!entity) {
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
entity = {
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: '',
x: 0,
y: 0,
z: 0,
props: {},
parent: null,
};
this.entities.push(entity);
}
entity.assetId = asset.id;
@@ -109,14 +131,21 @@ export class World {
* during this tick already). Position is local to `parent`, world when there is none.
* Returns null for unknown or unplaceable assets.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null {
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: Entity | null,
): Entity | null {
const scene = findScene(this.project, this.sceneId);
const asset = findAsset(this.project, assetId);
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
// A container brings its content along (parented below it, in template order).
const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])];
const created = [
object,
...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
@@ -152,9 +181,11 @@ export class World {
const asset = object && findAsset(this.project, entity.assetId);
if (!object || !asset) continue;
const p = object.position;
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z };
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
object.position = { x: entity.x, y: entity.y, z: entity.z };
const base = resolveSceneObjectProps(asset, object);
for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value;
for (const [key, value] of Object.entries(entity.props))
if (key in base && base[key] !== value) object.overrides[key] = value;
}
return spawned || scene.objects.length !== before;
}
@@ -210,8 +241,13 @@ export class World {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point));
if (colliders.length > 0)
return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some(
(e) =>
(e.type === 'sprite' || e.type === 'text') &&
footprintContains(e.type, e.props, worldPositionOf(e), point),
);
}
childrenOf(entity: Entity): Entity[] {
@@ -220,7 +256,11 @@ export class World {
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] {
return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child);
return descendantsBelow(
entity,
(parent) => this.childrenOf(parent),
(child) => child,
);
}
findByObjectId(objectId: string): Entity | undefined {