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": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
|
"test": "vitest run",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"rxjs": "^7.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ecs",
|
"ecs",
|
||||||
"entity-component-system",
|
"entity-component-system",
|
||||||
"rxjs",
|
|
||||||
"observable",
|
"observable",
|
||||||
"game"
|
"game"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const Position = defineComponent({ x: 0, y: 0 });
|
* const Position = defineComponent('position', { x: 0, y: 0 });
|
||||||
* type Position = typeof Position.type;
|
* type Position = typeof Position.type;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export interface ComponentDef<T extends Record<string, any>> {
|
export interface ComponentDef<T extends Record<string, any>> {
|
||||||
/** Unique symbol used as the storage key. */
|
/** Unique symbol used as the storage key. */
|
||||||
readonly _key: symbol;
|
readonly _key: symbol;
|
||||||
|
/** Human-readable name, used for serialization. */
|
||||||
|
readonly name: string;
|
||||||
/** Default values applied when a component is first added. */
|
/** Default values applied when a component is first added. */
|
||||||
readonly defaults: T;
|
readonly defaults: T;
|
||||||
/** Phantom type for inference. */
|
/** 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
|
* Define a component type. The name is used for serialization.
|
||||||
* TypeScript shape.
|
* The defaults object provides both the TypeScript shape and initial values.
|
||||||
*/
|
*/
|
||||||
export function defineComponent<T extends Record<string, any>>(
|
export function defineComponent<T extends Record<string, any>>(
|
||||||
defaults: T
|
name: string,
|
||||||
|
defaults: T,
|
||||||
): ComponentDef<T> {
|
): ComponentDef<T> {
|
||||||
return {
|
return {
|
||||||
_key: Symbol(),
|
_key: Symbol(),
|
||||||
|
name,
|
||||||
defaults: { ...defaults },
|
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 ─────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────
|
||||||
export { World } from './world';
|
export { World } from "./world";
|
||||||
export { defineComponent } from './component';
|
export { defineComponent } from "./component";
|
||||||
export type { ComponentDef } from './component';
|
export type { ComponentDef } from "./component";
|
||||||
export { query } from './query';
|
export { defineRelationship } from "./relationship";
|
||||||
export { Query } from './query';
|
export type { RelationshipDef } from "./relationship";
|
||||||
export type { Entity } from './entity';
|
export { query } from "./query";
|
||||||
export { makeEntity, entityIndex, entityGeneration } from './entity';
|
export { Query } from "./query";
|
||||||
export { SparseSet } from './storage/sparse-set';
|
export type { Entity } from "./entity";
|
||||||
export type { WorldEvent, QueryUpdate } from './observable/events';
|
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 { ComponentDef } from "../component";
|
||||||
import type { Entity } from '../entity';
|
import type { RelationshipDef } from "../relationship";
|
||||||
|
import type { Entity } from "../entity";
|
||||||
|
|
||||||
// ── World Events ──────────────────────────────────────
|
// ── World Events ──────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Discriminated union of all world-level events.
|
* Events that carry an `entity` field — component and lifecycle events.
|
||||||
* Emitted via `world.events$`.
|
|
||||||
*/
|
*/
|
||||||
export type WorldEvent =
|
export type EntityEvent =
|
||||||
| SpawnedEvent
|
| SpawnedEvent
|
||||||
| DestroyedEvent
|
| DestroyedEvent
|
||||||
| ComponentAddedEvent
|
| ComponentAddedEvent
|
||||||
| ComponentRemovedEvent
|
| ComponentRemovedEvent
|
||||||
| ComponentChangedEvent;
|
| 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 {
|
export interface SpawnedEvent {
|
||||||
type: 'spawned';
|
type: "spawned";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DestroyedEvent {
|
export interface DestroyedEvent {
|
||||||
type: 'destroyed';
|
type: "destroyed";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentAddedEvent {
|
export interface ComponentAddedEvent {
|
||||||
type: 'componentAdded';
|
type: "componentAdded";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
component: ComponentDef<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentRemovedEvent {
|
export interface ComponentRemovedEvent {
|
||||||
type: 'componentRemoved';
|
type: "componentRemoved";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
component: ComponentDef<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentChangedEvent {
|
export interface ComponentChangedEvent {
|
||||||
type: 'componentChanged';
|
type: "componentChanged";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
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 ────────────────────────────────
|
// ── Query Observables ────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Emitted by `world.observe(query)` when the result set changes.
|
* 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. */
|
/** Entities still matching that had a `markDirty` this frame. */
|
||||||
changed: Entity[];
|
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 { Query } from "../query";
|
||||||
import type { Entity } from "../entity";
|
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 {
|
interface QueryObserverState {
|
||||||
query: Query;
|
query: Query;
|
||||||
/** Cached set of entities currently matching the query. */
|
|
||||||
matched: Set<Entity>;
|
matched: Set<Entity>;
|
||||||
subject: Subject<QueryUpdate>;
|
subject: Subject<QueryUpdate>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RelationshipObserverState {
|
||||||
|
rel: RelationshipDef;
|
||||||
|
edges: Set<string>;
|
||||||
|
subject: Subject<RelationshipUpdate>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Observable layer ─────────────────────────────────
|
// ── Observable layer ─────────────────────────────────
|
||||||
/**
|
|
||||||
* Manages observable subscriptions for a World.
|
|
||||||
* Kept separate from the World class for clarity.
|
|
||||||
*/
|
|
||||||
export class ObservableLayer {
|
export class ObservableLayer {
|
||||||
/** Raw event stream. */
|
|
||||||
readonly events$ = new Subject<WorldEvent>();
|
readonly events$ = new Subject<WorldEvent>();
|
||||||
|
|
||||||
/** Active query observers. */
|
|
||||||
private _observers: QueryObserverState[] = [];
|
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> {
|
observe(query: Query): Subject<QueryUpdate> {
|
||||||
const existing = this._observers.find(
|
const existing = this._observers.find(
|
||||||
(o) => o.query === query || queriesEqual(o.query, query),
|
(o) => o.query === query || queriesEqual(o.query, query),
|
||||||
|
|
@ -39,48 +46,146 @@ export class ObservableLayer {
|
||||||
matched: new Set(),
|
matched: new Set(),
|
||||||
subject: new Subject<QueryUpdate>(),
|
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._observers.push(state);
|
||||||
|
this._indexObserver(state);
|
||||||
return state.subject;
|
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 {
|
seed(query: Query, entities: Entity[]): void {
|
||||||
const obs = this._observers.find(
|
const obs = this._observers.find(
|
||||||
(o) => o.query === query || queriesEqual(o.query, query),
|
(o) => o.query === query || queriesEqual(o.query, query),
|
||||||
);
|
);
|
||||||
if (!obs) return;
|
if (!obs) return;
|
||||||
|
for (const e of entities) obs.matched.add(e);
|
||||||
|
}
|
||||||
|
|
||||||
for (const e of entities) {
|
// ── Relationship observers ───────────────────────
|
||||||
obs.matched.add(e);
|
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Event dispatch ───────────────────────────────
|
||||||
* Feed an event into the observable system.
|
|
||||||
* Called by the World after state mutation.
|
|
||||||
*/
|
|
||||||
onEvent(
|
onEvent(
|
||||||
event: WorldEvent,
|
event: WorldEvent,
|
||||||
queryMatches: (query: Query, e: Entity) => boolean,
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
): void {
|
): void {
|
||||||
// Forward to the global stream
|
|
||||||
this.events$.next(event);
|
this.events$.next(event);
|
||||||
|
|
||||||
// Update each observer
|
this._dispatchToObservers(event, queryMatches);
|
||||||
for (const observer of this._observers) {
|
|
||||||
this._updateObserver(observer, 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(
|
private _updateObserver(
|
||||||
obs: QueryObserverState,
|
obs: QueryObserverState,
|
||||||
event: WorldEvent,
|
event: EntityEvent,
|
||||||
queryMatches: (query: Query, e: Entity) => boolean,
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const e = event.entity;
|
const e = event.entity;
|
||||||
|
|
@ -89,7 +194,6 @@ export class ObservableLayer {
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "spawned":
|
case "spawned":
|
||||||
// Entity is bare; won't match unless components added later
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "destroyed":
|
case "destroyed":
|
||||||
|
|
@ -120,25 +224,87 @@ export class ObservableLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all observer state (useful for tests). */
|
private _updateRelObserver(
|
||||||
reset(): void {
|
obs: RelationshipObserverState,
|
||||||
for (const obs of this._observers) {
|
event: WorldEvent,
|
||||||
obs.subject.complete();
|
): void {
|
||||||
obs.matched.clear();
|
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. */
|
// ── Teardown ─────────────────────────────────────
|
||||||
complete(): void {
|
|
||||||
this.events$.complete();
|
reset(): void {
|
||||||
for (const obs of this._observers) {
|
for (const o of this._observers) {
|
||||||
obs.subject.complete();
|
o.subject.complete();
|
||||||
|
o.matched.clear();
|
||||||
}
|
}
|
||||||
this._observers = [];
|
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 {
|
function queriesEqual(a: Query, b: Query): boolean {
|
||||||
if (a.with.length !== b.with.length) return false;
|
if (a.with.length !== b.with.length) return false;
|
||||||
if (a.not.length !== b.not.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])
|
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 type { Query } from "./query";
|
||||||
import { SparseSet } from "./storage/sparse-set";
|
import { SparseSet } from "./storage/sparse-set";
|
||||||
import { ObservableLayer } from "./observable/observe";
|
import { ObservableLayer } from "./observable/observe";
|
||||||
import type { QueryUpdate } from "./observable/events";
|
import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
|
||||||
import { Observable } from "rxjs";
|
import type { RelationshipDef } from "./relationship";
|
||||||
|
import type { Observable } from "./observable/subject";
|
||||||
|
import type { WorldEvent } from "./observable/events";
|
||||||
|
import type { WorldSnapshot } from "./serialization";
|
||||||
|
|
||||||
// ── World ─────────────────────────────────────────────
|
// ── World ─────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* The central ECS container.
|
* 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.
|
* Call `flush()` once per frame to emit batched observable events.
|
||||||
*/
|
*/
|
||||||
export class World {
|
export class World {
|
||||||
|
|
@ -23,6 +26,14 @@ export class World {
|
||||||
private _components = new Map<symbol, SparseSet<any>>();
|
private _components = new Map<symbol, SparseSet<any>>();
|
||||||
private _keyToDef = new Map<symbol, ComponentDef<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 ───────────────────────────────
|
// ── Change tracking ───────────────────────────────
|
||||||
private _dirty = new Map<symbol, Set<number>>();
|
private _dirty = new Map<symbol, Set<number>>();
|
||||||
|
|
||||||
|
|
@ -30,7 +41,7 @@ export class World {
|
||||||
private _observable = new ObservableLayer();
|
private _observable = new ObservableLayer();
|
||||||
|
|
||||||
/** Global event stream. */
|
/** Global event stream. */
|
||||||
get events$(): Observable<any> {
|
get events$(): Observable<WorldEvent> {
|
||||||
return this._observable.events$.asObservable();
|
return this._observable.events$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,11 +64,34 @@ export class World {
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy an entity, removing all its components. */
|
/** Destroy an entity, removing all its components and relationships. */
|
||||||
destroy(entity: Entity): void {
|
destroy(entity: Entity): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
if (!this._isAlive(idx, entity)) return;
|
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) {
|
for (const [, store] of this._components) {
|
||||||
store.remove(idx);
|
store.remove(idx);
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +112,6 @@ export class World {
|
||||||
|
|
||||||
// ── Component operations ──────────────────────────
|
// ── Component operations ──────────────────────────
|
||||||
|
|
||||||
/** Add a component to an entity. Returns the live value. */
|
|
||||||
add<T extends Record<string, any>>(
|
add<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
|
|
@ -95,7 +128,6 @@ export class World {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a component from an entity. */
|
|
||||||
remove(entity: Entity, def: ComponentDef<any>): void {
|
remove(entity: Entity, def: ComponentDef<any>): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, 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 {
|
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
|
|
@ -125,7 +156,6 @@ export class World {
|
||||||
return store.get(idx);
|
return store.get(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a mutable reference, or undefined if absent. */
|
|
||||||
tryGet<T extends Record<string, any>>(
|
tryGet<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
|
|
@ -135,7 +165,6 @@ export class World {
|
||||||
return this._components.get(def._key)?.tryGet(idx);
|
return this._components.get(def._key)?.tryGet(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if an entity has a component. */
|
|
||||||
has(entity: Entity, def: ComponentDef<any>): boolean {
|
has(entity: Entity, def: ComponentDef<any>): boolean {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
if (!this._isAlive(idx, entity)) return false;
|
if (!this._isAlive(idx, entity)) return false;
|
||||||
|
|
@ -143,7 +172,6 @@ export class World {
|
||||||
return store?.has(idx) ?? false;
|
return store?.has(idx) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace a component value. Sets the value and marks dirty. */
|
|
||||||
set<T extends Record<string, any>>(
|
set<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
|
|
@ -165,7 +193,6 @@ export class World {
|
||||||
|
|
||||||
// ── Change tracking ───────────────────────────────
|
// ── Change tracking ───────────────────────────────
|
||||||
|
|
||||||
/** Mark entity's component as dirty. Not emitted until `flush()`. */
|
|
||||||
markDirty(entity: Entity, def: ComponentDef<any>): void {
|
markDirty(entity: Entity, def: ComponentDef<any>): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
|
|
@ -178,7 +205,6 @@ export class World {
|
||||||
dirty.add(idx);
|
dirty.add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit all pending change events. Call once per frame. */
|
|
||||||
flush(): void {
|
flush(): void {
|
||||||
for (const [key, dirtySet] of this._dirty) {
|
for (const [key, dirtySet] of this._dirty) {
|
||||||
if (dirtySet.size === 0) continue;
|
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 ───────────────────────────────────────
|
// ── Queries ───────────────────────────────────────
|
||||||
|
|
||||||
/** Iterate all entities matching a query synchronously. */
|
|
||||||
*query(q: Query): IterableIterator<Entity> {
|
*query(q: Query): IterableIterator<Entity> {
|
||||||
const withStores = q.with.map((d) => this._components.get(d._key));
|
const withStores = q.with.map((d) => this._components.get(d._key));
|
||||||
const withoutStores = q.not.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> {
|
observe(q: Query): Observable<QueryUpdate> {
|
||||||
const subject = this._observable.observe(q);
|
const subject = this._observable.observe(q);
|
||||||
|
|
||||||
// Seed with currently-matching entities
|
|
||||||
const existing = [...this.query(q)];
|
const existing = [...this.query(q)];
|
||||||
this._observable.seed(q, existing);
|
this._observable.seed(q, existing);
|
||||||
|
|
||||||
return subject.asObservable();
|
return subject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total number of *alive* entities. */
|
|
||||||
get entityCount(): number {
|
get entityCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = 0; i < this._generations.length; i++) {
|
for (let i = 0; i < this._generations.length; i++) {
|
||||||
|
|
@ -238,6 +366,123 @@ export class World {
|
||||||
return count;
|
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 ─────────────────────────────────────
|
// ── Internals ─────────────────────────────────────
|
||||||
|
|
||||||
private _emit(event: import("./observable/events").WorldEvent): void {
|
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>>(
|
private _getOrCreateStore<T extends Record<string, any>>(
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
): SparseSet<T> {
|
): SparseSet<T> {
|
||||||
|
|
@ -281,4 +528,43 @@ export class World {
|
||||||
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
|
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