Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
809 changes: 809 additions & 0 deletions demo/boids-swarm.html

Large diffs are not rendered by default.

821 changes: 821 additions & 0 deletions demo/food-web.html

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions hierarchy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ <h1>ECS-js</h1>
<ul>
<li><a href="#base-demo">Base demo (logs below)</a></li>
<li><a href="./demo/fishery-collapse.html">Fishery collapse demo</a></li>
<li><a href="./demo/food-web.html">Ecosystem food-web sandbox</a></li>
<li><a href="./demo/boids-swarm.html">Boids swarm simulation</a></li>
</ul>
</nav>
<main>
Expand Down
133 changes: 133 additions & 0 deletions tests/hierarchy.edgecases.test.mjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
155 changes: 155 additions & 0 deletions tests/hierarchy.reparent.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
75 changes: 75 additions & 0 deletions tests/world.errors.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading