Compare commits

...

10 Commits

Author SHA1 Message Date
hypercross 46da8abbe1 refactor: remove rxjs dependency
Replace rxjs with a lightweight internal Subject implementation
to reduce package size and complexity.
2026-05-31 16:38:27 +08:00
hypercross 87c01858e7 test: add missing types to serialization tests 2026-05-31 16:36:25 +08:00
hypercross 81efb6cb0a refactor: split WorldEvent into EntityEvent and RelEvent 2026-05-31 16:31:27 +08:00
hypercross 05674a349f refactor: improve type safety in World and tests
Replace `any` types with specific interfaces like `WorldEvent`,
`QueryUpdate`, and `Entity` to strengthen type checking. This includes
refining the deserialization logic in `World.fromSnapshot` to use
properly typed component definitions.
2026-05-31 16:24:59 +08:00
hypercross 9953c7c556 test: expand serialization test coverage 2026-05-31 16:13:01 +08:00
hypercross 24616a0855 perf(observable): index observers by component for faster dispatch
Introduce a component-to-observer index to avoid iterating over all
observers for every world event. This optimizes event dispatching by
only notifying observers whose queries are relevant to the specific
component being added, removed, or changed.
2026-05-31 16:10:51 +08:00
hypercross 1c55485f9f feat: add world serialization support
Introduce `toJSON` and `fromJSON` methods to the `World` class to
allow saving and restoring world states. This requires components and
relationships to have human-readable names for stable serialization.
2026-05-31 16:10:19 +08:00
hypercross d0bb119911 feat: implement relationship system 2026-05-31 15:54:29 +08:00
hypercross 32f8f29912 feat: add relationship support and observability
Introduces relationship management to the World, including
storage for forward and reverse relationships. Adds new observable
events and a `RelationshipUpdate` stream to track when relationships
are added or removed.
2026-05-31 15:54:21 +08:00
hypercross ba4a688f57 test: add vitest and implement unit tests 2026-05-31 15:47:19 +08:00
15 changed files with 3124 additions and 240 deletions

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,20 +19,17 @@
"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"
"typescript": "^5.6.0",
"vitest": "^4.1.7"
},
"keywords": [
"ecs",
"entity-component-system",
"rxjs",
"observable",
"game"
],

View File

@ -5,13 +5,15 @@
*
* @example
* ```ts
* const Position = defineComponent({ x: 0, y: 0 });
* const Position = defineComponent('position', { 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. */
@ -19,15 +21,17 @@ export interface ComponentDef<T extends Record<string, any>> {
}
/**
* Define a component type. The argument provides both default values and the
* TypeScript shape.
* Define a component type. The name is used for serialization.
* The defaults object provides both the TypeScript shape and initial values.
*/
export function defineComponent<T extends Record<string, any>>(
defaults: T
name: string,
defaults: T,
): ComponentDef<T> {
return {
_key: Symbol(),
name,
defaults: { ...defaults },
type: undefined as unknown as T, // phantom; never read at runtime
type: undefined as unknown as T,
};
}

View File

@ -1,10 +1,20 @@
// ── Public API ─────────────────────────────────────────
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';
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";

View File

@ -1,46 +1,71 @@
import type { ComponentDef } from '../component';
import type { Entity } from '../entity';
import type { ComponentDef } from "../component";
import type { RelationshipDef } from "../relationship";
import type { Entity } from "../entity";
// ── World Events ──────────────────────────────────────
/**
* Discriminated union of all world-level events.
* Emitted via `world.events$`.
* Events that carry an `entity` field component and lifecycle events.
*/
export type WorldEvent =
export type EntityEvent =
| 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.
@ -53,3 +78,13 @@ 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,33 +1,40 @@
import { Subject } from "rxjs";
import { Subject } from "./subject";
import type { Query } from "../query";
import type { Entity } from "../entity";
import type { WorldEvent, QueryUpdate } from "./events";
import type {
WorldEvent,
EntityEvent,
QueryUpdate,
RelationshipUpdate,
} from "./events";
import type { RelationshipDef } from "../relationship";
import type { ComponentDef } from "../component";
// ── Internal observer state per query ────────────────
// ── Internal state ───────────────────────────────────
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),
@ -39,48 +46,146 @@ 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);
}
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));
}
}
/**
* Feed an event into the observable system.
* Called by the World after state mutation.
*/
// ── Event dispatch ───────────────────────────────
onEvent(
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
// Forward to the global stream
this.events$.next(event);
// Update each observer
for (const observer of this._observers) {
this._updateObserver(observer, event, queryMatches);
this._dispatchToObservers(event, queryMatches);
for (const o of this._relObservers) {
this._updateRelObserver(o, event);
}
}
// ── 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: WorldEvent,
event: EntityEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
const e = event.entity;
@ -89,7 +194,6 @@ export class ObservableLayer {
switch (event.type) {
case "spawned":
// Entity is bare; won't match unless components added later
break;
case "destroyed":
@ -120,25 +224,87 @@ export class ObservableLayer {
}
}
/** Reset all observer state (useful for tests). */
reset(): void {
for (const obs of this._observers) {
obs.subject.complete();
obs.matched.clear();
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;
}
}
this._observers = [];
}
/** Complete all streams. */
complete(): void {
this.events$.complete();
for (const obs of this._observers) {
obs.subject.complete();
// ── Teardown ─────────────────────────────────────
reset(): void {
for (const o of this._observers) {
o.subject.complete();
o.matched.clear();
}
this._observers = [];
this._compIndex.clear();
for (const o of this._relObservers) {
o.subject.complete();
o.edges.clear();
}
this._relObservers = [];
}
complete(): void {
this.events$.complete();
for (const o of this._observers) o.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;
@ -147,3 +313,7 @@ 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}`;
}

50
src/observable/subject.ts Normal file
View File

@ -0,0 +1,50 @@
// ── 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);
},
};
}
}

24
src/relationship.ts Normal file
View File

@ -0,0 +1,24 @@
// ── 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 };
}

10
src/serialization.ts Normal file
View File

@ -0,0 +1,10 @@
/**
* 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,14 +4,17 @@ 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 } from "./observable/events";
import { Observable } from "rxjs";
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";
// ── World ─────────────────────────────────────────────
/**
* The central ECS container.
*
* Manages entities, components, queries, and change tracking.
* Manages entities, components, relationships, queries, and change tracking.
* Call `flush()` once per frame to emit batched observable events.
*/
export class World {
@ -23,6 +26,14 @@ 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>>();
@ -30,7 +41,7 @@ export class World {
private _observable = new ObservableLayer();
/** Global event stream. */
get events$(): Observable<any> {
get events$(): Observable<WorldEvent> {
return this._observable.events$.asObservable();
}
@ -53,11 +64,34 @@ export class World {
return e;
}
/** Destroy an entity, removing all its components. */
/** Destroy an entity, removing all its components and relationships. */
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);
}
@ -78,7 +112,6 @@ 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>,
@ -95,7 +128,6 @@ 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);
@ -111,7 +143,6 @@ 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);
@ -125,7 +156,6 @@ 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>,
@ -135,7 +165,6 @@ 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;
@ -143,7 +172,6 @@ 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>,
@ -165,7 +193,6 @@ 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);
@ -178,7 +205,6 @@ 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;
@ -197,9 +223,116 @@ 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));
@ -216,18 +349,13 @@ 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++) {
@ -238,6 +366,123 @@ 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 {
@ -258,6 +503,8 @@ export class World {
}
}
// ── Component storage helpers ────────────────────
private _getOrCreateStore<T extends Record<string, any>>(
def: ComponentDef<T>,
): SparseSet<T> {
@ -281,4 +528,43 @@ 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,
});
}
}

381
test/relationships.test.ts Normal file
View File

@ -0,0 +1,381 @@
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([]);
});
});

526
test/serialization.test.ts Normal file
View File

@ -0,0 +1,526 @@
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 });
});
});

View File

@ -1,132 +0,0 @@
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.");

361
test/world.test.ts Normal file
View File

@ -0,0 +1,361 @@
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");
});
});

7
vitest.config.ts Normal file
View File

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