Compare commits
10 Commits
4ede2d7f3b
...
46da8abbe1
| Author | SHA1 | Date |
|---|---|---|
|
|
46da8abbe1 | |
|
|
87c01858e7 | |
|
|
81efb6cb0a | |
|
|
05674a349f | |
|
|
9953c7c556 | |
|
|
24616a0855 | |
|
|
1c55485f9f | |
|
|
d0bb119911 | |
|
|
32f8f29912 | |
|
|
ba4a688f57 |
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
28
src/index.ts
28
src/index.ts
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
324
src/world.ts
324
src/world.ts
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
132
test/smoke.ts
132
test/smoke.ts
|
|
@ -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.");
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue