Compare commits

..

No commits in common. "46da8abbe1dd843f2e786c23d91d993145a92be5" and "4ede2d7f3b934335907820c5b9e2cf9b4c6e88ca" have entirely different histories.

15 changed files with 237 additions and 3121 deletions

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,17 +19,20 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"rxjs": "^7.0.0"
},
"devDependencies": {
"rxjs": "^7.8.1",
"tsup": "^8.3.5",
"typescript": "^5.6.0",
"vitest": "^4.1.7"
"typescript": "^5.6.0"
},
"keywords": [
"ecs",
"entity-component-system",
"rxjs",
"observable",
"game"
],

View File

@ -5,15 +5,13 @@
*
* @example
* ```ts
* const Position = defineComponent('position', { x: 0, y: 0 });
* const Position = defineComponent({ x: 0, y: 0 });
* type Position = typeof Position.type;
* ```
*/
export interface ComponentDef<T extends Record<string, any>> {
/** Unique symbol used as the storage key. */
readonly _key: symbol;
/** Human-readable name, used for serialization. */
readonly name: string;
/** Default values applied when a component is first added. */
readonly defaults: T;
/** Phantom type for inference. */
@ -21,17 +19,15 @@ export interface ComponentDef<T extends Record<string, any>> {
}
/**
* Define a component type. The name is used for serialization.
* The defaults object provides both the TypeScript shape and initial values.
* Define a component type. The argument provides both default values and the
* TypeScript shape.
*/
export function defineComponent<T extends Record<string, any>>(
name: string,
defaults: T,
defaults: T
): ComponentDef<T> {
return {
_key: Symbol(),
name,
defaults: { ...defaults },
type: undefined as unknown as T,
type: undefined as unknown as T, // phantom; never read at runtime
};
}

View File

@ -1,20 +1,10 @@
// ── Public API ─────────────────────────────────────────
export { World } from "./world";
export { defineComponent } from "./component";
export type { ComponentDef } from "./component";
export { defineRelationship } from "./relationship";
export type { RelationshipDef } from "./relationship";
export { query } from "./query";
export { Query } from "./query";
export type { Entity } from "./entity";
export { makeEntity, entityIndex, entityGeneration } from "./entity";
export { SparseSet } from "./storage/sparse-set";
export type {
WorldEvent,
EntityEvent,
RelEvent,
QueryUpdate,
RelationshipUpdate,
} from "./observable/events";
export type { WorldSnapshot } from "./serialization";
export type { Observable, Subscription } from "./observable/subject";
export { World } from './world';
export { defineComponent } from './component';
export type { ComponentDef } from './component';
export { query } from './query';
export { Query } from './query';
export type { Entity } from './entity';
export { makeEntity, entityIndex, entityGeneration } from './entity';
export { SparseSet } from './storage/sparse-set';
export type { WorldEvent, QueryUpdate } from './observable/events';

View File

@ -1,71 +1,46 @@
import type { ComponentDef } from "../component";
import type { RelationshipDef } from "../relationship";
import type { Entity } from "../entity";
import type { ComponentDef } from '../component';
import type { Entity } from '../entity';
// ── World Events ──────────────────────────────────────
/**
* Events that carry an `entity` field component and lifecycle events.
* Discriminated union of all world-level events.
* Emitted via `world.events$`.
*/
export type EntityEvent =
export type WorldEvent =
| SpawnedEvent
| DestroyedEvent
| ComponentAddedEvent
| ComponentRemovedEvent
| ComponentChangedEvent;
/**
* Events that carry `source`/`target` fields relationship events.
*/
export type RelEvent = RelAddedEvent | RelRemovedEvent;
/**
* Discriminated union of all world-level events.
* Emitted via `world.events$`.
*/
export type WorldEvent = EntityEvent | RelEvent;
export interface SpawnedEvent {
type: "spawned";
type: 'spawned';
entity: Entity;
}
export interface DestroyedEvent {
type: "destroyed";
type: 'destroyed';
entity: Entity;
}
export interface ComponentAddedEvent {
type: "componentAdded";
type: 'componentAdded';
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentRemovedEvent {
type: "componentRemoved";
type: 'componentRemoved';
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentChangedEvent {
type: "componentChanged";
type: 'componentChanged';
entity: Entity;
component: ComponentDef<any>;
}
export interface RelAddedEvent {
type: "relationshipAdded";
source: Entity;
target: Entity;
relationship: RelationshipDef;
}
export interface RelRemovedEvent {
type: "relationshipRemoved";
source: Entity;
target: Entity;
relationship: RelationshipDef;
}
// ── Query Observables ────────────────────────────────
/**
* Emitted by `world.observe(query)` when the result set changes.
@ -78,13 +53,3 @@ export interface QueryUpdate {
/** Entities still matching that had a `markDirty` this frame. */
changed: Entity[];
}
/**
* Emitted by `world.observeRelated(rel)` when relationships change.
*/
export interface RelationshipUpdate {
/** Newly established relationships. */
added: { source: Entity; target: Entity }[];
/** Broken relationships. */
removed: { source: Entity; target: Entity }[];
}

View File

@ -1,40 +1,33 @@
import { Subject } from "./subject";
import { Subject } from "rxjs";
import type { Query } from "../query";
import type { Entity } from "../entity";
import type {
WorldEvent,
EntityEvent,
QueryUpdate,
RelationshipUpdate,
} from "./events";
import type { RelationshipDef } from "../relationship";
import type { ComponentDef } from "../component";
import type { WorldEvent, QueryUpdate } from "./events";
// ── Internal state ───────────────────────────────────
// ── Internal observer state per query ────────────────
interface QueryObserverState {
query: Query;
/** Cached set of entities currently matching the query. */
matched: Set<Entity>;
subject: Subject<QueryUpdate>;
}
interface RelationshipObserverState {
rel: RelationshipDef;
edges: Set<string>;
subject: Subject<RelationshipUpdate>;
}
// ── Observable layer ─────────────────────────────────
/**
* Manages observable subscriptions for a World.
* Kept separate from the World class for clarity.
*/
export class ObservableLayer {
/** Raw event stream. */
readonly events$ = new Subject<WorldEvent>();
/** Active query observers. */
private _observers: QueryObserverState[] = [];
private _relObservers: RelationshipObserverState[] = [];
// ── Observer index: component key → observers that care ──
private _compIndex = new Map<symbol, Set<QueryObserverState>>();
// ── Query observers ─────────────────────────────
/**
* Get or create a Subject for a query.
* If this is the first subscription, seed the matched set using
* the provided queryMatches callback.
*/
observe(query: Query): Subject<QueryUpdate> {
const existing = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
@ -46,146 +39,48 @@ export class ObservableLayer {
matched: new Set(),
subject: new Subject<QueryUpdate>(),
};
// Seeding is handled by World (we don't have entity iteration here).
// The World's observe() method seeds via a separate path.
this._observers.push(state);
this._indexObserver(state);
return state.subject;
}
/**
* Seed the initial matched set for an observer.
* Called once by World.observe() with all currently-matching entities.
*/
seed(query: Query, entities: Entity[]): void {
const obs = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
);
if (!obs) return;
for (const e of entities) obs.matched.add(e);
}
// ── Relationship observers ───────────────────────
observeRelated(rel: RelationshipDef): Subject<RelationshipUpdate> {
const existing = this._relObservers.find((o) => o.rel._key === rel._key);
if (existing) return existing.subject;
const state: RelationshipObserverState = {
rel,
edges: new Set(),
subject: new Subject<RelationshipUpdate>(),
};
this._relObservers.push(state);
return state.subject;
}
seedRelated(
rel: RelationshipDef,
edges: { source: Entity; target: Entity }[],
): void {
const obs = this._relObservers.find((o) => o.rel._key === rel._key);
if (!obs) return;
for (const { source, target } of edges) {
obs.edges.add(edgeKey(source, target));
for (const e of entities) {
obs.matched.add(e);
}
}
// ── Event dispatch ───────────────────────────────
/**
* Feed an event into the observable system.
* Called by the World after state mutation.
*/
onEvent(
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
// Forward to the global stream
this.events$.next(event);
this._dispatchToObservers(event, queryMatches);
for (const o of this._relObservers) {
this._updateRelObserver(o, event);
// Update each observer
for (const observer of this._observers) {
this._updateObserver(observer, event, queryMatches);
}
}
// ── Private: observer indexing ───────────────────
/** Add an observer to the component index. */
private _indexObserver(state: QueryObserverState): void {
for (const def of state.query.with) {
this._addToIndex(def._key, state);
}
for (const def of state.query.not) {
this._addToIndex(def._key, state);
}
// Also index under a well-known symbol for spawn/destroy events
// (those always fan out to all observers).
this._addToIndex(ANY_KEY, state);
}
/** Remove an observer from the component index. */
private _unindexObserver(state: QueryObserverState): void {
for (const def of state.query.with) {
this._remFromIndex(def._key, state);
}
for (const def of state.query.not) {
this._remFromIndex(def._key, state);
}
this._remFromIndex(ANY_KEY, state);
}
private _addToIndex(key: symbol, state: QueryObserverState): void {
let set = this._compIndex.get(key);
if (!set) {
set = new Set();
this._compIndex.set(key, set);
}
set.add(state);
}
private _remFromIndex(key: symbol, state: QueryObserverState): void {
const set = this._compIndex.get(key);
if (!set) return;
set.delete(state);
if (set.size === 0) this._compIndex.delete(key);
}
/** Dispatch to only the relevant observers. */
private _dispatchToObservers(
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
if (!("entity" in event)) return;
const entityEvent = event as EntityEvent;
// Determine which component keys are relevant
let keys: symbol[] = [];
switch (entityEvent.type) {
case "spawned":
case "destroyed":
keys = [ANY_KEY];
break;
case "componentAdded":
case "componentRemoved":
case "componentChanged":
keys = [entityEvent.component._key];
break;
}
// Collect unique observers to update (deduplicate across keys)
const seen = new Set<QueryObserverState>();
for (const key of keys) {
const set = this._compIndex.get(key);
if (!set) continue;
for (const o of set) {
if (!seen.has(o)) {
seen.add(o);
// event is entity-bearing after the in-guard above
this._updateObserver(o, entityEvent, queryMatches);
}
}
}
}
// ── Private: observer update logic ───────────────
private _updateObserver(
obs: QueryObserverState,
event: EntityEvent,
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
const e = event.entity;
@ -194,6 +89,7 @@ export class ObservableLayer {
switch (event.type) {
case "spawned":
// Entity is bare; won't match unless components added later
break;
case "destroyed":
@ -224,87 +120,25 @@ export class ObservableLayer {
}
}
private _updateRelObserver(
obs: RelationshipObserverState,
event: WorldEvent,
): void {
switch (event.type) {
case "relationshipAdded": {
if (event.relationship._key !== obs.rel._key) break;
const key = edgeKey(event.source, event.target);
if (obs.edges.has(key)) break;
obs.edges.add(key);
obs.subject.next({
added: [{ source: event.source, target: event.target }],
removed: [],
});
break;
}
case "relationshipRemoved": {
if (event.relationship._key !== obs.rel._key) break;
const key = edgeKey(event.source, event.target);
if (!obs.edges.has(key)) break;
obs.edges.delete(key);
obs.subject.next({
added: [],
removed: [{ source: event.source, target: event.target }],
});
break;
}
case "destroyed": {
const removed: { source: Entity; target: Entity }[] = [];
for (const key of obs.edges) {
const [si, ti] = key.split(":").map(Number);
const idx = event.entity & 0xfffff;
if (si === idx || ti === idx) {
removed.push({ source: si as Entity, target: ti as Entity });
}
}
for (const r of removed) {
obs.edges.delete(edgeKey(r.source, r.target));
}
if (removed.length > 0) {
obs.subject.next({ added: [], removed });
}
break;
}
}
}
// ── Teardown ─────────────────────────────────────
/** Reset all observer state (useful for tests). */
reset(): void {
for (const o of this._observers) {
o.subject.complete();
o.matched.clear();
for (const obs of this._observers) {
obs.subject.complete();
obs.matched.clear();
}
this._observers = [];
this._compIndex.clear();
for (const o of this._relObservers) {
o.subject.complete();
o.edges.clear();
}
this._relObservers = [];
}
/** Complete all streams. */
complete(): void {
this.events$.complete();
for (const o of this._observers) o.subject.complete();
for (const obs of this._observers) {
obs.subject.complete();
}
this._observers = [];
this._compIndex.clear();
for (const o of this._relObservers) o.subject.complete();
this._relObservers = [];
}
}
// ── Helpers ─────────────────────────────────────
/** Sentinel key for observers that must be notified on spawn/destroy. */
const ANY_KEY = Symbol("any");
function queriesEqual(a: Query, b: Query): boolean {
if (a.with.length !== b.with.length) return false;
if (a.not.length !== b.not.length) return false;
@ -313,7 +147,3 @@ function queriesEqual(a: Query, b: Query): boolean {
a.not.every((c, i) => c === b.not[i])
);
}
function edgeKey(source: Entity, target: Entity): string {
return `${source & 0xfffff}:${target & 0xfffff}`;
}

View File

@ -1,50 +0,0 @@
// ── Internal Observable / Subject ─────────────────────
/** Minimal subscription handle returned by `.subscribe()`. */
export interface Subscription {
unsubscribe(): void;
}
/** Minimal observable interface — only supports single-callback subscribe. */
export interface Observable<T> {
subscribe(observer: (value: T) => void): Subscription;
}
/** Lightweight multicast subject, replacing the RxJS dependency. */
export class Subject<T> implements Observable<T> {
private _subs = new Set<(value: T) => void>();
private _done = false;
/** Push a value to all current subscribers. No-op after complete. */
next(value: T): void {
if (this._done) return;
for (const fn of this._subs) {
fn(value);
}
}
/** Register a subscriber. Returns a handle to unsubscribe. */
subscribe(observer: (value: T) => void): Subscription {
const fn = observer;
this._subs.add(fn);
return {
unsubscribe: () => {
this._subs.delete(fn);
},
};
}
/** Complete this subject — clears all subscribers and silences future calls. */
complete(): void {
this._done = true;
this._subs.clear();
}
/** Return a read-only Observable facade (hides `next` / `complete`). */
asObservable(): Observable<T> {
return {
subscribe: (observer: (value: T) => void): Subscription => {
return this.subscribe(observer);
},
};
}
}

View File

@ -1,24 +0,0 @@
// ── Relationship ─────────────────────────────────────
/**
* A relationship definition like a component, but represents a directed
* link between two entities.
*
* @example
* ```ts
* const ChildOf = defineRelationship('childOf');
* world.relate(child, ChildOf, parent);
* ```
*/
export interface RelationshipDef {
/** Unique symbol used as the storage key. */
readonly _key: symbol;
/** Human-readable name, used for serialization. */
readonly name: string;
}
/**
* Define a named relationship between entities.
*/
export function defineRelationship(name: string): RelationshipDef {
return { _key: Symbol(), name };
}

View File

@ -1,10 +0,0 @@
/**
* Plain JSON-compatible representation of a World.
* Returned by `world.toJSON()`, consumed by `World.fromJSON()`.
*/
export interface WorldSnapshot {
/** Entity stable ID → component map (component name → data). */
entities: Record<string, Record<string, unknown>>;
/** Relationship name → (source ID → target ID). */
relationships: Record<string, Record<string, string>>;
}

View File

@ -4,17 +4,14 @@ import { makeEntity, entityIndex, entityGeneration } from "./entity";
import type { Query } from "./query";
import { SparseSet } from "./storage/sparse-set";
import { ObservableLayer } from "./observable/observe";
import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
import type { RelationshipDef } from "./relationship";
import type { Observable } from "./observable/subject";
import type { WorldEvent } from "./observable/events";
import type { WorldSnapshot } from "./serialization";
import type { QueryUpdate } from "./observable/events";
import { Observable } from "rxjs";
// ── World ─────────────────────────────────────────────
/**
* The central ECS container.
*
* Manages entities, components, relationships, queries, and change tracking.
* Manages entities, components, queries, and change tracking.
* Call `flush()` once per frame to emit batched observable events.
*/
export class World {
@ -26,14 +23,6 @@ export class World {
private _components = new Map<symbol, SparseSet<any>>();
private _keyToDef = new Map<symbol, ComponentDef<any>>();
// ── Relationship storage ──────────────────────────
/** Forward: relationship._key → SparseSet<target entity> (keyed by source index). */
private _relForward = new Map<symbol, SparseSet<Entity>>();
/** Reverse: relationship._key → Map<target index, Set<source index>>. */
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
/** Key → RelationshipDef for event emission. */
private _relKeyToDef = new Map<symbol, RelationshipDef>();
// ── Change tracking ───────────────────────────────
private _dirty = new Map<symbol, Set<number>>();
@ -41,7 +30,7 @@ export class World {
private _observable = new ObservableLayer();
/** Global event stream. */
get events$(): Observable<WorldEvent> {
get events$(): Observable<any> {
return this._observable.events$.asObservable();
}
@ -64,34 +53,11 @@ export class World {
return e;
}
/** Destroy an entity, removing all its components and relationships. */
/** Destroy an entity, removing all its components. */
destroy(entity: Entity): void {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return;
// Clean relationships before components
for (const [key] of this._relForward) {
// Entity as source
const fwd = this._relForward.get(key)!;
if (fwd.has(idx)) {
const target = fwd.get(idx);
const rel = this._relKeyToDef.get(key)!;
this._relRemoveEdge(entity, target, rel);
}
// Entity as target
const rev = this._relReverse.get(key)!;
const sources = rev.get(idx);
if (sources) {
for (const si of [...sources]) {
const source = makeEntity(si, this._generations[si]);
const rel = this._relKeyToDef.get(key)!;
this._relRemoveEdge(source, entity, rel);
}
}
}
// Remove components
for (const [, store] of this._components) {
store.remove(idx);
}
@ -112,6 +78,7 @@ export class World {
// ── Component operations ──────────────────────────
/** Add a component to an entity. Returns the live value. */
add<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
@ -128,6 +95,7 @@ export class World {
return value;
}
/** Remove a component from an entity. */
remove(entity: Entity, def: ComponentDef<any>): void {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
@ -143,6 +111,7 @@ export class World {
}
}
/** Get a mutable reference to a component. Throws if absent. */
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
@ -156,6 +125,7 @@ export class World {
return store.get(idx);
}
/** Get a mutable reference, or undefined if absent. */
tryGet<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
@ -165,6 +135,7 @@ export class World {
return this._components.get(def._key)?.tryGet(idx);
}
/** Check if an entity has a component. */
has(entity: Entity, def: ComponentDef<any>): boolean {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return false;
@ -172,6 +143,7 @@ export class World {
return store?.has(idx) ?? false;
}
/** Replace a component value. Sets the value and marks dirty. */
set<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
@ -193,6 +165,7 @@ export class World {
// ── Change tracking ───────────────────────────────
/** Mark entity's component as dirty. Not emitted until `flush()`. */
markDirty(entity: Entity, def: ComponentDef<any>): void {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
@ -205,6 +178,7 @@ export class World {
dirty.add(idx);
}
/** Emit all pending change events. Call once per frame. */
flush(): void {
for (const [key, dirtySet] of this._dirty) {
if (dirtySet.size === 0) continue;
@ -223,116 +197,9 @@ export class World {
}
}
// ── Relationships ─────────────────────────────────
/**
* Create a directed relationship from `source` to `target`.
* Each source can only have one target per relationship type.
* If a relationship already exists, it is replaced.
*/
relate(source: Entity, rel: RelationshipDef, target: Entity): void {
const si = entityIndex(source);
const ti = entityIndex(target);
this._assertAlive(si, source);
this._assertAlive(ti, target);
// If source already has this relationship, remove it first
const existing = this.getRelated(source, rel);
if (existing !== undefined) {
this._relRemoveEdge(source, existing, rel);
}
this._relEnsureMaps(rel);
// Forward
this._relForward.get(rel._key)!.set(si, target);
// Reverse
let rev = this._relReverse.get(rel._key)!.get(ti);
if (!rev) {
rev = new Set();
this._relReverse.get(rel._key)!.set(ti, rev);
}
rev.add(si);
this._emit({
type: "relationshipAdded",
source,
target,
relationship: rel,
});
}
/**
* Remove the relationship from `source`.
* No-op if no such relationship exists.
*/
unrelate(source: Entity, rel: RelationshipDef): void {
const si = entityIndex(source);
if (!this._isAlive(si, source)) return;
const target = this.getRelated(source, rel);
if (target === undefined) return;
this._relRemoveEdge(source, target, rel);
}
/**
* Get the target entity for a relationship, or undefined.
*/
getRelated(source: Entity, rel: RelationshipDef): Entity | undefined {
const si = entityIndex(source);
if (!this._isAlive(si, source)) return undefined;
const fwd = this._relForward.get(rel._key);
if (!fwd) return undefined;
return fwd.tryGet(si);
}
/**
* Get all source entities that point to `target` via this relationship.
*/
getRelatedTo(target: Entity, rel: RelationshipDef): Entity[] {
const ti = entityIndex(target);
if (!this._isAlive(ti, target)) return [];
const rev = this._relReverse.get(rel._key);
if (!rev) return [];
const sources = rev.get(ti);
if (!sources) return [];
return [...sources].map((si) => makeEntity(si, this._generations[si]));
}
/**
* Observe relationship changes for a given type.
*
* ```ts
* world.observeRelated(ChildOf).subscribe(update => {
* // update.added, update.removed
* });
* ```
*/
observeRelated(rel: RelationshipDef): Observable<RelationshipUpdate> {
const subject = this._observable.observeRelated(rel);
// Seed with current edges
const edges: { source: Entity; target: Entity }[] = [];
const fwd = this._relForward.get(rel._key);
if (fwd) {
for (const [si, target] of fwd.entries()) {
const source = makeEntity(si, this._generations[si]);
edges.push({ source, target });
}
}
this._observable.seedRelated(rel, edges);
return subject.asObservable();
}
// ── Queries ───────────────────────────────────────
/** Iterate all entities matching a query synchronously. */
*query(q: Query): IterableIterator<Entity> {
const withStores = q.with.map((d) => this._components.get(d._key));
const withoutStores = q.not.map((d) => this._components.get(d._key));
@ -349,13 +216,18 @@ export class World {
}
}
/** Observe changes to a query's result set. */
observe(q: Query): Observable<QueryUpdate> {
const subject = this._observable.observe(q);
// Seed with currently-matching entities
const existing = [...this.query(q)];
this._observable.seed(q, existing);
return subject.asObservable();
}
/** Total number of *alive* entities. */
get entityCount(): number {
let count = 0;
for (let i = 0; i < this._generations.length; i++) {
@ -366,123 +238,6 @@ export class World {
return count;
}
// ── Serialization ────────────────────────────────
/**
* Serialize the entire world to a plain JSON-compatible object.
*
* Each entity gets a stable string ID ("e0", "e1", ).
* Components are keyed by their `name`. Relationships are keyed
* by their `name` with entity references using the same stable IDs.
*/
toJSON(): WorldSnapshot {
// Build entity index → string id mapping
const ids: string[] = [];
let nextId = 0;
const entities: Record<string, Record<string, unknown>> = {};
for (let i = 0; i < this._generations.length; i++) {
if (this._generations[i] === 0 || this._free.includes(i)) continue;
const strId = `e${nextId++}`;
ids[i] = strId;
const comps: Record<string, unknown> = {};
for (const [key, store] of this._components) {
if (store.has(i)) {
const def = this._keyToDef.get(key)!;
comps[def.name] = store.get(i);
}
}
if (Object.keys(comps).length > 0) {
entities[strId] = comps;
} else {
// Still record bare entities
entities[strId] = {};
}
}
// Relationships
const relationships: Record<string, Record<string, string>> = {};
for (const [key, fwd] of this._relForward) {
const rel = this._relKeyToDef.get(key)!;
const edges: Record<string, string> = {};
for (const [si, target] of fwd.entries()) {
const ti = entityIndex(target);
if (ids[si] !== undefined && ids[ti] !== undefined) {
edges[ids[si]] = ids[ti];
}
}
if (Object.keys(edges).length > 0) {
relationships[rel.name] = edges;
}
}
return { entities, relationships };
}
/**
* Deserialize a world from a snapshot.
*
* @param data The output of `world.toJSON()`.
* @param components All ComponentDefs that may appear in the snapshot.
* @param relationships All RelationshipDefs that may appear in the snapshot.
*/
static fromJSON(
data: WorldSnapshot,
components: ComponentDef<any>[],
relationships?: RelationshipDef[],
): World {
const world = new World();
const compByName = new Map(components.map((c) => [c.name, c])) as Map<
string,
ComponentDef<Record<string, unknown>>
>;
const relByName = new Map((relationships ?? []).map((r) => [r.name, r]));
// Map string ids → real Entity handles
const idToEntity = new Map<string, Entity>();
for (const [strId, comps] of Object.entries(data.entities)) {
const entity = world.spawn();
idToEntity.set(strId, entity);
for (const [compName, value] of Object.entries(comps)) {
const def = compByName.get(compName);
if (!def) {
throw new Error(
`Unknown component "${compName}" in snapshot. ` +
`Pass it in the components array.`,
);
}
// Unknown at deserialization boundary; shape matches ComponentDef.defaults
world.add(entity, def, value as Partial<Record<string, unknown>>);
}
}
// Restore relationships
for (const [relName, edges] of Object.entries(data.relationships)) {
const rel = relByName.get(relName);
if (!rel) {
throw new Error(
`Unknown relationship "${relName}" in snapshot. ` +
`Pass it in the relationships array.`,
);
}
for (const [srcId, tgtId] of Object.entries(edges)) {
const source = idToEntity.get(srcId);
const target = idToEntity.get(tgtId);
if (source && target) {
world.relate(source, rel, target);
}
}
}
return world;
}
// ── Internals ─────────────────────────────────────
private _emit(event: import("./observable/events").WorldEvent): void {
@ -503,8 +258,6 @@ export class World {
}
}
// ── Component storage helpers ────────────────────
private _getOrCreateStore<T extends Record<string, any>>(
def: ComponentDef<T>,
): SparseSet<T> {
@ -528,43 +281,4 @@ export class World {
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
);
}
// ── Relationship helpers ─────────────────────────
private _relEnsureMaps(rel: RelationshipDef): void {
if (!this._relForward.has(rel._key)) {
this._relForward.set(rel._key, new SparseSet<Entity>());
this._relReverse.set(rel._key, new Map());
this._relKeyToDef.set(rel._key, rel);
}
}
/** Remove an edge internally and emit the event. */
private _relRemoveEdge(
source: Entity,
target: Entity,
rel: RelationshipDef,
): void {
const si = entityIndex(source);
const ti = entityIndex(target);
const fwd = this._relForward.get(rel._key);
if (fwd) fwd.remove(si);
const rev = this._relReverse.get(rel._key);
if (rev) {
const sources = rev.get(ti);
if (sources) {
sources.delete(si);
if (sources.size === 0) rev.delete(ti);
}
}
this._emit({
type: "relationshipRemoved",
source,
target,
relationship: rel,
});
}
}

View File

@ -1,381 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineRelationship,
type RelationshipUpdate,
type WorldEvent,
} from "../src/index";
// ── Relationships ─────────────────────────────────────
const ChildOf = defineRelationship("childOf");
const Targeting = defineRelationship("targeting");
const Inside = defineRelationship("inside");
// ── Helpers ───────────────────────────────────────────
function collectEvents(world: World): WorldEvent[] {
const log: WorldEvent[] = [];
world.events$.subscribe((e: WorldEvent) => log.push(e));
return log;
}
function collectRelUpdates(obs$: {
subscribe: Function;
}): RelationshipUpdate[] {
const log: RelationshipUpdate[] = [];
obs$.subscribe((u: RelationshipUpdate) => log.push(u));
return log;
}
// ── Basic relate / unrelate ───────────────────────────
describe("Relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("relates two entities", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
expect(world.getRelated(child, ChildOf)).toBe(parent);
});
it("getRelated returns undefined when no relationship", () => {
const e = world.spawn();
expect(world.getRelated(e, ChildOf)).toBeUndefined();
});
it("getRelatedTo returns reverse lookup", () => {
const parent = world.spawn();
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, parent);
world.relate(b, ChildOf, parent);
const children = world.getRelatedTo(parent, ChildOf);
expect(children).toHaveLength(2);
expect(children).toContain(a);
expect(children).toContain(b);
});
it("getRelatedTo returns empty when no edges", () => {
const e = world.spawn();
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
});
it("unrelate removes the relationship", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.unrelate(child, ChildOf);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
});
it("unrelate is idempotent", () => {
const e = world.spawn();
expect(() => world.unrelate(e, ChildOf)).not.toThrow();
});
it("relate replaces existing relationship", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
expect(world.getRelated(a, ChildOf)).toBe(b);
expect(world.getRelatedTo(b, ChildOf)).toContain(a);
world.relate(a, ChildOf, c);
expect(world.getRelated(a, ChildOf)).toBe(c);
// a should no longer point to b
expect(world.getRelatedTo(b, ChildOf)).toEqual([]);
expect(world.getRelatedTo(c, ChildOf)).toContain(a);
});
});
// ── Events ────────────────────────────────────────────
describe("Relationship events", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits relationshipAdded event", () => {
const events = collectEvents(world);
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const ev = events.find((e) => e.type === "relationshipAdded")!;
expect(ev).toMatchObject({
type: "relationshipAdded",
source: a,
target: b,
});
});
it("emits relationshipRemoved on unrelate", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const events = collectEvents(world);
world.unrelate(a, ChildOf);
const ev = events.find((e) => e.type === "relationshipRemoved")!;
expect(ev).toMatchObject({
type: "relationshipRemoved",
source: a,
target: b,
});
});
it("emits relationshipRemoved when replacing an edge", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
const events = collectEvents(world);
world.relate(a, ChildOf, c);
const removed = events.filter((e) => e.type === "relationshipRemoved");
const added = events.filter((e) => e.type === "relationshipAdded");
expect(removed).toHaveLength(1);
expect(removed[0]).toMatchObject({ source: a, target: b });
expect(added).toHaveLength(1);
expect(added[0]).toMatchObject({ source: a, target: c });
});
});
// ── Observable relationships ──────────────────────────
describe("Observable relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits added on relate", () => {
const log = collectRelUpdates(world.observeRelated(ChildOf));
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
expect(log).toHaveLength(1);
expect(log[0].added).toEqual([{ source: a, target: b }]);
expect(log[0].removed).toEqual([]);
});
it("emits removed on unrelate", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.unrelate(a, ChildOf);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
});
it("emits removed+added on replacement", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.relate(a, ChildOf, c);
// Should have two updates: one removed, one added
expect(log).toHaveLength(2);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
expect(log[1].added).toEqual([{ source: a, target: c }]);
});
it("seeds with existing relationships", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
// Unrelate should trigger removed — proving seed worked
world.unrelate(a, ChildOf);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
});
it("observers are scoped to relationship type", () => {
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
expect(childLog).toHaveLength(1);
expect(targetLog).toHaveLength(0);
});
});
// ── Destroy cleanup ───────────────────────────────────
describe("Destroy cleanup", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("removes edges when source is destroyed", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.destroy(child);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
});
it("removes edges when target is destroyed", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.destroy(parent);
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
});
it("emits relationshipRemoved events on destroy", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, Targeting, b);
world.relate(a, ChildOf, c);
const log = collectRelUpdates(world.observeRelated(ChildOf));
const tLog = collectRelUpdates(world.observeRelated(Targeting));
world.destroy(a);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: c }]);
expect(tLog).toHaveLength(1);
expect(tLog[0].removed).toEqual([{ source: a, target: b }]);
});
it("detects cross-relationship observers when destroying target", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.destroy(parent);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: child, target: parent }]);
});
it("handles destroy when entity is source for multiple relationships", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
world.relate(a, Targeting, c);
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
world.destroy(a);
expect(childLog).toHaveLength(1);
expect(childLog[0].removed).toEqual([{ source: a, target: b }]);
expect(targetLog).toHaveLength(1);
expect(targetLog[0].removed).toEqual([{ source: a, target: c }]);
});
});
// ── Multiple relationship types ──────────────────────
describe("Multiple relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("an entity can have different relationship types simultaneously", () => {
const e = world.spawn();
const a = world.spawn();
const b = world.spawn();
world.relate(e, ChildOf, a);
world.relate(e, Targeting, b);
expect(world.getRelated(e, ChildOf)).toBe(a);
expect(world.getRelated(e, Targeting)).toBe(b);
});
it("relationships of different types don't interfere", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
world.relate(a, Targeting, c);
world.unrelate(a, ChildOf);
expect(world.getRelated(a, ChildOf)).toBeUndefined();
// Targeting should still be intact
expect(world.getRelated(a, Targeting)).toBe(c);
});
});
// ── Dead entities ─────────────────────────────────────
describe("Dead entity safety", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("relate throws on dead source", () => {
const a = world.spawn();
const b = world.spawn();
world.destroy(a);
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
});
it("relate throws on dead target", () => {
const a = world.spawn();
const b = world.spawn();
world.destroy(b);
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
});
it("getRelated returns undefined for dead entity", () => {
const e = world.spawn();
world.destroy(e);
expect(world.getRelated(e, ChildOf)).toBeUndefined();
});
it("getRelatedTo returns empty for dead entity", () => {
const e = world.spawn();
world.destroy(e);
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
});
});

View File

@ -1,526 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineComponent,
defineRelationship,
type WorldSnapshot,
query,
type QueryUpdate,
type RelationshipUpdate,
type WorldEvent,
type ComponentDef,
type RelationshipDef,
} from "../src/index";
// ── Definitions ─────────────────────────────────────
const Position = defineComponent("position", { x: 0, y: 0 });
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
const Health = defineComponent("health", { current: 100, max: 100 });
const Shield = defineComponent("shield", { armor: 5, broken: false });
const Name = defineComponent("name", { value: "" });
const Team = defineComponent("team", { id: 0, color: "#fff" });
const ChildOf = defineRelationship("childOf");
const Targeting = defineRelationship("targeting");
const OwnedBy = defineRelationship("ownedBy");
// ── Serialization helpers ────────────────────────────
function roundTrip(
world: World,
components: ComponentDef<any>[] = [
Position,
Velocity,
Health,
Shield,
Name,
Team,
],
rels: RelationshipDef[] = [ChildOf, Targeting, OwnedBy],
): World {
const json = JSON.stringify(world.toJSON());
return World.fromJSON(JSON.parse(json), components, rels);
}
function sortedIds(snap: WorldSnapshot): string[] {
return Object.keys(snap.entities).sort();
}
// ── Tests ────────────────────────────────────────────
describe("Serialization", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("serializes components by name", () => {
const e = world.spawn();
world.add(e, Position, { x: 10, y: 20 });
world.add(e, Velocity, { vx: 1, vy: 0 });
const snap = world.toJSON();
expect(snap.entities).toHaveProperty("e0");
expect(snap.entities.e0.position).toEqual({ x: 10, y: 20 });
expect(snap.entities.e0.velocity).toEqual({ vx: 1, vy: 0 });
});
it("round-trips components through JSON", () => {
const e = world.spawn();
world.add(e, Position, { x: 42, y: 99 });
world.add(e, Velocity, { vx: 2, vy: -1 });
const loaded = roundTrip(world);
const loadedEnts = [...loaded.query(query(Position, Velocity))];
expect(loadedEnts).toHaveLength(1);
const loadedE = loadedEnts[0];
expect(loaded.get(loadedE, Position)).toEqual({ x: 42, y: 99 });
expect(loaded.get(loadedE, Velocity)).toEqual({ vx: 2, vy: -1 });
});
it("handles multiple entities", () => {
world.spawn();
world.spawn();
const snap = world.toJSON();
const entries = Object.entries(snap.entities);
expect(entries).toHaveLength(2);
});
it("serializes relationships", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
const snap = world.toJSON();
expect(snap.relationships.childOf).toHaveProperty("e1");
expect(snap.relationships.childOf.e1).toBe("e0");
});
it("round-trips relationships", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
const loaded = roundTrip(world);
const reSnap = loaded.toJSON();
expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1);
});
it("throws on unknown component in snapshot", () => {
const snap: WorldSnapshot = {
entities: { e0: { unknownComp: { x: 1 } } },
relationships: {},
};
expect(() => World.fromJSON(snap, [Position])).toThrow(
'Unknown component "unknownComp"',
);
});
it("throws on unknown relationship in snapshot", () => {
const snap: WorldSnapshot = {
entities: { e0: { position: { x: 0, y: 0 } } },
relationships: { unknownRel: { e0: "e1" } },
};
expect(() => World.fromJSON(snap, [Position], [ChildOf])).toThrow(
'Unknown relationship "unknownRel"',
);
});
it("preserves entities with no components", () => {
world.spawn();
const snap = world.toJSON();
expect(snap.entities).toHaveProperty("e0");
expect(snap.entities.e0).toEqual({});
});
it("preserves bare entities on round-trip", () => {
world.spawn();
const loaded = roundTrip(world);
expect(loaded.entityCount).toBe(1);
const withPos = [...loaded.query(query(Position))];
expect(withPos).toHaveLength(0);
expect(loaded.entityCount).toBe(1);
});
it("entity IDs are re-sequential (hole collapsed)", () => {
world.spawn(); // e0
const b = world.spawn();
world.spawn(); // e2
world.destroy(b); // hole at e1
const snap = world.toJSON();
// Holes are collapsed during serialization
expect(sortedIds(snap)).toEqual(["e0", "e1"]);
});
it("round-trips JSON stringify and parse", () => {
const a = world.spawn();
world.add(a, Position, { x: 10, y: 20 });
world.add(a, Health, { current: 75, max: 100 });
const loaded = roundTrip(world);
const reSnap = loaded.toJSON();
expect(reSnap.entities.e0.position).toEqual({ x: 10, y: 20 });
expect(reSnap.entities.e0.health).toEqual({ current: 75, max: 100 });
});
it("empty world serializes to empty snapshot", () => {
const snap = world.toJSON();
expect(snap.entities).toEqual({});
expect(snap.relationships).toEqual({});
});
});
// ── Extended: rich mixed state ───────────────────────
describe("Serialization — complex state", () => {
let world: World;
function setupRichWorld() {
const w = new World();
// Player with many components
const player = w.spawn();
w.add(player, Position, { x: 100, y: 200 });
w.add(player, Velocity, { vx: 0, vy: 0 });
w.add(player, Health, { current: 85, max: 100 });
w.add(player, Shield, { armor: 20, broken: false });
w.add(player, Name, { value: "Hero" });
w.add(player, Team, { id: 1, color: "#ff0000" });
// Enemy
const enemy = w.spawn();
w.add(enemy, Position, { x: 500, y: 300 });
w.add(enemy, Health, { current: 50, max: 50 });
w.add(enemy, Team, { id: 2, color: "#0000ff" });
// Bullet (few components)
const bullet = w.spawn();
w.add(bullet, Position, { x: 100, y: 200 });
w.add(bullet, Velocity, { vx: 10, vy: 0 });
// Bare entity
w.spawn();
// Relationships
w.relate(bullet, OwnedBy, player);
w.relate(enemy, Targeting, player);
return { w, player, enemy, bullet };
}
it("round-trips a rich world", () => {
const { w, player, enemy, bullet } = setupRichWorld();
const loaded = roundTrip(w);
expect(loaded.entityCount).toBe(4);
// Find player by Name component
const players = [...loaded.query(query(Name))];
expect(players).toHaveLength(1);
const p = players[0];
expect(loaded.get(p, Position)).toEqual({ x: 100, y: 200 });
expect(loaded.get(p, Velocity)).toEqual({ vx: 0, vy: 0 });
expect(loaded.get(p, Health)).toEqual({ current: 85, max: 100 });
expect(loaded.get(p, Shield)).toEqual({ armor: 20, broken: false });
expect(loaded.get(p, Name)).toEqual({ value: "Hero" });
expect(loaded.get(p, Team)).toEqual({ id: 1, color: "#ff0000" });
// Find enemy
const enemies = [...loaded.query(query(Health, Team))].filter(
(e) => !loaded.has(e, Name),
);
expect(enemies).toHaveLength(1);
const en = enemies[0];
expect(loaded.get(en, Health)).toEqual({ current: 50, max: 50 });
// Bullets
const bullets = [...loaded.query(query(Position, Velocity))].filter(
(e) =>
!loaded.has(e, Health) &&
!loaded.has(e, Name) &&
!loaded.has(e, Shield),
);
expect(bullets).toHaveLength(1);
});
it("preserves relationships in rich world", () => {
const { w } = setupRichWorld();
const loaded = roundTrip(w);
const snap = loaded.toJSON();
// Verify relationship structure exists
expect(snap.relationships).toHaveProperty("ownedBy");
expect(snap.relationships).toHaveProperty("targeting");
const ownedByEdges = snap.relationships.ownedBy;
const targetingEdges = snap.relationships.targeting;
// OwnedBy: exactly one edge
expect(Object.keys(ownedByEdges)).toHaveLength(1);
// Targeting: exactly one edge
expect(Object.keys(targetingEdges)).toHaveLength(1);
});
it("relationships reference correct entities after round-trip", () => {
const { w } = setupRichWorld();
const snap = w.toJSON();
// Let's trace: bullet has Position+Velocity, is OwnedBy
// Find bullet's string id
const bulletId = Object.keys(snap.entities).find((id) => {
const comps = snap.entities[id];
return comps.position && comps.velocity && !comps.health;
})!;
const playerId = snap.relationships.ownedBy[bulletId];
// Player should have a name
const playerComps = snap.entities[playerId];
expect(playerComps).toHaveProperty("name");
expect((playerComps.name as { value: string }).value).toBe("Hero");
});
});
// ── Extended: multiple relationship types ────────────
describe("Serialization — multiple relationships", () => {
it("round-trips multiple relationship types on same entity", () => {
const w = new World();
const a = w.spawn();
const b = w.spawn();
const c = w.spawn();
w.relate(a, ChildOf, b);
w.relate(a, Targeting, c);
const loaded = roundTrip(w);
const snap = loaded.toJSON();
expect(Object.keys(snap.relationships.childOf)).toHaveLength(1);
expect(Object.keys(snap.relationships.targeting)).toHaveLength(1);
});
it("round-trips many-to-one relationships", () => {
const w = new World();
const parent = w.spawn();
const c1 = w.spawn();
const c2 = w.spawn();
const c3 = w.spawn();
w.relate(c1, ChildOf, parent);
w.relate(c2, ChildOf, parent);
w.relate(c3, ChildOf, parent);
const loaded = roundTrip(w);
const snap = loaded.toJSON();
const edges = snap.relationships.childOf;
const children = Object.keys(edges);
expect(children).toHaveLength(3);
// All three should point to the same parent
const parentId = edges[children[0]];
expect(edges[children[1]]).toBe(parentId);
expect(edges[children[2]]).toBe(parentId);
});
it("round-trips replaced relationships correctly", () => {
const w = new World();
const a = w.spawn();
const b = w.spawn();
const c = w.spawn();
w.relate(a, ChildOf, b);
w.relate(a, ChildOf, c); // replace b → c
const loaded = roundTrip(w);
const snap = loaded.toJSON();
const edges = snap.relationships.childOf;
expect(Object.keys(edges)).toHaveLength(1);
// Child pointer resolves forward
const aId = Object.keys(snap.entities).find(
(id) =>
!snap.relationships.childOf[id] &&
!Object.values(snap.relationships.childOf).includes(id),
);
// Actually, find a via exclusion: a has no components, b and c are targets.
// Let's just check the edge count is right.
expect(Object.keys(edges)).toHaveLength(1);
});
});
// ── Extended: nested / array data ────────────────────
describe("Serialization — nested data", () => {
const Inventory = defineComponent("inventory", {
items: [] as string[],
gold: 0,
});
const Transform = defineComponent("transform", {
position: { x: 0, y: 0 },
scale: { x: 1, y: 1 },
});
it("round-trips components with array values", () => {
const w = new World();
const e = w.spawn();
w.add(e, Inventory, { items: ["sword", "shield", "potion"], gold: 42 });
const loaded = roundTrip(w, [Inventory]);
const loadedE = [...loaded.query(query(Inventory))][0];
const inv = loaded.get(loadedE, Inventory);
expect(inv.items).toEqual(["sword", "shield", "potion"]);
expect(inv.gold).toBe(42);
});
it("round-trips components with nested object values", () => {
const w = new World();
const e = w.spawn();
w.add(e, Transform, {
position: { x: 10, y: 20 },
scale: { x: 2, y: 2 },
});
const loaded = roundTrip(w, [Transform]);
const loadedE = [...loaded.query(query(Transform))][0];
const t = loaded.get(loadedE, Transform);
expect(t.position).toEqual({ x: 10, y: 20 });
expect(t.scale).toEqual({ x: 2, y: 2 });
});
it("empty arrays survive round-trip", () => {
const w = new World();
const e = w.spawn();
w.add(e, Inventory, { items: [], gold: 0 });
const loaded = roundTrip(w, [Inventory]);
const loadedE = [...loaded.query(query(Inventory))][0];
expect(loaded.get(loadedE, Inventory).items).toEqual([]);
});
});
// ── Extended: observables still work after load ──────
describe("Serialization — observables after load", () => {
it("loaded world emits events on mutation", () => {
const w = new World();
w.spawn();
const loaded = roundTrip(w);
const events: WorldEvent[] = [];
loaded.events$.subscribe((e) => events.push(e));
const e = loaded.spawn();
expect(events).toHaveLength(1);
expect(events[0].type).toBe("spawned");
});
it("loaded world query observables work", () => {
const w = new World();
const e = w.spawn();
w.add(e, Position, { x: 1, y: 2 });
const loaded = roundTrip(w);
const updates: QueryUpdate[] = [];
loaded.observe(query(Position)).subscribe((u) => {
if (u.added.length || u.removed.length || u.changed.length) {
updates.push(u);
}
});
// Add a new Position entity
const e2 = loaded.spawn();
loaded.add(e2, Position, { x: 3, y: 4 });
expect(updates).toHaveLength(1);
expect(updates[0].added).toHaveLength(1);
});
it("loaded world relationship observables work", () => {
const w = new World();
const a = w.spawn();
const b = w.spawn();
w.relate(a, ChildOf, b);
const loaded = roundTrip(w);
const relUpdates: RelationshipUpdate[] = [];
loaded.observeRelated(ChildOf).subscribe((u) => relUpdates.push(u));
const c = loaded.spawn();
const d = loaded.spawn();
loaded.relate(c, ChildOf, d);
expect(relUpdates).toHaveLength(1);
expect(relUpdates[0].added).toHaveLength(1);
});
});
// ── Extended: stress ─────────────────────────────────
describe("Serialization — stress", () => {
it("round-trips 500 entities with mixed components", () => {
const w = new World();
for (let i = 0; i < 500; i++) {
const e = w.spawn();
w.add(e, Position, { x: i, y: i * 2 });
if (i % 2 === 0) {
w.add(e, Velocity, { vx: 1, vy: 0 });
}
if (i % 3 === 0) {
w.add(e, Health, { current: i, max: 1000 });
}
if (i % 5 === 0) {
w.add(e, Name, { value: `entity_${i}` });
}
}
// Add some relationships
const all = [...w.query(query(Position))];
for (let i = 0; i < 100; i++) {
const src = all[i * 2];
const tgt = all[i * 2 + 1];
if (src && tgt) {
w.relate(src, ChildOf, tgt);
}
}
const loaded = roundTrip(w);
expect(loaded.entityCount).toBe(500);
const withPos = [...loaded.query(query(Position))];
const withVel = [...loaded.query(query(Velocity))];
const withHealth = [...loaded.query(query(Health))];
expect(withPos).toHaveLength(500);
expect(withVel).toHaveLength(250); // every 2nd
expect(withHealth).toHaveLength(167); // every 3rd ≈ floor(499/3)+1
// Verify a few random entities
const e0 = withPos[0];
expect(loaded.get(e0, Position).x).toBe(0);
});
it("repeated round-trips are idempotent", () => {
const w = new World();
const e = w.spawn();
w.add(e, Position, { x: 10, y: 20 });
w.add(e, Health, { current: 75, max: 100 });
const loaded1 = roundTrip(w);
const loaded2 = roundTrip(loaded1);
expect(loaded2.entityCount).toBe(loaded1.entityCount);
const e2 = [...loaded2.query(query(Position))][0];
expect(loaded2.get(e2, Position)).toEqual({ x: 10, y: 20 });
expect(loaded2.get(e2, Health)).toEqual({ current: 75, max: 100 });
});
});

132
test/smoke.ts Normal file
View File

@ -0,0 +1,132 @@
import {
World,
defineComponent,
query,
QueryUpdate,
WorldEvent,
} from "../src/index";
// ── Define components ─────────────────────────────────
const Position = defineComponent({ x: 0, y: 0 });
const Velocity = defineComponent({ vx: 0, vy: 0 });
const Health = defineComponent({ current: 100, max: 100 });
const Dead = defineComponent({ timestamp: 0 });
// Type inference check
const _p: { x: number; y: number } = Position.defaults;
// ── World setup ──────────────────────────────────────
const world = new World();
let events: WorldEvent[] = [];
world.events$.subscribe((e) => events.push(e));
// ── Entity lifecycle ─────────────────────────────────
const player = world.spawn();
const enemy = world.spawn();
console.assert(events.length === 2, "spawn events");
console.assert(events[0].type === "spawned" && events[0].entity === player);
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
console.assert(world.isAlive(player), "player alive");
console.assert(world.isAlive(enemy), "enemy alive");
console.assert(world.entityCount === 2, "two entities");
// ── Add components ───────────────────────────────────
const pos = world.add(player, Position, { x: 10, y: 20 });
world.add(player, Velocity, { vx: 1, vy: 0 });
world.add(enemy, Position, { x: 50, y: 0 });
world.add(enemy, Health, { current: 50 });
console.assert(pos.x === 10 && pos.y === 20, "add with init");
console.assert(world.has(player, Position), "has Position");
console.assert(world.has(player, Velocity), "has Velocity");
console.assert(!world.has(player, Health), "no Health");
console.assert(events.length === 6, "component add events");
// ── Sync query ───────────────────────────────────────
const movable = [...world.query(query(Position, Velocity))];
console.assert(movable.length === 1, "player only in movable");
console.assert(movable[0] === player);
const allPos = [...world.query(query(Position))];
console.assert(allPos.length === 2, "both have Position");
// ── Observable query ─────────────────────────────────
const queryLog: QueryUpdate[] = [];
world.observe(query(Position, Velocity)).subscribe((u) => {
if (u.added.length || u.removed.length || u.changed.length) {
queryLog.push(u);
}
});
// ── Mutation + change tracking ───────────────────────
world.get(player, Position).x += 5;
world.markDirty(player, Position);
world.get(player, Velocity).vx *= 2;
world.markDirty(player, Velocity);
// flush should emit componentChanged events and update queries
world.flush();
console.assert(
events.some((e) => e.type === "componentChanged"),
"change events",
);
// The query observer should have received changed: [player]
const lastUpdate = queryLog[queryLog.length - 1];
console.assert(
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
"player in changed",
);
// ── Remove component ─────────────────────────────────
world.remove(player, Velocity);
console.assert(!world.has(player, Velocity), "Velocity removed");
const movableAfter = [...world.query(query(Position, Velocity))];
console.assert(movableAfter.length === 0, "no one movable after remove");
// The observer should have emitted {removed: [player]}
const remUpdate = queryLog[queryLog.length - 1];
console.assert(
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
"player removed from query",
);
// ── Destroy ──────────────────────────────────────────
world.destroy(enemy);
console.assert(!world.isAlive(enemy), "enemy destroyed");
console.assert(world.entityCount === 1, "one entity left");
// ── componentChanged query update ────────────────────
// Add enemy back, observe query(Health).without(Dead)
const enemy2 = world.spawn();
world.add(enemy2, Health, { current: 75 });
const healthLog: QueryUpdate[] = [];
world.observe(query(Health).without(Dead)).subscribe((u) => {
healthLog.push(u);
});
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
// Let's add Dead to trigger the removal
world.add(enemy2, Dead, { timestamp: 123 });
world.flush();
console.assert(
healthLog.some((u) => u.removed[0] === enemy2),
"enemy removed from health-not-dead query after gaining Dead",
);
// ── Entity recycling ─────────────────────────────────
world.destroy(player);
const recycled = world.spawn();
console.assert(recycled !== player, "recycled entity has new generation");
console.assert(world.isAlive(recycled), "recycled entity is alive");
console.assert(!world.isAlive(player), "old handle is dead");
console.log("✅ All smoke tests passed.");

View File

@ -1,361 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineComponent,
query,
type QueryUpdate,
type WorldEvent,
type Entity,
} from "../src/index";
// ── Components ──────────────────────────────────────
const Position = defineComponent("position", { x: 0, y: 0 });
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
const Health = defineComponent("health", { current: 100, max: 100 });
const Dead = defineComponent("dead", { timestamp: 0 });
// ── Helpers ────────────────────────────────────────
function collectUpdates(obs$: { subscribe: Function }): QueryUpdate[] {
const log: QueryUpdate[] = [];
obs$.subscribe((u: QueryUpdate) => {
if (u.added.length || u.removed.length || u.changed.length) log.push(u);
});
return log;
}
function collectEvents(world: World): WorldEvent[] {
const log: WorldEvent[] = [];
world.events$.subscribe((e: WorldEvent) => log.push(e));
return log;
}
// ── Entity lifecycle ───────────────────────────────
describe("Entity lifecycle", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("spawns entities", () => {
const e = world.spawn();
expect(world.isAlive(e)).toBe(true);
expect(world.entityCount).toBe(1);
});
it("emits spawn event", () => {
const events = collectEvents(world);
const e = world.spawn();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "spawned", entity: e });
});
it("destroys entities", () => {
const e = world.spawn();
world.destroy(e);
expect(world.isAlive(e)).toBe(false);
expect(world.entityCount).toBe(0);
});
it("emits destroy event", () => {
const events = collectEvents(world);
const e = world.spawn();
world.destroy(e);
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({ type: "destroyed", entity: e });
});
it("recycles entity indices with generation bump", () => {
const a = world.spawn();
world.destroy(a);
const b = world.spawn();
expect(b).not.toBe(a);
expect(world.isAlive(b)).toBe(true);
expect(world.isAlive(a)).toBe(false);
});
it("throws on operations with dead entity", () => {
const e = world.spawn();
world.destroy(e);
expect(() => world.get(e, Position)).toThrow("not alive");
expect(() => world.has(e, Position)).not.toThrow(); // has() is safe
});
});
// ── Components ──────────────────────────────────────
describe("Components", () => {
let world: World;
let entity: Entity;
beforeEach(() => {
world = new World();
entity = world.spawn() as Entity;
});
it("add returns defaults", () => {
const pos = world.add(entity, Position);
expect(pos).toEqual({ x: 0, y: 0 });
});
it("add overrides defaults with init", () => {
const pos = world.add(entity, Position, { x: 10, y: 20 });
expect(pos.x).toBe(10);
expect(pos.y).toBe(20);
});
it("get returns the live mutable object", () => {
world.add(entity, Position, { x: 5 });
const pos = world.get(entity, Position);
pos.x = 99;
expect(world.get(entity, Position).x).toBe(99);
});
it("tryGet returns undefined when absent", () => {
expect(world.tryGet(entity, Position)).toBeUndefined();
world.add(entity, Position);
expect(world.tryGet(entity, Position)).toEqual({ x: 0, y: 0 });
});
it("has checks component presence", () => {
expect(world.has(entity, Position)).toBe(false);
world.add(entity, Position);
expect(world.has(entity, Position)).toBe(true);
});
it("remove removes the component", () => {
world.add(entity, Position);
world.remove(entity, Position);
expect(world.has(entity, Position)).toBe(false);
});
it("remove is idempotent", () => {
expect(() => world.remove(entity, Position)).not.toThrow();
});
it("set replaces and marks dirty", () => {
world.add(entity, Position);
world.set(entity, Position, { x: 42, y: 99 });
expect(world.get(entity, Position)).toEqual({ x: 42, y: 99 });
});
it("set throws if component not added first", () => {
expect(() => world.set(entity, Position, { x: 1, y: 2 })).toThrow(
"Use add()",
);
});
it("emits componentAdded event", () => {
const events = collectEvents(world);
world.add(entity, Position);
expect(events.find((e) => e.type === "componentAdded")).toMatchObject({
type: "componentAdded",
entity,
});
});
it("emits componentRemoved event", () => {
world.add(entity, Position);
const events = collectEvents(world);
world.remove(entity, Position);
expect(events.find((e) => e.type === "componentRemoved")).toMatchObject({
type: "componentRemoved",
entity,
});
});
});
// ── Queries ─────────────────────────────────────────
describe("Sync queries", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("returns matching entities", () => {
const a = world.spawn();
world.add(a, Position);
world.add(a, Velocity);
const b = world.spawn();
world.add(b, Position);
const result = [...world.query(query(Position, Velocity))];
expect(result).toEqual([a]);
});
it("returns empty when no match", () => {
const e = world.spawn();
world.add(e, Position);
const result = [...world.query(query(Position, Velocity))];
expect(result).toHaveLength(0);
});
it("excludes with .without()", () => {
const a = world.spawn();
world.add(a, Health);
world.add(a, Dead);
const b = world.spawn();
world.add(b, Health);
const result = [...world.query(query(Health).without(Dead))];
expect(result).toEqual([b]);
});
});
// ── Observable queries ──────────────────────────────
describe("Observable queries", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits added when an entity later matches", () => {
const log = collectUpdates(world.observe(query(Position)));
const e = world.spawn();
world.add(e, Position);
expect(log).toHaveLength(1);
expect(log[0].added).toEqual([e]);
expect(log[0].removed).toEqual([]);
});
it("emits removed when an entity stops matching", () => {
const e = world.spawn();
world.add(e, Position);
const log = collectUpdates(world.observe(query(Position)));
world.remove(e, Position);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([e]);
});
it("emits removed on entity destroy", () => {
const e = world.spawn();
world.add(e, Position);
const log = collectUpdates(world.observe(query(Position)));
world.destroy(e);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([e]);
});
it("emits changed on matching entities after flush", () => {
const e = world.spawn();
world.add(e, Position);
const log = collectUpdates(world.observe(query(Position)));
world.get(e, Position).x += 1;
world.markDirty(e, Position);
world.flush();
expect(log).toHaveLength(1);
expect(log[0].changed).toEqual([e]);
});
it("seeds with currently matching entities on subscribe", () => {
const e = world.spawn();
world.add(e, Position);
// Next subscription should know e already matches
const log = collectUpdates(world.observe(query(Position)));
// Remove to trigger an event
world.remove(e, Position);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([e]);
});
it("handles .without() queries", () => {
const e = world.spawn();
world.add(e, Health);
const log = collectUpdates(world.observe(query(Health).without(Dead)));
world.add(e, Dead);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([e]);
});
});
// ── Change tracking ─────────────────────────────────
describe("Change tracking", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits componentChanged on flush", () => {
const e = world.spawn();
world.add(e, Position);
const events = collectEvents(world);
world.get(e, Position).x = 42;
world.markDirty(e, Position);
world.flush();
expect(events.some((ev) => ev.type === "componentChanged")).toBe(true);
});
it("batches multiple dirty marks into one flush", () => {
const a = world.spawn();
const b = world.spawn();
world.add(a, Position);
world.add(b, Position);
let changeCount = 0;
world.observe(query(Position)).subscribe((u) => {
changeCount += u.changed.length;
});
world.markDirty(a, Position);
world.markDirty(b, Position);
world.flush();
expect(changeCount).toBe(2);
});
it("set() implicitly marks dirty", () => {
const e = world.spawn();
world.add(e, Position);
const log = collectUpdates(world.observe(query(Position)));
world.set(e, Position, { x: 1, y: 2 });
world.flush();
expect(log).toHaveLength(1);
expect(log[0].changed).toEqual([e]);
});
it("clears dirty after flush", () => {
const e = world.spawn();
world.add(e, Position);
let changeCount = 0;
world.observe(query(Position)).subscribe((u) => {
changeCount += u.changed.length;
});
world.markDirty(e, Position);
world.flush();
expect(changeCount).toBe(1);
world.flush();
expect(changeCount).toBe(1); // no new emissions
});
});
// ── TypeScript inference ────────────────────────────
describe("Type safety", () => {
it("infers component type from defaults", () => {
const Shield = defineComponent("shield", { armor: 5, broken: false });
const s = Shield.defaults;
// compile-time check: these should be the inferred types
const _armor: number = s.armor;
const _broken: boolean = s.broken;
expect(typeof _armor).toBe("number");
expect(typeof _broken).toBe("boolean");
});
});

View File

@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
});