diff --git a/demo/boids-swarm.html b/demo/boids-swarm.html new file mode 100644 index 0000000..17facdf --- /dev/null +++ b/demo/boids-swarm.html @@ -0,0 +1,809 @@ + + +ecs-js — Boids Swarm + +
+
+

Boids Swarm

+

Classic Reynolds flocking — separation, alignment, cohesion — with predator pursuit. SoA storage, phase scheduling, archetypes, entity scripts, Not() queries, and events.

+
+ + + +
+
+ + +
+ + Flocking rules +
+ + + + + + +
+ + Perception +
+ + + + +
+ + Populations +
+ + + + +
+ + Speed +
+ + + + + + +
+ + Predator / prey +
+ + + + + + +
+ + Display +
+ + + + +
+ +
+ Time0.00 + Prey- + Predators- + FPS- + Catches0 + Scouts0 +
+
+
Event log
+
+
+
+ Phases: sense → steer → integrate → wrap → render → scripts.
+ SoA store, archetypes, Not() queries, entity scripts, events. +
+
+ +
+ diff --git a/demo/food-web.html b/demo/food-web.html new file mode 100644 index 0000000..0225e89 --- /dev/null +++ b/demo/food-web.html @@ -0,0 +1,821 @@ + + +ecs-js — Wildlands RTS + +
+
+

🐾 Wildlands Command 🌌

+

An RTS-flavored biome: plants bloom, grazers sweep in flocks, and predators prowl with command arrows. Seasonal scripts strike at random.

+
+ + + +
+
+ + +
+
Biome tuning
+
+ + + + + + +
+
Populations
+
+ + + + + + +
+
Display
+
+ + + + +
+
+ ⏱️ Time0.00 + 🌿 Plants— + 🦌 Herbivores— + 🦊 Predators— + 📡 Events— + 🛰️ StatusOK +
+
+
Event log
+
+
+
Phases: climate → grow → spread → sense → act → decay → render → scripts. Scripts inject seasonal shocks and rally pings.
+
+ +
+ diff --git a/hierarchy.js b/hierarchy.js index f3abc89..e58a282 100644 --- a/hierarchy.js +++ b/hierarchy.js @@ -154,10 +154,9 @@ function _reinsertSameParent(world, child, parent, opts){ /** @private */ function _bumpIndices(world, parent, startIdx, delta){ - let i=0; for (const c of children(world, parent)){ - if (i >= startIdx){ const s = world.get(c, Sibling); world.set(c, Sibling, { index: (s.index|0) + delta }); } - i++; + const s = world.get(c, Sibling); + if ((s.index|0) >= startIdx) world.set(c, Sibling, { index: (s.index|0) + delta }); } } /** @private */ diff --git a/index.html b/index.html index c66d23e..184f9b3 100644 --- a/index.html +++ b/index.html @@ -107,6 +107,8 @@

ECS-js

diff --git a/tests/hierarchy.edgecases.test.mjs b/tests/hierarchy.edgecases.test.mjs new file mode 100644 index 0000000..ab36bd8 --- /dev/null +++ b/tests/hierarchy.edgecases.test.mjs @@ -0,0 +1,133 @@ +import { assert, test } from './testlib.js'; +import { World } from '../core.js'; +import { + attach, detach, children, childCount, ensureParent, + Parent, Sibling, getParent, indexOf +} from '../hierarchy.js'; + +test('attach throws on self-attach', () => { + const world = new World(); + const e = world.create(); + assert.throws(() => attach(world, e, e), Error, /cannot parent to self/); +}); + +test('attach throws when parent is descendant of child (cycle)', () => { + const world = new World(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + attach(world, b, a); + attach(world, c, b); + // a -> b -> c; attaching a under c would create a cycle + assert.throws(() => attach(world, a, c), Error, /cannot create a cycle/); +}); + +test('attach throws when both before and after specified', () => { + const world = new World(); + const parent = world.create(); + const x = world.create(); + const y = world.create(); + attach(world, x, parent); + attach(world, y, parent); + const child = world.create(); + assert.throws( + () => attach(world, child, parent, { before: x, after: y }), + Error, + /at most one of before\/after/ + ); +}); + +test('attach throws when before target is not child of parent', () => { + const world = new World(); + const p1 = world.create(); + const p2 = world.create(); + const x = world.create(); + attach(world, x, p2); // x belongs to p2, not p1 + ensureParent(world, p1); + const child = world.create(); + assert.throws( + () => attach(world, child, p1, { before: x }), + Error, + /before target not child of parent/ + ); +}); + +test('attach throws when after target is not child of parent', () => { + const world = new World(); + const p1 = world.create(); + const p2 = world.create(); + const x = world.create(); + attach(world, x, p2); // x belongs to p2, not p1 + ensureParent(world, p1); + const child = world.create(); + assert.throws( + () => attach(world, child, p1, { after: x }), + Error, + /after target not child of parent/ + ); +}); + +test('detach is a no-op for entity without Sibling component', () => { + const world = new World(); + const e = world.create(); + // should not throw, just return the entity + const result = detach(world, e); + assert.equal(result, e); + assert.equal(world.has(e, Sibling), false, 'entity should still lack Sibling'); +}); + +test('detach an only child empties the parent', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + assert.equal(childCount(world, parent), 1); + + detach(world, child); + assert.equal(childCount(world, parent), 0); + const p = world.get(parent, Parent); + assert.equal(p.first, 0, 'first should be 0'); + assert.equal(p.last, 0, 'last should be 0'); +}); + +test('detach with opts.remove strips Sibling component', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + assert.ok(world.has(child, Sibling)); + + detach(world, child, { remove: true }); + assert.equal(world.has(child, Sibling), false, 'Sibling should be removed'); +}); + +test('detach without opts.remove zeroes Sibling fields', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + + detach(world, child); + assert.ok(world.has(child, Sibling), 'Sibling should still be present'); + const s = world.get(child, Sibling); + assert.equal(s.parent, 0, 'parent should be zeroed'); + assert.equal(s.prev, 0, 'prev should be zeroed'); + assert.equal(s.next, 0, 'next should be zeroed'); + assert.equal(s.index, 0, 'index should be zeroed'); +}); + +test('children yields nothing for entity without Parent component', () => { + const world = new World(); + const e = world.create(); + const list = [...children(world, e)]; + assert.deepEqual(list, []); +}); + +test('children yields nothing for ensured but empty parent', () => { + const world = new World(); + const e = world.create(); + ensureParent(world, e); + assert.ok(world.has(e, Parent), 'should have Parent component'); + const list = [...children(world, e)]; + assert.deepEqual(list, []); +}); diff --git a/tests/hierarchy.reparent.test.mjs b/tests/hierarchy.reparent.test.mjs new file mode 100644 index 0000000..408e130 --- /dev/null +++ b/tests/hierarchy.reparent.test.mjs @@ -0,0 +1,155 @@ +import { assert, test } from './testlib.js'; +import { World } from '../core.js'; +import { + attach, detach, reparent, destroySubtree, + children, childCount, getParent, indexOf, + Parent, Sibling, ensureParent +} from '../hierarchy.js'; + +test('reparent moves child from one parent to another', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const child = world.create(); + attach(world, child, pA); + assert.equal(getParent(world, child), pA); + assert.equal(childCount(world, pA), 1); + + reparent(world, child, pB); + assert.equal(getParent(world, child), pB); + assert.equal(childCount(world, pA), 0, 'old parent should have 0 children'); + assert.equal(childCount(world, pB), 1, 'new parent should have 1 child'); +}); + +test('reparent with before/after ordering', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const x = world.create(); + const y = world.create(); + const child = world.create(); + attach(world, x, pB); + attach(world, y, pB); + attach(world, child, pA); + + reparent(world, child, pB, { before: y }); + const kids = [...children(world, pB)]; + assert.deepEqual(kids, [x, child, y]); +}); + +test('reparent to self throws via attach guard', () => { + const world = new World(); + const e = world.create(); + assert.throws(() => reparent(world, e, e), Error, /cannot parent to self/); +}); + +test('reparent creating cycle throws', () => { + const world = new World(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + attach(world, b, a); + attach(world, c, b); + // a -> b -> c; reparenting a under c would be a cycle + assert.throws(() => reparent(world, a, c), Error, /cannot create a cycle/); +}); + +test('deep tree: 5-level hierarchy traversal', () => { + const world = new World(); + const root = world.create(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + const d = world.create(); + const e = world.create(); + attach(world, a, root); + attach(world, b, a); + attach(world, c, b); + attach(world, d, c); + attach(world, e, d); + + assert.equal(getParent(world, a), root); + assert.equal(getParent(world, b), a); + assert.equal(getParent(world, c), b); + assert.equal(getParent(world, d), c); + assert.equal(getParent(world, e), d); + assert.equal(childCount(world, root), 1); + assert.equal(childCount(world, d), 1); +}); + +test('destroySubtree on deep tree destroys all descendants', () => { + const world = new World(); + const root = world.create(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + const d = world.create(); + const e = world.create(); + attach(world, a, root); + attach(world, b, a); + attach(world, c, b); + attach(world, d, c); + attach(world, e, d); + + destroySubtree(world, root); + for (const eid of [root, a, b, c, d, e]) { + assert.equal(world.isAlive(eid), false, `entity ${eid} should be dead`); + } +}); + +test('destroySubtree on leaf destroys only that node', () => { + const world = new World(); + const parent = world.create(); + const leaf = world.create(); + const sibling = world.create(); + attach(world, leaf, parent); + attach(world, sibling, parent); + assert.equal(childCount(world, parent), 2); + + // destroySubtree calls world.destroy() which removes component stores + // but does NOT call detach(), so the parent's count is not decremented. + // Use detach + destroy for clean removal from a surviving parent. + detach(world, leaf); + destroySubtree(world, leaf); + assert.equal(world.isAlive(leaf), false, 'leaf should be dead'); + assert.ok(world.isAlive(parent), 'parent should be alive'); + assert.ok(world.isAlive(sibling), 'sibling should be alive'); + assert.equal(childCount(world, parent), 1, 'parent should have 1 child remaining'); +}); + +test('reparent preserves sibling indices at new parent', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const x = world.create(); + const child = world.create(); + const y = world.create(); + const m = world.create(); + const n = world.create(); + + // pA has [x, child, y] + attach(world, x, pA); + attach(world, child, pA); + attach(world, y, pA); + // pB has [m, n] + attach(world, m, pB); + attach(world, n, pB); + + assert.equal(indexOf(world, x), 0); + assert.equal(indexOf(world, child), 1); + assert.equal(indexOf(world, y), 2); + + reparent(world, child, pB); + + // pA should now have [x, y] with contiguous indices 0, 1 + assert.deepEqual([...children(world, pA)], [x, y]); + assert.equal(childCount(world, pA), 2); + assert.equal(indexOf(world, x), 0); + assert.equal(indexOf(world, y), 1, 'y should be bumped down to index 1 after middle child removed'); + + // pB should have [m, n, child] with contiguous indices + assert.deepEqual([...children(world, pB)], [m, n, child]); + assert.equal(indexOf(world, m), 0); + assert.equal(indexOf(world, n), 1); + assert.equal(indexOf(world, child), 2); +}); diff --git a/tests/world.errors.test.mjs b/tests/world.errors.test.mjs new file mode 100644 index 0000000..1f6d600 --- /dev/null +++ b/tests/world.errors.test.mjs @@ -0,0 +1,75 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, Component, WorldBuilder } from '../core.js'; + +const Position = defineComponent('ErrPos', { x: 0, y: 0 }); +const Validated = defineComponent('ErrVal', { x: 0 }, { + validate: (r) => r.x > 0 +}); + +test('add on dead entity throws', () => { + const world = new World(); + const eid = world.create(); + world.destroy(eid); + assert.throws(() => world.add(eid, Position, { x: 1 }), Error, /add: entity not alive/); +}); + +test('set on missing component throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.set(eid, Position, { x: 1 }), Error, /set: entity lacks component/); +}); + +test('mutate on missing component throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.mutate(eid, Position, r => { r.x = 1; }), Error, /mutate: entity lacks component/); +}); + +test('tick without scheduler throws', () => { + const world = new World(); + assert.throws(() => world.tick(1), Error, /tick: no scheduler installed/); +}); + +test('add with failing validation throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.add(eid, Validated, { x: -1 }), Error, /Validation failed/); +}); + +test('set with failing validation throws', () => { + const world = new World(); + const eid = world.create(); + world.add(eid, Validated, { x: 5 }); + assert.throws(() => world.set(eid, Validated, { x: -1 }), Error, /Validation failed/); +}); + +test('Component builder with empty name throws', () => { + assert.throws(() => Component(''), Error, /non-empty name/); +}); + +test('withSchedulerFn with non-function throws', () => { + const builder = new WorldBuilder(); + assert.throws(() => builder.withSchedulerFn('not a fn'), Error, /scheduler must be a function/); +}); + +test('setScheduler with non-function throws', () => { + const world = new World(); + assert.throws(() => world.setScheduler(42), Error, /scheduler must be a function/); +}); + +test('onStrictError with non-function non-null throws', () => { + const world = new World(); + assert.throws(() => world.onStrictError(42), Error, /handler must be a function or null/); +}); + +test('strict mode mid-tick mutation without handler is caught by tick', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.setScheduler((w) => { + w.add(eid, Position, { x: 99 }); + }); + // tick catches the scheduler error internally via logError + world.tick(1); + // The add should NOT have succeeded — strict threw, tick caught it + assert.equal(world.has(eid, Position), false, 'mutation should not apply when strict throws'); +}); diff --git a/tests/world.performance.test.mjs b/tests/world.performance.test.mjs new file mode 100644 index 0000000..fa944b3 --- /dev/null +++ b/tests/world.performance.test.mjs @@ -0,0 +1,134 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, defineTag, Not } from '../core.js'; +import { attach, destroySubtree, children } from '../hierarchy.js'; + +const Position = defineComponent('PerfPos', { x: 0, y: 0 }); +const Velocity = defineComponent('PerfVel', { dx: 0, dy: 0 }); + +test('bulk creation: 10,000 entities', () => { + const world = new World(); + const ids = new Set(); + for (let i = 0; i < 10000; i++) { + ids.add(world.create()); + } + assert.equal(world.alive.size, 10000, 'all 10K entities should be alive'); + assert.equal(ids.size, 10000, 'all ids should be unique'); +}); + +test('bulk query: 10,000 entities with component', () => { + const world = new World(); + for (let i = 0; i < 10000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: i * 2 }); + } + const result = world.query(Position); + assert.equal(result.count(), 10000); + + // Verify iteration works + let count = 0; + for (const [id, pos] of result) { count++; } + assert.equal(count, 10000, 'iteration should yield all 10K'); +}); + +test('query cache invalidation under churn', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: 0 }); + entities.push(e); + } + + // Warm the cache + assert.equal(world.query(Position).count(), 1000); + + // Remove Position from first 500 + for (let i = 0; i < 500; i++) { + world.remove(entities[i], Position); + } + + // Add Position to 500 new entities + for (let i = 0; i < 500; i++) { + const e = world.create(); + world.add(e, Position, { x: 1000 + i, y: 0 }); + } + + // Re-query — cache should have been invalidated + assert.equal(world.query(Position).count(), 1000, 'query should reflect churn'); +}); + +test('SoA store: create and query 5,000 entities', () => { + const world = new World({ store: 'soa' }); + for (let i = 0; i < 5000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: i * 3 }); + } + const result = world.query(Position); + assert.equal(result.count(), 5000); + + // Verify data integrity via iteration + let count = 0; + for (const [id, pos] of result) { + assert.ok(Number.isFinite(pos.x), 'x should be finite'); + count++; + } + assert.equal(count, 5000); +}); + +test('deferred op queue at MAX boundary (1001 destroys)', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1001; i++) { + entities.push(world.create()); + } + assert.equal(world.alive.size, 1001); + + world.setScheduler((w) => { + for (const e of entities) w.destroy(e); + }); + + // First tick: scheduler queues 1001 destroys, flush processes MAX=1000 + world.tick(1); + assert.equal(world.alive.size, 1, 'exactly 1 entity should survive first flush'); + + // Second tick: remaining 1 destroy flushes + world.tick(1); + assert.equal(world.alive.size, 0, 'all entities should be gone after second tick'); +}); + +test('deep hierarchy: 100-level chain with destroySubtree', () => { + const world = new World(); + const root = world.create(); + let parent = root; + for (let i = 0; i < 100; i++) { + const child = world.create(); + attach(world, child, parent); + parent = child; + } + // 101 entities total (root + 100 levels) + assert.equal(world.alive.size, 101); + + destroySubtree(world, root); + assert.equal(world.alive.size, 0, 'all 101 entities should be dead'); +}); + +test('entity id reuse after bulk destroy', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1000; i++) { + entities.push(world.create()); + } + const nextIdAfterCreate = world._nextId; + + // Destroy all + for (const e of entities) world.destroy(e); + assert.equal(world.alive.size, 0); + assert.equal(world._free.length, 1000, 'free list should have 1000 ids'); + + // Create 1000 new — should reuse from free list + for (let i = 0; i < 1000; i++) { + world.create(); + } + assert.equal(world.alive.size, 1000); + assert.equal(world._nextId, nextIdAfterCreate, '_nextId should not grow when reusing ids'); +}); diff --git a/tests/world.query.test.mjs b/tests/world.query.test.mjs new file mode 100644 index 0000000..8a0c3c5 --- /dev/null +++ b/tests/world.query.test.mjs @@ -0,0 +1,206 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, defineTag, Not, Changed } from '../core.js'; + +const Position = defineComponent('QPos', { x: 0, y: 0 }); +const Velocity = defineComponent('QVel', { dx: 0, dy: 0 }); +const Marker = defineTag('QMarker'); +const Health = defineComponent('QHealth', { hp: 10 }); + +// ---- Not() ---- + +test('Not() excludes entities that have the negated component', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); + const e3 = world.create(); world.add(e3, Position, { x: 3 }); world.add(e3, Marker); + + const ids = [...world.query(Position, Not(Velocity))].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(ids, [e2, e3].sort((a, b) => a - b)); +}); + +test('Not() with all entities having negated component returns empty', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); world.add(e2, Velocity, { dx: 2 }); + + const result = [...world.query(Position, Not(Velocity))]; + assert.equal(result.length, 0); +}); + +// ---- Changed() ---- + +test('Changed() matches only entities modified between ticks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + // Tick to clear initial change marks from add() + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 and e3 BETWEEN ticks (set runs immediately, marks changed) + world.set(e1, Position, { x: 10 }); + world.set(e3, Position, { x: 30 }); + + // Now query — Changed() sees marks set between ticks + let capturedIds = []; + world.setScheduler((w) => { + capturedIds = [...w.query(Position, Changed(Position))].map(r => r[0]).sort((a, b) => a - b); + }); + world.tick(1); + assert.deepEqual(capturedIds, [e1, e3].sort((a, b) => a - b), 'only modified entities should match'); +}); + +test('Changed() returns nothing after tick clears change marks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + + // Tick to clear add() marks + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 in a tick + world.setScheduler((w) => { w.set(e1, Position, { x: 99 }); }); + world.tick(1); + + // After tick, change marks are cleared + const result = [...world.query(Position, Changed(Position))]; + assert.equal(result.length, 0, 'change marks should be cleared after tick'); +}); + +test('Combined Not() and Changed() filters', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + // Tick to clear initial marks + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 (has Velocity) and e2 (no Velocity) between ticks + world.set(e1, Position, { x: 10 }); + world.set(e2, Position, { x: 20 }); + // e3 not modified + + let capturedIds = []; + world.setScheduler((w) => { + capturedIds = [...w.query(Position, Not(Velocity), Changed(Position))].map(r => r[0]); + }); + world.tick(1); + // Only e2: changed AND lacks Velocity + assert.deepEqual(capturedIds, [e2]); +}); + +// ---- Empty results ---- + +test('query on empty world returns empty iterable', () => { + const world = new World(); + const result = [...world.query(Position)]; + assert.equal(result.length, 0); +}); + +test('query with no matching component returns empty', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); + + const result = [...world.query(Velocity)]; + assert.equal(result.length, 0); +}); + +// ---- count() ---- + +test('count() returns accurate count with dynamic filters', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + const result = world.query(Position, Not(Velocity)); + const spread = [...result].length; + const counted = result.count(); + assert.equal(counted, spread, 'count() should match iteration length'); + assert.equal(counted, 2); +}); + +test('count({cheap:true}) may overcount with Not() filter', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + const result = world.query(Position, Not(Velocity)); + const cheap = result.count({ cheap: true }); + const accurate = result.count(); + // cheap returns base cached list size (all with Position = 3) + // accurate filters out those with Velocity = 2 + assert.ok(cheap >= accurate, 'cheap count should be >= accurate count'); + assert.equal(cheap, 3, 'cheap returns base list size'); + assert.equal(accurate, 2, 'accurate filters Not()'); +}); + +// ---- Cache invalidation ---- + +test('cache invalidation: adding a component updates query results', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const before = [...world.query(Position)].map(r => r[0]); + assert.deepEqual(before, [e1]); + + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const after = [...world.query(Position)].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(after, [e1, e2].sort((a, b) => a - b)); +}); + +test('cache invalidation: removing a component updates query results', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const before = [...world.query(Position)].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(before, [e1, e2].sort((a, b) => a - b)); + + world.remove(e1, Position); + const after = [...world.query(Position)].map(r => r[0]); + assert.deepEqual(after, [e2]); +}); + +// ---- offset/limit ---- + +test('limit=0 returns nothing', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const result = [...world.query(Position, { limit: 0 })]; + assert.equal(result.length, 0); +}); + +test('offset greater than result count returns nothing', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const result = [...world.query(Position, { offset: 100 })]; + assert.equal(result.length, 0); +}); + +// ---- defineQuery handle across ticks ---- + +test('defineQuery handle returns consistent results across ticks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const qh = world.defineQuery(Position); + world.setScheduler(() => {}); + + const ids1 = [...qh()].map(r => r[0]).sort((a, b) => a - b); + world.tick(1); + const ids2 = [...qh()].map(r => r[0]).sort((a, b) => a - b); + + assert.deepEqual(ids1, [e1, e2].sort((a, b) => a - b)); + assert.deepEqual(ids2, ids1, 'handle should return same results across ticks'); +}); diff --git a/tests/world.soa.intratick.test.mjs b/tests/world.soa.intratick.test.mjs new file mode 100644 index 0000000..2a786d5 --- /dev/null +++ b/tests/world.soa.intratick.test.mjs @@ -0,0 +1,151 @@ +import { assert, test } from './testlib.js'; + +import { World, defineComponent } from '../core.js'; +import { clearSystems, composeScheduler } from '../systems.js'; + +const Position = defineComponent('Position', { x: 0, y: 0 }); +const Velocity = defineComponent('Velocity', { dx: 0, dy: 0 }); + +test('SoA direct mutation is visible to later phases in the same tick', (t) => { + for (const store of ['soa', 'map']) { + clearSystems(); + const world = new World({ store }); + const e = world.create(); + world.add(e, Position, { x: 0, y: 0 }); + world.add(e, Velocity, { dx: 10, dy: 5 }); + + let posAfterMove = null; + + // Phase 1: move — directly mutate Position via the query tuple reference + function moveSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + } + } + + // Phase 2: read — read Position and verify it reflects the mutation + function readSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + posAfterMove = { x: pos.x, y: pos.y }; + } + } + + world.system(moveSystem, 'move'); + world.system(readSystem, 'read'); + world.setScheduler(composeScheduler('move', 'read')); + + world.tick(1); + + assert.equal(posAfterMove.x, 10, `[${store}] direct mutation should be visible in later phase`); + assert.equal(posAfterMove.y, 5, `[${store}] direct mutation should be visible in later phase`); + } + t.after(clearSystems); +}); + +test('world.set() is deferred during tick and NOT visible to later phases', (t) => { + for (const store of ['soa', 'map']) { + clearSystems(); + const world = new World({ store }); + const e = world.create(); + world.add(e, Position, { x: 0, y: 0 }); + world.add(e, Velocity, { dx: 10, dy: 5 }); + + let posAfterMove = null; + + // Phase 1: move — use world.set() (deferred in non-strict mode) + function moveSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + w.set(id, Position, { x: pos.x + vel.dx * dt, y: pos.y + vel.dy * dt }); + } + } + + // Phase 2: read — check what Position looks like + function readSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + posAfterMove = { x: pos.x, y: pos.y }; + } + } + + world.system(moveSystem, 'move'); + world.system(readSystem, 'read'); + world.setScheduler(composeScheduler('move', 'read')); + + world.tick(1); + + // world.set() was deferred, so readSystem sees OLD values + assert.equal(posAfterMove.x, 0, `[${store}] world.set() should be deferred; old value visible`); + assert.equal(posAfterMove.y, 0, `[${store}] world.set() should be deferred; old value visible`); + + // After tick completes, deferred set is flushed + const pos = world.get(e, Position); + assert.equal(pos.x, 10, `[${store}] deferred set should be applied after tick`); + assert.equal(pos.y, 5, `[${store}] deferred set should be applied after tick`); + } + t.after(clearSystems); +}); + +test('SoA view object properties write through to backing arrays', () => { + const world = new World({ store: 'soa' }); + const e = world.create(); + world.add(e, Position, { x: 1, y: 2 }); + + // Get a view, mutate it, then get another view — should reflect the mutation + const view1 = world.get(e, Position); + view1.x = 99; + + const view2 = world.get(e, Position); + assert.equal(view2.x, 99, 'SoA views should share the same backing array'); + assert.equal(view1 === view2, true, 'SoA should return the same view object for a given entity'); +}); + +test('multi-phase pipeline: steer → integrate → wrap all via direct mutation', (t) => { + clearSystems(); + const world = new World({ store: 'soa' }); + const Accel = defineComponent('Accel', { ax: 0, ay: 0 }); + const e = world.create(); + world.add(e, Position, { x: 50, y: 50 }); + world.add(e, Velocity, { dx: 0, dy: 0 }); + world.add(e, Accel, { ax: 100, ay: 0 }); + + const WIDTH = 100; + const trail = []; + + function integrateSystem(w, dt) { + for (const [id, pos, vel, acc] of w.query(Position, Velocity, Accel)) { + vel.dx += acc.ax * dt; + vel.dy += acc.ay * dt; + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + } + } + + function wrapSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + pos.x = ((pos.x % WIDTH) + WIDTH) % WIDTH; + } + } + + function recordSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + trail.push({ x: pos.x, y: pos.y, dx: vel.dx, dy: vel.dy }); + } + } + + world.system(integrateSystem, 'integrate'); + world.system(wrapSystem, 'wrap'); + world.system(recordSystem, 'record'); + world.setScheduler(composeScheduler('integrate', 'wrap', 'record')); + + // Tick 1: accel=100, dt=1 → vel becomes 100, pos becomes 50+100=150 → wrap to 50 + world.tick(1); + assert.equal(trail[0].dx, 100, 'velocity should update from acceleration'); + assert.equal(trail[0].x, 50, 'position should wrap after exceeding width'); + + // Tick 2: vel=100+100=200, pos=50+200=250 → wrap to 50 + world.tick(1); + assert.equal(trail[1].dx, 200, 'velocity accumulates across ticks'); + assert.equal(trail[1].x, 50, 'position should wrap again'); + + t.after(clearSystems); +}); diff --git a/tests/world.strict.edgecases.test.mjs b/tests/world.strict.edgecases.test.mjs new file mode 100644 index 0000000..20d5dc7 --- /dev/null +++ b/tests/world.strict.edgecases.test.mjs @@ -0,0 +1,53 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent } from '../core.js'; + +const Tag = defineComponent('StrictTag', { v: 0 }); + +test('strict handler returning "defer" string queues the operation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => 'defer'); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 7 }); + }); + world.tick(1); + const rec = world.get(eid, Tag); + assert.ok(rec, 'component should be attached after deferred execution'); + assert.equal(rec.v, 7); +}); + +test('strict handler returning false ignores the operation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => false); + world.setScheduler((w) => { + w.destroy(eid); + }); + world.tick(1); + assert.ok(world.isAlive(eid), 'false should act as ignore, keeping entity alive'); + assert.equal(world.pendingOps().length, 0, 'no commands should be queued'); +}); + +test('strict handler returning undefined falls through to throw', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => { /* returns undefined */ }); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 1 }); + }); + // tick catches the re-thrown strict error + world.tick(1); + assert.equal(world.has(eid, Tag), false, 'mutation should not apply when handler returns undefined'); +}); + +test('strict handler that throws its own error still blocks mutation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => { throw new Error('custom handler error'); }); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 1 }); + }); + // tick catches the error chain + world.tick(1); + assert.equal(world.has(eid, Tag), false, 'mutation should not apply when handler throws'); +});