Compare commits
No commits in common. "46da8abbe1dd843f2e786c23d91d993145a92be5" and "4ede2d7f3b934335907820c5b9e2cf9b4c6e88ca" have entirely different histories.
46da8abbe1
...
4ede2d7f3b
File diff suppressed because it is too large
Load Diff
|
|
@ -19,17 +19,20 @@
|
||||||
"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,15 +5,13 @@
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const Position = defineComponent('position', { x: 0, y: 0 });
|
* const Position = defineComponent({ 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. */
|
||||||
|
|
@ -21,17 +19,15 @@ export interface ComponentDef<T extends Record<string, any>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a component type. The name is used for serialization.
|
* Define a component type. The argument provides both default values and the
|
||||||
* The defaults object provides both the TypeScript shape and initial values.
|
* TypeScript shape.
|
||||||
*/
|
*/
|
||||||
export function defineComponent<T extends Record<string, any>>(
|
export function defineComponent<T extends Record<string, any>>(
|
||||||
name: string,
|
defaults: T
|
||||||
defaults: T,
|
|
||||||
): ComponentDef<T> {
|
): ComponentDef<T> {
|
||||||
return {
|
return {
|
||||||
_key: Symbol(),
|
_key: Symbol(),
|
||||||
name,
|
|
||||||
defaults: { ...defaults },
|
defaults: { ...defaults },
|
||||||
type: undefined as unknown as T,
|
type: undefined as unknown as T, // phantom; never read at runtime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
src/index.ts
28
src/index.ts
|
|
@ -1,20 +1,10 @@
|
||||||
// ── 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 { defineRelationship } from "./relationship";
|
export { query } from './query';
|
||||||
export type { RelationshipDef } from "./relationship";
|
export { Query } from './query';
|
||||||
export { query } from "./query";
|
export type { Entity } from './entity';
|
||||||
export { Query } from "./query";
|
export { makeEntity, entityIndex, entityGeneration } from './entity';
|
||||||
export type { Entity } from "./entity";
|
export { SparseSet } from './storage/sparse-set';
|
||||||
export { makeEntity, entityIndex, entityGeneration } from "./entity";
|
export type { WorldEvent, QueryUpdate } from './observable/events';
|
||||||
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,71 +1,46 @@
|
||||||
import type { ComponentDef } from "../component";
|
import type { ComponentDef } from '../component';
|
||||||
import type { RelationshipDef } from "../relationship";
|
import type { Entity } from '../entity';
|
||||||
import type { Entity } from "../entity";
|
|
||||||
|
|
||||||
// ── World Events ──────────────────────────────────────
|
// ── World Events ──────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Events that carry an `entity` field — component and lifecycle events.
|
* Discriminated union of all world-level events.
|
||||||
|
* Emitted via `world.events$`.
|
||||||
*/
|
*/
|
||||||
export type EntityEvent =
|
export type WorldEvent =
|
||||||
| SpawnedEvent
|
| 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.
|
||||||
|
|
@ -78,13 +53,3 @@ 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,40 +1,33 @@
|
||||||
import { Subject } from "./subject";
|
import { Subject } from "rxjs";
|
||||||
import type { Query } from "../query";
|
import type { Query } from "../query";
|
||||||
import type { Entity } from "../entity";
|
import type { Entity } from "../entity";
|
||||||
import type {
|
import type { WorldEvent, QueryUpdate } from "./events";
|
||||||
WorldEvent,
|
|
||||||
EntityEvent,
|
|
||||||
QueryUpdate,
|
|
||||||
RelationshipUpdate,
|
|
||||||
} from "./events";
|
|
||||||
import type { RelationshipDef } from "../relationship";
|
|
||||||
import type { ComponentDef } from "../component";
|
|
||||||
|
|
||||||
// ── Internal state ───────────────────────────────────
|
// ── Internal observer state per query ────────────────
|
||||||
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),
|
||||||
|
|
@ -46,146 +39,48 @@ 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Relationship observers ───────────────────────
|
for (const e of entities) {
|
||||||
|
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);
|
||||||
|
|
||||||
this._dispatchToObservers(event, queryMatches);
|
// Update each observer
|
||||||
|
for (const observer of this._observers) {
|
||||||
for (const o of this._relObservers) {
|
this._updateObserver(observer, event, queryMatches);
|
||||||
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: EntityEvent,
|
event: WorldEvent,
|
||||||
queryMatches: (query: Query, e: Entity) => boolean,
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const e = event.entity;
|
const e = event.entity;
|
||||||
|
|
@ -194,6 +89,7 @@ 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":
|
||||||
|
|
@ -224,87 +120,25 @@ export class ObservableLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateRelObserver(
|
/** Reset all observer state (useful for tests). */
|
||||||
obs: RelationshipObserverState,
|
|
||||||
event: WorldEvent,
|
|
||||||
): void {
|
|
||||||
switch (event.type) {
|
|
||||||
case "relationshipAdded": {
|
|
||||||
if (event.relationship._key !== obs.rel._key) break;
|
|
||||||
const key = edgeKey(event.source, event.target);
|
|
||||||
if (obs.edges.has(key)) break;
|
|
||||||
obs.edges.add(key);
|
|
||||||
obs.subject.next({
|
|
||||||
added: [{ source: event.source, target: event.target }],
|
|
||||||
removed: [],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "relationshipRemoved": {
|
|
||||||
if (event.relationship._key !== obs.rel._key) break;
|
|
||||||
const key = edgeKey(event.source, event.target);
|
|
||||||
if (!obs.edges.has(key)) break;
|
|
||||||
obs.edges.delete(key);
|
|
||||||
obs.subject.next({
|
|
||||||
added: [],
|
|
||||||
removed: [{ source: event.source, target: event.target }],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "destroyed": {
|
|
||||||
const removed: { source: Entity; target: Entity }[] = [];
|
|
||||||
for (const key of obs.edges) {
|
|
||||||
const [si, ti] = key.split(":").map(Number);
|
|
||||||
const idx = event.entity & 0xfffff;
|
|
||||||
if (si === idx || ti === idx) {
|
|
||||||
removed.push({ source: si as Entity, target: ti as Entity });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const r of removed) {
|
|
||||||
obs.edges.delete(edgeKey(r.source, r.target));
|
|
||||||
}
|
|
||||||
if (removed.length > 0) {
|
|
||||||
obs.subject.next({ added: [], removed });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Teardown ─────────────────────────────────────
|
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
for (const o of this._observers) {
|
for (const obs of this._observers) {
|
||||||
o.subject.complete();
|
obs.subject.complete();
|
||||||
o.matched.clear();
|
obs.matched.clear();
|
||||||
}
|
}
|
||||||
this._observers = [];
|
this._observers = [];
|
||||||
this._compIndex.clear();
|
|
||||||
|
|
||||||
for (const o of this._relObservers) {
|
|
||||||
o.subject.complete();
|
|
||||||
o.edges.clear();
|
|
||||||
}
|
|
||||||
this._relObservers = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Complete all streams. */
|
||||||
complete(): void {
|
complete(): void {
|
||||||
this.events$.complete();
|
this.events$.complete();
|
||||||
for (const o of this._observers) o.subject.complete();
|
for (const obs of this._observers) {
|
||||||
|
obs.subject.complete();
|
||||||
|
}
|
||||||
this._observers = [];
|
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;
|
||||||
|
|
@ -313,7 +147,3 @@ 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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// ── Internal Observable / Subject ─────────────────────
|
|
||||||
/** Minimal subscription handle returned by `.subscribe()`. */
|
|
||||||
export interface Subscription {
|
|
||||||
unsubscribe(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal observable interface — only supports single-callback subscribe. */
|
|
||||||
export interface Observable<T> {
|
|
||||||
subscribe(observer: (value: T) => void): Subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lightweight multicast subject, replacing the RxJS dependency. */
|
|
||||||
export class Subject<T> implements Observable<T> {
|
|
||||||
private _subs = new Set<(value: T) => void>();
|
|
||||||
private _done = false;
|
|
||||||
|
|
||||||
/** Push a value to all current subscribers. No-op after complete. */
|
|
||||||
next(value: T): void {
|
|
||||||
if (this._done) return;
|
|
||||||
for (const fn of this._subs) {
|
|
||||||
fn(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a subscriber. Returns a handle to unsubscribe. */
|
|
||||||
subscribe(observer: (value: T) => void): Subscription {
|
|
||||||
const fn = observer;
|
|
||||||
this._subs.add(fn);
|
|
||||||
return {
|
|
||||||
unsubscribe: () => {
|
|
||||||
this._subs.delete(fn);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Complete this subject — clears all subscribers and silences future calls. */
|
|
||||||
complete(): void {
|
|
||||||
this._done = true;
|
|
||||||
this._subs.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return a read-only Observable facade (hides `next` / `complete`). */
|
|
||||||
asObservable(): Observable<T> {
|
|
||||||
return {
|
|
||||||
subscribe: (observer: (value: T) => void): Subscription => {
|
|
||||||
return this.subscribe(observer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// ── Relationship ─────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* A relationship definition — like a component, but represents a directed
|
|
||||||
* link between two entities.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const ChildOf = defineRelationship('childOf');
|
|
||||||
* world.relate(child, ChildOf, parent);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface RelationshipDef {
|
|
||||||
/** Unique symbol used as the storage key. */
|
|
||||||
readonly _key: symbol;
|
|
||||||
/** Human-readable name, used for serialization. */
|
|
||||||
readonly name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define a named relationship between entities.
|
|
||||||
*/
|
|
||||||
export function defineRelationship(name: string): RelationshipDef {
|
|
||||||
return { _key: Symbol(), name };
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* Plain JSON-compatible representation of a World.
|
|
||||||
* Returned by `world.toJSON()`, consumed by `World.fromJSON()`.
|
|
||||||
*/
|
|
||||||
export interface WorldSnapshot {
|
|
||||||
/** Entity stable ID → component map (component name → data). */
|
|
||||||
entities: Record<string, Record<string, unknown>>;
|
|
||||||
/** Relationship name → (source ID → target ID). */
|
|
||||||
relationships: Record<string, Record<string, string>>;
|
|
||||||
}
|
|
||||||
324
src/world.ts
324
src/world.ts
|
|
@ -4,17 +4,14 @@ 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, RelationshipUpdate } from "./observable/events";
|
import type { QueryUpdate } from "./observable/events";
|
||||||
import type { RelationshipDef } from "./relationship";
|
import { Observable } from "rxjs";
|
||||||
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, relationships, queries, and change tracking.
|
* Manages entities, components, 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 {
|
||||||
|
|
@ -26,14 +23,6 @@ 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>>();
|
||||||
|
|
||||||
|
|
@ -41,7 +30,7 @@ export class World {
|
||||||
private _observable = new ObservableLayer();
|
private _observable = new ObservableLayer();
|
||||||
|
|
||||||
/** Global event stream. */
|
/** Global event stream. */
|
||||||
get events$(): Observable<WorldEvent> {
|
get events$(): Observable<any> {
|
||||||
return this._observable.events$.asObservable();
|
return this._observable.events$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,34 +53,11 @@ export class World {
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy an entity, removing all its components and relationships. */
|
/** Destroy an entity, removing all its components. */
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +78,7 @@ 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>,
|
||||||
|
|
@ -128,6 +95,7 @@ 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);
|
||||||
|
|
@ -143,6 +111,7 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get a mutable reference to a component. Throws if absent. */
|
||||||
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
|
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);
|
||||||
|
|
@ -156,6 +125,7 @@ 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>,
|
||||||
|
|
@ -165,6 +135,7 @@ 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;
|
||||||
|
|
@ -172,6 +143,7 @@ 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>,
|
||||||
|
|
@ -193,6 +165,7 @@ 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);
|
||||||
|
|
@ -205,6 +178,7 @@ 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;
|
||||||
|
|
@ -223,116 +197,9 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Relationships ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a directed relationship from `source` to `target`.
|
|
||||||
* Each source can only have one target per relationship type.
|
|
||||||
* If a relationship already exists, it is replaced.
|
|
||||||
*/
|
|
||||||
relate(source: Entity, rel: RelationshipDef, target: Entity): void {
|
|
||||||
const si = entityIndex(source);
|
|
||||||
const ti = entityIndex(target);
|
|
||||||
this._assertAlive(si, source);
|
|
||||||
this._assertAlive(ti, target);
|
|
||||||
|
|
||||||
// If source already has this relationship, remove it first
|
|
||||||
const existing = this.getRelated(source, rel);
|
|
||||||
if (existing !== undefined) {
|
|
||||||
this._relRemoveEdge(source, existing, rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._relEnsureMaps(rel);
|
|
||||||
|
|
||||||
// Forward
|
|
||||||
this._relForward.get(rel._key)!.set(si, target);
|
|
||||||
|
|
||||||
// Reverse
|
|
||||||
let rev = this._relReverse.get(rel._key)!.get(ti);
|
|
||||||
if (!rev) {
|
|
||||||
rev = new Set();
|
|
||||||
this._relReverse.get(rel._key)!.set(ti, rev);
|
|
||||||
}
|
|
||||||
rev.add(si);
|
|
||||||
|
|
||||||
this._emit({
|
|
||||||
type: "relationshipAdded",
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
relationship: rel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the relationship from `source`.
|
|
||||||
* No-op if no such relationship exists.
|
|
||||||
*/
|
|
||||||
unrelate(source: Entity, rel: RelationshipDef): void {
|
|
||||||
const si = entityIndex(source);
|
|
||||||
if (!this._isAlive(si, source)) return;
|
|
||||||
|
|
||||||
const target = this.getRelated(source, rel);
|
|
||||||
if (target === undefined) return;
|
|
||||||
|
|
||||||
this._relRemoveEdge(source, target, rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the target entity for a relationship, or undefined.
|
|
||||||
*/
|
|
||||||
getRelated(source: Entity, rel: RelationshipDef): Entity | undefined {
|
|
||||||
const si = entityIndex(source);
|
|
||||||
if (!this._isAlive(si, source)) return undefined;
|
|
||||||
|
|
||||||
const fwd = this._relForward.get(rel._key);
|
|
||||||
if (!fwd) return undefined;
|
|
||||||
return fwd.tryGet(si);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all source entities that point to `target` via this relationship.
|
|
||||||
*/
|
|
||||||
getRelatedTo(target: Entity, rel: RelationshipDef): Entity[] {
|
|
||||||
const ti = entityIndex(target);
|
|
||||||
if (!this._isAlive(ti, target)) return [];
|
|
||||||
|
|
||||||
const rev = this._relReverse.get(rel._key);
|
|
||||||
if (!rev) return [];
|
|
||||||
|
|
||||||
const sources = rev.get(ti);
|
|
||||||
if (!sources) return [];
|
|
||||||
|
|
||||||
return [...sources].map((si) => makeEntity(si, this._generations[si]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observe relationship changes for a given type.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* world.observeRelated(ChildOf).subscribe(update => {
|
|
||||||
* // update.added, update.removed
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
observeRelated(rel: RelationshipDef): Observable<RelationshipUpdate> {
|
|
||||||
const subject = this._observable.observeRelated(rel);
|
|
||||||
|
|
||||||
// Seed with current edges
|
|
||||||
const edges: { source: Entity; target: Entity }[] = [];
|
|
||||||
const fwd = this._relForward.get(rel._key);
|
|
||||||
if (fwd) {
|
|
||||||
for (const [si, target] of fwd.entries()) {
|
|
||||||
const source = makeEntity(si, this._generations[si]);
|
|
||||||
edges.push({ source, target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._observable.seedRelated(rel, edges);
|
|
||||||
|
|
||||||
return subject.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Queries ───────────────────────────────────────
|
// ── 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));
|
||||||
|
|
@ -349,13 +216,18 @@ 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++) {
|
||||||
|
|
@ -366,123 +238,6 @@ 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 {
|
||||||
|
|
@ -503,8 +258,6 @@ 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> {
|
||||||
|
|
@ -528,43 +281,4 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import {
|
|
||||||
World,
|
|
||||||
defineRelationship,
|
|
||||||
type RelationshipUpdate,
|
|
||||||
type WorldEvent,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
// ── Relationships ─────────────────────────────────────
|
|
||||||
const ChildOf = defineRelationship("childOf");
|
|
||||||
const Targeting = defineRelationship("targeting");
|
|
||||||
const Inside = defineRelationship("inside");
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────
|
|
||||||
function collectEvents(world: World): WorldEvent[] {
|
|
||||||
const log: WorldEvent[] = [];
|
|
||||||
world.events$.subscribe((e: WorldEvent) => log.push(e));
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectRelUpdates(obs$: {
|
|
||||||
subscribe: Function;
|
|
||||||
}): RelationshipUpdate[] {
|
|
||||||
const log: RelationshipUpdate[] = [];
|
|
||||||
obs$.subscribe((u: RelationshipUpdate) => log.push(u));
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Basic relate / unrelate ───────────────────────────
|
|
||||||
describe("Relationships", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relates two entities", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
expect(world.getRelated(child, ChildOf)).toBe(parent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelated returns undefined when no relationship", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelatedTo returns reverse lookup", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
|
|
||||||
world.relate(a, ChildOf, parent);
|
|
||||||
world.relate(b, ChildOf, parent);
|
|
||||||
|
|
||||||
const children = world.getRelatedTo(parent, ChildOf);
|
|
||||||
expect(children).toHaveLength(2);
|
|
||||||
expect(children).toContain(a);
|
|
||||||
expect(children).toContain(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelatedTo returns empty when no edges", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unrelate removes the relationship", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
world.unrelate(child, ChildOf);
|
|
||||||
|
|
||||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
|
||||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unrelate is idempotent", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
expect(() => world.unrelate(e, ChildOf)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relate replaces existing relationship", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
expect(world.getRelated(a, ChildOf)).toBe(b);
|
|
||||||
expect(world.getRelatedTo(b, ChildOf)).toContain(a);
|
|
||||||
|
|
||||||
world.relate(a, ChildOf, c);
|
|
||||||
expect(world.getRelated(a, ChildOf)).toBe(c);
|
|
||||||
// a should no longer point to b
|
|
||||||
expect(world.getRelatedTo(b, ChildOf)).toEqual([]);
|
|
||||||
expect(world.getRelatedTo(c, ChildOf)).toContain(a);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────
|
|
||||||
describe("Relationship events", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits relationshipAdded event", () => {
|
|
||||||
const events = collectEvents(world);
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const ev = events.find((e) => e.type === "relationshipAdded")!;
|
|
||||||
expect(ev).toMatchObject({
|
|
||||||
type: "relationshipAdded",
|
|
||||||
source: a,
|
|
||||||
target: b,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits relationshipRemoved on unrelate", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const events = collectEvents(world);
|
|
||||||
world.unrelate(a, ChildOf);
|
|
||||||
|
|
||||||
const ev = events.find((e) => e.type === "relationshipRemoved")!;
|
|
||||||
expect(ev).toMatchObject({
|
|
||||||
type: "relationshipRemoved",
|
|
||||||
source: a,
|
|
||||||
target: b,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits relationshipRemoved when replacing an edge", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const events = collectEvents(world);
|
|
||||||
world.relate(a, ChildOf, c);
|
|
||||||
|
|
||||||
const removed = events.filter((e) => e.type === "relationshipRemoved");
|
|
||||||
const added = events.filter((e) => e.type === "relationshipAdded");
|
|
||||||
|
|
||||||
expect(removed).toHaveLength(1);
|
|
||||||
expect(removed[0]).toMatchObject({ source: a, target: b });
|
|
||||||
expect(added).toHaveLength(1);
|
|
||||||
expect(added[0]).toMatchObject({ source: a, target: c });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Observable relationships ──────────────────────────
|
|
||||||
describe("Observable relationships", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits added on relate", () => {
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].added).toEqual([{ source: a, target: b }]);
|
|
||||||
expect(log[0].removed).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits removed on unrelate", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
world.unrelate(a, ChildOf);
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits removed+added on replacement", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
world.relate(a, ChildOf, c);
|
|
||||||
|
|
||||||
// Should have two updates: one removed, one added
|
|
||||||
expect(log).toHaveLength(2);
|
|
||||||
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
|
||||||
expect(log[1].added).toEqual([{ source: a, target: c }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds with existing relationships", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
|
|
||||||
// Unrelate should trigger removed — proving seed worked
|
|
||||||
world.unrelate(a, ChildOf);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("observers are scoped to relationship type", () => {
|
|
||||||
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
|
||||||
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
expect(childLog).toHaveLength(1);
|
|
||||||
expect(targetLog).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Destroy cleanup ───────────────────────────────────
|
|
||||||
describe("Destroy cleanup", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes edges when source is destroyed", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
|
|
||||||
world.destroy(child);
|
|
||||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
|
||||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes edges when target is destroyed", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
|
|
||||||
world.destroy(parent);
|
|
||||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
|
||||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits relationshipRemoved events on destroy", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
|
|
||||||
world.relate(a, Targeting, b);
|
|
||||||
world.relate(a, ChildOf, c);
|
|
||||||
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
const tLog = collectRelUpdates(world.observeRelated(Targeting));
|
|
||||||
|
|
||||||
world.destroy(a);
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([{ source: a, target: c }]);
|
|
||||||
|
|
||||||
expect(tLog).toHaveLength(1);
|
|
||||||
expect(tLog[0].removed).toEqual([{ source: a, target: b }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects cross-relationship observers when destroying target", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
|
|
||||||
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
world.destroy(parent);
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([{ source: child, target: parent }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles destroy when entity is source for multiple relationships", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
world.relate(a, Targeting, c);
|
|
||||||
|
|
||||||
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
|
||||||
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
|
||||||
|
|
||||||
world.destroy(a);
|
|
||||||
|
|
||||||
expect(childLog).toHaveLength(1);
|
|
||||||
expect(childLog[0].removed).toEqual([{ source: a, target: b }]);
|
|
||||||
|
|
||||||
expect(targetLog).toHaveLength(1);
|
|
||||||
expect(targetLog[0].removed).toEqual([{ source: a, target: c }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multiple relationship types ──────────────────────
|
|
||||||
describe("Multiple relationships", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an entity can have different relationship types simultaneously", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
|
|
||||||
world.relate(e, ChildOf, a);
|
|
||||||
world.relate(e, Targeting, b);
|
|
||||||
|
|
||||||
expect(world.getRelated(e, ChildOf)).toBe(a);
|
|
||||||
expect(world.getRelated(e, Targeting)).toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relationships of different types don't interfere", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
|
|
||||||
world.relate(a, ChildOf, b);
|
|
||||||
world.relate(a, Targeting, c);
|
|
||||||
|
|
||||||
world.unrelate(a, ChildOf);
|
|
||||||
|
|
||||||
expect(world.getRelated(a, ChildOf)).toBeUndefined();
|
|
||||||
// Targeting should still be intact
|
|
||||||
expect(world.getRelated(a, Targeting)).toBe(c);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Dead entities ─────────────────────────────────────
|
|
||||||
describe("Dead entity safety", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relate throws on dead source", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.destroy(a);
|
|
||||||
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relate throws on dead target", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.destroy(b);
|
|
||||||
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelated returns undefined for dead entity", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.destroy(e);
|
|
||||||
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelatedTo returns empty for dead entity", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.destroy(e);
|
|
||||||
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,526 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import {
|
|
||||||
World,
|
|
||||||
defineComponent,
|
|
||||||
defineRelationship,
|
|
||||||
type WorldSnapshot,
|
|
||||||
query,
|
|
||||||
type QueryUpdate,
|
|
||||||
type RelationshipUpdate,
|
|
||||||
type WorldEvent,
|
|
||||||
type ComponentDef,
|
|
||||||
type RelationshipDef,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
// ── Definitions ─────────────────────────────────────
|
|
||||||
const Position = defineComponent("position", { x: 0, y: 0 });
|
|
||||||
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
|
||||||
const Health = defineComponent("health", { current: 100, max: 100 });
|
|
||||||
const Shield = defineComponent("shield", { armor: 5, broken: false });
|
|
||||||
const Name = defineComponent("name", { value: "" });
|
|
||||||
const Team = defineComponent("team", { id: 0, color: "#fff" });
|
|
||||||
|
|
||||||
const ChildOf = defineRelationship("childOf");
|
|
||||||
const Targeting = defineRelationship("targeting");
|
|
||||||
const OwnedBy = defineRelationship("ownedBy");
|
|
||||||
|
|
||||||
// ── Serialization helpers ────────────────────────────
|
|
||||||
function roundTrip(
|
|
||||||
world: World,
|
|
||||||
components: ComponentDef<any>[] = [
|
|
||||||
Position,
|
|
||||||
Velocity,
|
|
||||||
Health,
|
|
||||||
Shield,
|
|
||||||
Name,
|
|
||||||
Team,
|
|
||||||
],
|
|
||||||
rels: RelationshipDef[] = [ChildOf, Targeting, OwnedBy],
|
|
||||||
): World {
|
|
||||||
const json = JSON.stringify(world.toJSON());
|
|
||||||
return World.fromJSON(JSON.parse(json), components, rels);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortedIds(snap: WorldSnapshot): string[] {
|
|
||||||
return Object.keys(snap.entities).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────
|
|
||||||
describe("Serialization", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes components by name", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position, { x: 10, y: 20 });
|
|
||||||
world.add(e, Velocity, { vx: 1, vy: 0 });
|
|
||||||
|
|
||||||
const snap = world.toJSON();
|
|
||||||
expect(snap.entities).toHaveProperty("e0");
|
|
||||||
expect(snap.entities.e0.position).toEqual({ x: 10, y: 20 });
|
|
||||||
expect(snap.entities.e0.velocity).toEqual({ vx: 1, vy: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips components through JSON", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position, { x: 42, y: 99 });
|
|
||||||
world.add(e, Velocity, { vx: 2, vy: -1 });
|
|
||||||
|
|
||||||
const loaded = roundTrip(world);
|
|
||||||
|
|
||||||
const loadedEnts = [...loaded.query(query(Position, Velocity))];
|
|
||||||
expect(loadedEnts).toHaveLength(1);
|
|
||||||
|
|
||||||
const loadedE = loadedEnts[0];
|
|
||||||
expect(loaded.get(loadedE, Position)).toEqual({ x: 42, y: 99 });
|
|
||||||
expect(loaded.get(loadedE, Velocity)).toEqual({ vx: 2, vy: -1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiple entities", () => {
|
|
||||||
world.spawn();
|
|
||||||
world.spawn();
|
|
||||||
|
|
||||||
const snap = world.toJSON();
|
|
||||||
const entries = Object.entries(snap.entities);
|
|
||||||
expect(entries).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes relationships", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
|
|
||||||
const snap = world.toJSON();
|
|
||||||
expect(snap.relationships.childOf).toHaveProperty("e1");
|
|
||||||
expect(snap.relationships.childOf.e1).toBe("e0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips relationships", () => {
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
world.relate(child, ChildOf, parent);
|
|
||||||
|
|
||||||
const loaded = roundTrip(world);
|
|
||||||
|
|
||||||
const reSnap = loaded.toJSON();
|
|
||||||
expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on unknown component in snapshot", () => {
|
|
||||||
const snap: WorldSnapshot = {
|
|
||||||
entities: { e0: { unknownComp: { x: 1 } } },
|
|
||||||
relationships: {},
|
|
||||||
};
|
|
||||||
expect(() => World.fromJSON(snap, [Position])).toThrow(
|
|
||||||
'Unknown component "unknownComp"',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on unknown relationship in snapshot", () => {
|
|
||||||
const snap: WorldSnapshot = {
|
|
||||||
entities: { e0: { position: { x: 0, y: 0 } } },
|
|
||||||
relationships: { unknownRel: { e0: "e1" } },
|
|
||||||
};
|
|
||||||
expect(() => World.fromJSON(snap, [Position], [ChildOf])).toThrow(
|
|
||||||
'Unknown relationship "unknownRel"',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves entities with no components", () => {
|
|
||||||
world.spawn();
|
|
||||||
const snap = world.toJSON();
|
|
||||||
expect(snap.entities).toHaveProperty("e0");
|
|
||||||
expect(snap.entities.e0).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves bare entities on round-trip", () => {
|
|
||||||
world.spawn();
|
|
||||||
const loaded = roundTrip(world);
|
|
||||||
expect(loaded.entityCount).toBe(1);
|
|
||||||
|
|
||||||
const withPos = [...loaded.query(query(Position))];
|
|
||||||
expect(withPos).toHaveLength(0);
|
|
||||||
expect(loaded.entityCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("entity IDs are re-sequential (hole collapsed)", () => {
|
|
||||||
world.spawn(); // e0
|
|
||||||
const b = world.spawn();
|
|
||||||
world.spawn(); // e2
|
|
||||||
world.destroy(b); // hole at e1
|
|
||||||
|
|
||||||
const snap = world.toJSON();
|
|
||||||
// Holes are collapsed during serialization
|
|
||||||
expect(sortedIds(snap)).toEqual(["e0", "e1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips JSON stringify and parse", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
world.add(a, Position, { x: 10, y: 20 });
|
|
||||||
world.add(a, Health, { current: 75, max: 100 });
|
|
||||||
|
|
||||||
const loaded = roundTrip(world);
|
|
||||||
const reSnap = loaded.toJSON();
|
|
||||||
|
|
||||||
expect(reSnap.entities.e0.position).toEqual({ x: 10, y: 20 });
|
|
||||||
expect(reSnap.entities.e0.health).toEqual({ current: 75, max: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty world serializes to empty snapshot", () => {
|
|
||||||
const snap = world.toJSON();
|
|
||||||
expect(snap.entities).toEqual({});
|
|
||||||
expect(snap.relationships).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extended: rich mixed state ───────────────────────
|
|
||||||
describe("Serialization — complex state", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
function setupRichWorld() {
|
|
||||||
const w = new World();
|
|
||||||
|
|
||||||
// Player with many components
|
|
||||||
const player = w.spawn();
|
|
||||||
w.add(player, Position, { x: 100, y: 200 });
|
|
||||||
w.add(player, Velocity, { vx: 0, vy: 0 });
|
|
||||||
w.add(player, Health, { current: 85, max: 100 });
|
|
||||||
w.add(player, Shield, { armor: 20, broken: false });
|
|
||||||
w.add(player, Name, { value: "Hero" });
|
|
||||||
w.add(player, Team, { id: 1, color: "#ff0000" });
|
|
||||||
|
|
||||||
// Enemy
|
|
||||||
const enemy = w.spawn();
|
|
||||||
w.add(enemy, Position, { x: 500, y: 300 });
|
|
||||||
w.add(enemy, Health, { current: 50, max: 50 });
|
|
||||||
w.add(enemy, Team, { id: 2, color: "#0000ff" });
|
|
||||||
|
|
||||||
// Bullet (few components)
|
|
||||||
const bullet = w.spawn();
|
|
||||||
w.add(bullet, Position, { x: 100, y: 200 });
|
|
||||||
w.add(bullet, Velocity, { vx: 10, vy: 0 });
|
|
||||||
|
|
||||||
// Bare entity
|
|
||||||
w.spawn();
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
w.relate(bullet, OwnedBy, player);
|
|
||||||
w.relate(enemy, Targeting, player);
|
|
||||||
|
|
||||||
return { w, player, enemy, bullet };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("round-trips a rich world", () => {
|
|
||||||
const { w, player, enemy, bullet } = setupRichWorld();
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
|
|
||||||
expect(loaded.entityCount).toBe(4);
|
|
||||||
|
|
||||||
// Find player by Name component
|
|
||||||
const players = [...loaded.query(query(Name))];
|
|
||||||
expect(players).toHaveLength(1);
|
|
||||||
const p = players[0];
|
|
||||||
expect(loaded.get(p, Position)).toEqual({ x: 100, y: 200 });
|
|
||||||
expect(loaded.get(p, Velocity)).toEqual({ vx: 0, vy: 0 });
|
|
||||||
expect(loaded.get(p, Health)).toEqual({ current: 85, max: 100 });
|
|
||||||
expect(loaded.get(p, Shield)).toEqual({ armor: 20, broken: false });
|
|
||||||
expect(loaded.get(p, Name)).toEqual({ value: "Hero" });
|
|
||||||
expect(loaded.get(p, Team)).toEqual({ id: 1, color: "#ff0000" });
|
|
||||||
|
|
||||||
// Find enemy
|
|
||||||
const enemies = [...loaded.query(query(Health, Team))].filter(
|
|
||||||
(e) => !loaded.has(e, Name),
|
|
||||||
);
|
|
||||||
expect(enemies).toHaveLength(1);
|
|
||||||
const en = enemies[0];
|
|
||||||
expect(loaded.get(en, Health)).toEqual({ current: 50, max: 50 });
|
|
||||||
|
|
||||||
// Bullets
|
|
||||||
const bullets = [...loaded.query(query(Position, Velocity))].filter(
|
|
||||||
(e) =>
|
|
||||||
!loaded.has(e, Health) &&
|
|
||||||
!loaded.has(e, Name) &&
|
|
||||||
!loaded.has(e, Shield),
|
|
||||||
);
|
|
||||||
expect(bullets).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves relationships in rich world", () => {
|
|
||||||
const { w } = setupRichWorld();
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
|
|
||||||
const snap = loaded.toJSON();
|
|
||||||
|
|
||||||
// Verify relationship structure exists
|
|
||||||
expect(snap.relationships).toHaveProperty("ownedBy");
|
|
||||||
expect(snap.relationships).toHaveProperty("targeting");
|
|
||||||
|
|
||||||
const ownedByEdges = snap.relationships.ownedBy;
|
|
||||||
const targetingEdges = snap.relationships.targeting;
|
|
||||||
|
|
||||||
// OwnedBy: exactly one edge
|
|
||||||
expect(Object.keys(ownedByEdges)).toHaveLength(1);
|
|
||||||
// Targeting: exactly one edge
|
|
||||||
expect(Object.keys(targetingEdges)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relationships reference correct entities after round-trip", () => {
|
|
||||||
const { w } = setupRichWorld();
|
|
||||||
const snap = w.toJSON();
|
|
||||||
|
|
||||||
// Let's trace: bullet has Position+Velocity, is OwnedBy
|
|
||||||
// Find bullet's string id
|
|
||||||
const bulletId = Object.keys(snap.entities).find((id) => {
|
|
||||||
const comps = snap.entities[id];
|
|
||||||
return comps.position && comps.velocity && !comps.health;
|
|
||||||
})!;
|
|
||||||
const playerId = snap.relationships.ownedBy[bulletId];
|
|
||||||
|
|
||||||
// Player should have a name
|
|
||||||
const playerComps = snap.entities[playerId];
|
|
||||||
expect(playerComps).toHaveProperty("name");
|
|
||||||
expect((playerComps.name as { value: string }).value).toBe("Hero");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extended: multiple relationship types ────────────
|
|
||||||
describe("Serialization — multiple relationships", () => {
|
|
||||||
it("round-trips multiple relationship types on same entity", () => {
|
|
||||||
const w = new World();
|
|
||||||
const a = w.spawn();
|
|
||||||
const b = w.spawn();
|
|
||||||
const c = w.spawn();
|
|
||||||
|
|
||||||
w.relate(a, ChildOf, b);
|
|
||||||
w.relate(a, Targeting, c);
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const snap = loaded.toJSON();
|
|
||||||
|
|
||||||
expect(Object.keys(snap.relationships.childOf)).toHaveLength(1);
|
|
||||||
expect(Object.keys(snap.relationships.targeting)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips many-to-one relationships", () => {
|
|
||||||
const w = new World();
|
|
||||||
const parent = w.spawn();
|
|
||||||
const c1 = w.spawn();
|
|
||||||
const c2 = w.spawn();
|
|
||||||
const c3 = w.spawn();
|
|
||||||
|
|
||||||
w.relate(c1, ChildOf, parent);
|
|
||||||
w.relate(c2, ChildOf, parent);
|
|
||||||
w.relate(c3, ChildOf, parent);
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const snap = loaded.toJSON();
|
|
||||||
|
|
||||||
const edges = snap.relationships.childOf;
|
|
||||||
const children = Object.keys(edges);
|
|
||||||
expect(children).toHaveLength(3);
|
|
||||||
|
|
||||||
// All three should point to the same parent
|
|
||||||
const parentId = edges[children[0]];
|
|
||||||
expect(edges[children[1]]).toBe(parentId);
|
|
||||||
expect(edges[children[2]]).toBe(parentId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips replaced relationships correctly", () => {
|
|
||||||
const w = new World();
|
|
||||||
const a = w.spawn();
|
|
||||||
const b = w.spawn();
|
|
||||||
const c = w.spawn();
|
|
||||||
|
|
||||||
w.relate(a, ChildOf, b);
|
|
||||||
w.relate(a, ChildOf, c); // replace b → c
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const snap = loaded.toJSON();
|
|
||||||
|
|
||||||
const edges = snap.relationships.childOf;
|
|
||||||
expect(Object.keys(edges)).toHaveLength(1);
|
|
||||||
|
|
||||||
// Child pointer resolves forward
|
|
||||||
const aId = Object.keys(snap.entities).find(
|
|
||||||
(id) =>
|
|
||||||
!snap.relationships.childOf[id] &&
|
|
||||||
!Object.values(snap.relationships.childOf).includes(id),
|
|
||||||
);
|
|
||||||
// Actually, find a via exclusion: a has no components, b and c are targets.
|
|
||||||
// Let's just check the edge count is right.
|
|
||||||
expect(Object.keys(edges)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extended: nested / array data ────────────────────
|
|
||||||
describe("Serialization — nested data", () => {
|
|
||||||
const Inventory = defineComponent("inventory", {
|
|
||||||
items: [] as string[],
|
|
||||||
gold: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Transform = defineComponent("transform", {
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
scale: { x: 1, y: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips components with array values", () => {
|
|
||||||
const w = new World();
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Inventory, { items: ["sword", "shield", "potion"], gold: 42 });
|
|
||||||
|
|
||||||
const loaded = roundTrip(w, [Inventory]);
|
|
||||||
const loadedE = [...loaded.query(query(Inventory))][0];
|
|
||||||
|
|
||||||
const inv = loaded.get(loadedE, Inventory);
|
|
||||||
expect(inv.items).toEqual(["sword", "shield", "potion"]);
|
|
||||||
expect(inv.gold).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips components with nested object values", () => {
|
|
||||||
const w = new World();
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Transform, {
|
|
||||||
position: { x: 10, y: 20 },
|
|
||||||
scale: { x: 2, y: 2 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const loaded = roundTrip(w, [Transform]);
|
|
||||||
const loadedE = [...loaded.query(query(Transform))][0];
|
|
||||||
|
|
||||||
const t = loaded.get(loadedE, Transform);
|
|
||||||
expect(t.position).toEqual({ x: 10, y: 20 });
|
|
||||||
expect(t.scale).toEqual({ x: 2, y: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty arrays survive round-trip", () => {
|
|
||||||
const w = new World();
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Inventory, { items: [], gold: 0 });
|
|
||||||
|
|
||||||
const loaded = roundTrip(w, [Inventory]);
|
|
||||||
const loadedE = [...loaded.query(query(Inventory))][0];
|
|
||||||
|
|
||||||
expect(loaded.get(loadedE, Inventory).items).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extended: observables still work after load ──────
|
|
||||||
describe("Serialization — observables after load", () => {
|
|
||||||
it("loaded world emits events on mutation", () => {
|
|
||||||
const w = new World();
|
|
||||||
w.spawn();
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const events: WorldEvent[] = [];
|
|
||||||
loaded.events$.subscribe((e) => events.push(e));
|
|
||||||
|
|
||||||
const e = loaded.spawn();
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0].type).toBe("spawned");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loaded world query observables work", () => {
|
|
||||||
const w = new World();
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Position, { x: 1, y: 2 });
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const updates: QueryUpdate[] = [];
|
|
||||||
loaded.observe(query(Position)).subscribe((u) => {
|
|
||||||
if (u.added.length || u.removed.length || u.changed.length) {
|
|
||||||
updates.push(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a new Position entity
|
|
||||||
const e2 = loaded.spawn();
|
|
||||||
loaded.add(e2, Position, { x: 3, y: 4 });
|
|
||||||
|
|
||||||
expect(updates).toHaveLength(1);
|
|
||||||
expect(updates[0].added).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loaded world relationship observables work", () => {
|
|
||||||
const w = new World();
|
|
||||||
const a = w.spawn();
|
|
||||||
const b = w.spawn();
|
|
||||||
w.relate(a, ChildOf, b);
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
const relUpdates: RelationshipUpdate[] = [];
|
|
||||||
loaded.observeRelated(ChildOf).subscribe((u) => relUpdates.push(u));
|
|
||||||
|
|
||||||
const c = loaded.spawn();
|
|
||||||
const d = loaded.spawn();
|
|
||||||
loaded.relate(c, ChildOf, d);
|
|
||||||
|
|
||||||
expect(relUpdates).toHaveLength(1);
|
|
||||||
expect(relUpdates[0].added).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extended: stress ─────────────────────────────────
|
|
||||||
describe("Serialization — stress", () => {
|
|
||||||
it("round-trips 500 entities with mixed components", () => {
|
|
||||||
const w = new World();
|
|
||||||
|
|
||||||
for (let i = 0; i < 500; i++) {
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Position, { x: i, y: i * 2 });
|
|
||||||
if (i % 2 === 0) {
|
|
||||||
w.add(e, Velocity, { vx: 1, vy: 0 });
|
|
||||||
}
|
|
||||||
if (i % 3 === 0) {
|
|
||||||
w.add(e, Health, { current: i, max: 1000 });
|
|
||||||
}
|
|
||||||
if (i % 5 === 0) {
|
|
||||||
w.add(e, Name, { value: `entity_${i}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some relationships
|
|
||||||
const all = [...w.query(query(Position))];
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const src = all[i * 2];
|
|
||||||
const tgt = all[i * 2 + 1];
|
|
||||||
if (src && tgt) {
|
|
||||||
w.relate(src, ChildOf, tgt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = roundTrip(w);
|
|
||||||
expect(loaded.entityCount).toBe(500);
|
|
||||||
|
|
||||||
const withPos = [...loaded.query(query(Position))];
|
|
||||||
const withVel = [...loaded.query(query(Velocity))];
|
|
||||||
const withHealth = [...loaded.query(query(Health))];
|
|
||||||
|
|
||||||
expect(withPos).toHaveLength(500);
|
|
||||||
expect(withVel).toHaveLength(250); // every 2nd
|
|
||||||
expect(withHealth).toHaveLength(167); // every 3rd ≈ floor(499/3)+1
|
|
||||||
|
|
||||||
// Verify a few random entities
|
|
||||||
const e0 = withPos[0];
|
|
||||||
expect(loaded.get(e0, Position).x).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repeated round-trips are idempotent", () => {
|
|
||||||
const w = new World();
|
|
||||||
const e = w.spawn();
|
|
||||||
w.add(e, Position, { x: 10, y: 20 });
|
|
||||||
w.add(e, Health, { current: 75, max: 100 });
|
|
||||||
|
|
||||||
const loaded1 = roundTrip(w);
|
|
||||||
const loaded2 = roundTrip(loaded1);
|
|
||||||
|
|
||||||
expect(loaded2.entityCount).toBe(loaded1.entityCount);
|
|
||||||
|
|
||||||
const e2 = [...loaded2.query(query(Position))][0];
|
|
||||||
expect(loaded2.get(e2, Position)).toEqual({ x: 10, y: 20 });
|
|
||||||
expect(loaded2.get(e2, Health)).toEqual({ current: 75, max: 100 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import {
|
||||||
|
World,
|
||||||
|
defineComponent,
|
||||||
|
query,
|
||||||
|
QueryUpdate,
|
||||||
|
WorldEvent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// ── Define components ─────────────────────────────────
|
||||||
|
const Position = defineComponent({ x: 0, y: 0 });
|
||||||
|
const Velocity = defineComponent({ vx: 0, vy: 0 });
|
||||||
|
const Health = defineComponent({ current: 100, max: 100 });
|
||||||
|
const Dead = defineComponent({ timestamp: 0 });
|
||||||
|
|
||||||
|
// Type inference check
|
||||||
|
const _p: { x: number; y: number } = Position.defaults;
|
||||||
|
|
||||||
|
// ── World setup ──────────────────────────────────────
|
||||||
|
const world = new World();
|
||||||
|
|
||||||
|
let events: WorldEvent[] = [];
|
||||||
|
world.events$.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
// ── Entity lifecycle ─────────────────────────────────
|
||||||
|
const player = world.spawn();
|
||||||
|
const enemy = world.spawn();
|
||||||
|
|
||||||
|
console.assert(events.length === 2, "spawn events");
|
||||||
|
console.assert(events[0].type === "spawned" && events[0].entity === player);
|
||||||
|
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
|
||||||
|
|
||||||
|
console.assert(world.isAlive(player), "player alive");
|
||||||
|
console.assert(world.isAlive(enemy), "enemy alive");
|
||||||
|
console.assert(world.entityCount === 2, "two entities");
|
||||||
|
|
||||||
|
// ── Add components ───────────────────────────────────
|
||||||
|
const pos = world.add(player, Position, { x: 10, y: 20 });
|
||||||
|
world.add(player, Velocity, { vx: 1, vy: 0 });
|
||||||
|
world.add(enemy, Position, { x: 50, y: 0 });
|
||||||
|
world.add(enemy, Health, { current: 50 });
|
||||||
|
|
||||||
|
console.assert(pos.x === 10 && pos.y === 20, "add with init");
|
||||||
|
console.assert(world.has(player, Position), "has Position");
|
||||||
|
console.assert(world.has(player, Velocity), "has Velocity");
|
||||||
|
console.assert(!world.has(player, Health), "no Health");
|
||||||
|
console.assert(events.length === 6, "component add events");
|
||||||
|
|
||||||
|
// ── Sync query ───────────────────────────────────────
|
||||||
|
const movable = [...world.query(query(Position, Velocity))];
|
||||||
|
console.assert(movable.length === 1, "player only in movable");
|
||||||
|
console.assert(movable[0] === player);
|
||||||
|
|
||||||
|
const allPos = [...world.query(query(Position))];
|
||||||
|
console.assert(allPos.length === 2, "both have Position");
|
||||||
|
|
||||||
|
// ── Observable query ─────────────────────────────────
|
||||||
|
const queryLog: QueryUpdate[] = [];
|
||||||
|
world.observe(query(Position, Velocity)).subscribe((u) => {
|
||||||
|
if (u.added.length || u.removed.length || u.changed.length) {
|
||||||
|
queryLog.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutation + change tracking ───────────────────────
|
||||||
|
world.get(player, Position).x += 5;
|
||||||
|
world.markDirty(player, Position);
|
||||||
|
world.get(player, Velocity).vx *= 2;
|
||||||
|
world.markDirty(player, Velocity);
|
||||||
|
|
||||||
|
// flush should emit componentChanged events and update queries
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
console.assert(
|
||||||
|
events.some((e) => e.type === "componentChanged"),
|
||||||
|
"change events",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The query observer should have received changed: [player]
|
||||||
|
const lastUpdate = queryLog[queryLog.length - 1];
|
||||||
|
console.assert(
|
||||||
|
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
|
||||||
|
"player in changed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Remove component ─────────────────────────────────
|
||||||
|
world.remove(player, Velocity);
|
||||||
|
|
||||||
|
console.assert(!world.has(player, Velocity), "Velocity removed");
|
||||||
|
const movableAfter = [...world.query(query(Position, Velocity))];
|
||||||
|
console.assert(movableAfter.length === 0, "no one movable after remove");
|
||||||
|
|
||||||
|
// The observer should have emitted {removed: [player]}
|
||||||
|
const remUpdate = queryLog[queryLog.length - 1];
|
||||||
|
console.assert(
|
||||||
|
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
|
||||||
|
"player removed from query",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Destroy ──────────────────────────────────────────
|
||||||
|
world.destroy(enemy);
|
||||||
|
console.assert(!world.isAlive(enemy), "enemy destroyed");
|
||||||
|
console.assert(world.entityCount === 1, "one entity left");
|
||||||
|
|
||||||
|
// ── componentChanged query update ────────────────────
|
||||||
|
// Add enemy back, observe query(Health).without(Dead)
|
||||||
|
const enemy2 = world.spawn();
|
||||||
|
world.add(enemy2, Health, { current: 75 });
|
||||||
|
|
||||||
|
const healthLog: QueryUpdate[] = [];
|
||||||
|
world.observe(query(Health).without(Dead)).subscribe((u) => {
|
||||||
|
healthLog.push(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
|
||||||
|
// Let's add Dead to trigger the removal
|
||||||
|
world.add(enemy2, Dead, { timestamp: 123 });
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
console.assert(
|
||||||
|
healthLog.some((u) => u.removed[0] === enemy2),
|
||||||
|
"enemy removed from health-not-dead query after gaining Dead",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Entity recycling ─────────────────────────────────
|
||||||
|
world.destroy(player);
|
||||||
|
const recycled = world.spawn();
|
||||||
|
|
||||||
|
console.assert(recycled !== player, "recycled entity has new generation");
|
||||||
|
console.assert(world.isAlive(recycled), "recycled entity is alive");
|
||||||
|
console.assert(!world.isAlive(player), "old handle is dead");
|
||||||
|
|
||||||
|
console.log("✅ All smoke tests passed.");
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import {
|
|
||||||
World,
|
|
||||||
defineComponent,
|
|
||||||
query,
|
|
||||||
type QueryUpdate,
|
|
||||||
type WorldEvent,
|
|
||||||
type Entity,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
// ── Components ──────────────────────────────────────
|
|
||||||
const Position = defineComponent("position", { x: 0, y: 0 });
|
|
||||||
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
|
||||||
const Health = defineComponent("health", { current: 100, max: 100 });
|
|
||||||
const Dead = defineComponent("dead", { timestamp: 0 });
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────
|
|
||||||
function collectUpdates(obs$: { subscribe: Function }): QueryUpdate[] {
|
|
||||||
const log: QueryUpdate[] = [];
|
|
||||||
obs$.subscribe((u: QueryUpdate) => {
|
|
||||||
if (u.added.length || u.removed.length || u.changed.length) log.push(u);
|
|
||||||
});
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectEvents(world: World): WorldEvent[] {
|
|
||||||
const log: WorldEvent[] = [];
|
|
||||||
world.events$.subscribe((e: WorldEvent) => log.push(e));
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entity lifecycle ───────────────────────────────
|
|
||||||
describe("Entity lifecycle", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spawns entities", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
expect(world.isAlive(e)).toBe(true);
|
|
||||||
expect(world.entityCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits spawn event", () => {
|
|
||||||
const events = collectEvents(world);
|
|
||||||
const e = world.spawn();
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0]).toMatchObject({ type: "spawned", entity: e });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("destroys entities", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.destroy(e);
|
|
||||||
expect(world.isAlive(e)).toBe(false);
|
|
||||||
expect(world.entityCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits destroy event", () => {
|
|
||||||
const events = collectEvents(world);
|
|
||||||
const e = world.spawn();
|
|
||||||
world.destroy(e);
|
|
||||||
expect(events).toHaveLength(2);
|
|
||||||
expect(events[1]).toMatchObject({ type: "destroyed", entity: e });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recycles entity indices with generation bump", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
world.destroy(a);
|
|
||||||
const b = world.spawn();
|
|
||||||
expect(b).not.toBe(a);
|
|
||||||
expect(world.isAlive(b)).toBe(true);
|
|
||||||
expect(world.isAlive(a)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on operations with dead entity", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.destroy(e);
|
|
||||||
expect(() => world.get(e, Position)).toThrow("not alive");
|
|
||||||
expect(() => world.has(e, Position)).not.toThrow(); // has() is safe
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Components ──────────────────────────────────────
|
|
||||||
describe("Components", () => {
|
|
||||||
let world: World;
|
|
||||||
let entity: Entity;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
entity = world.spawn() as Entity;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("add returns defaults", () => {
|
|
||||||
const pos = world.add(entity, Position);
|
|
||||||
expect(pos).toEqual({ x: 0, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("add overrides defaults with init", () => {
|
|
||||||
const pos = world.add(entity, Position, { x: 10, y: 20 });
|
|
||||||
expect(pos.x).toBe(10);
|
|
||||||
expect(pos.y).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("get returns the live mutable object", () => {
|
|
||||||
world.add(entity, Position, { x: 5 });
|
|
||||||
const pos = world.get(entity, Position);
|
|
||||||
pos.x = 99;
|
|
||||||
expect(world.get(entity, Position).x).toBe(99);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tryGet returns undefined when absent", () => {
|
|
||||||
expect(world.tryGet(entity, Position)).toBeUndefined();
|
|
||||||
world.add(entity, Position);
|
|
||||||
expect(world.tryGet(entity, Position)).toEqual({ x: 0, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has checks component presence", () => {
|
|
||||||
expect(world.has(entity, Position)).toBe(false);
|
|
||||||
world.add(entity, Position);
|
|
||||||
expect(world.has(entity, Position)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remove removes the component", () => {
|
|
||||||
world.add(entity, Position);
|
|
||||||
world.remove(entity, Position);
|
|
||||||
expect(world.has(entity, Position)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remove is idempotent", () => {
|
|
||||||
expect(() => world.remove(entity, Position)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set replaces and marks dirty", () => {
|
|
||||||
world.add(entity, Position);
|
|
||||||
world.set(entity, Position, { x: 42, y: 99 });
|
|
||||||
expect(world.get(entity, Position)).toEqual({ x: 42, y: 99 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set throws if component not added first", () => {
|
|
||||||
expect(() => world.set(entity, Position, { x: 1, y: 2 })).toThrow(
|
|
||||||
"Use add()",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits componentAdded event", () => {
|
|
||||||
const events = collectEvents(world);
|
|
||||||
world.add(entity, Position);
|
|
||||||
expect(events.find((e) => e.type === "componentAdded")).toMatchObject({
|
|
||||||
type: "componentAdded",
|
|
||||||
entity,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits componentRemoved event", () => {
|
|
||||||
world.add(entity, Position);
|
|
||||||
const events = collectEvents(world);
|
|
||||||
world.remove(entity, Position);
|
|
||||||
expect(events.find((e) => e.type === "componentRemoved")).toMatchObject({
|
|
||||||
type: "componentRemoved",
|
|
||||||
entity,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Queries ─────────────────────────────────────────
|
|
||||||
describe("Sync queries", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns matching entities", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
world.add(a, Position);
|
|
||||||
world.add(a, Velocity);
|
|
||||||
|
|
||||||
const b = world.spawn();
|
|
||||||
world.add(b, Position);
|
|
||||||
|
|
||||||
const result = [...world.query(query(Position, Velocity))];
|
|
||||||
expect(result).toEqual([a]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty when no match", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
const result = [...world.query(query(Position, Velocity))];
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes with .without()", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
world.add(a, Health);
|
|
||||||
world.add(a, Dead);
|
|
||||||
|
|
||||||
const b = world.spawn();
|
|
||||||
world.add(b, Health);
|
|
||||||
|
|
||||||
const result = [...world.query(query(Health).without(Dead))];
|
|
||||||
expect(result).toEqual([b]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Observable queries ──────────────────────────────
|
|
||||||
describe("Observable queries", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits added when an entity later matches", () => {
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].added).toEqual([e]);
|
|
||||||
expect(log[0].removed).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits removed when an entity stops matching", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
world.remove(e, Position);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([e]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits removed on entity destroy", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
world.destroy(e);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([e]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits changed on matching entities after flush", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
world.get(e, Position).x += 1;
|
|
||||||
world.markDirty(e, Position);
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].changed).toEqual([e]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds with currently matching entities on subscribe", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
// Next subscription should know e already matches
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
|
|
||||||
// Remove to trigger an event
|
|
||||||
world.remove(e, Position);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([e]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles .without() queries", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Health);
|
|
||||||
|
|
||||||
const log = collectUpdates(world.observe(query(Health).without(Dead)));
|
|
||||||
|
|
||||||
world.add(e, Dead);
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].removed).toEqual([e]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Change tracking ─────────────────────────────────
|
|
||||||
describe("Change tracking", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits componentChanged on flush", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
const events = collectEvents(world);
|
|
||||||
world.get(e, Position).x = 42;
|
|
||||||
world.markDirty(e, Position);
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
expect(events.some((ev) => ev.type === "componentChanged")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("batches multiple dirty marks into one flush", () => {
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.add(a, Position);
|
|
||||||
world.add(b, Position);
|
|
||||||
|
|
||||||
let changeCount = 0;
|
|
||||||
world.observe(query(Position)).subscribe((u) => {
|
|
||||||
changeCount += u.changed.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
world.markDirty(a, Position);
|
|
||||||
world.markDirty(b, Position);
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
expect(changeCount).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set() implicitly marks dirty", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
const log = collectUpdates(world.observe(query(Position)));
|
|
||||||
world.set(e, Position, { x: 1, y: 2 });
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
expect(log).toHaveLength(1);
|
|
||||||
expect(log[0].changed).toEqual([e]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears dirty after flush", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Position);
|
|
||||||
|
|
||||||
let changeCount = 0;
|
|
||||||
world.observe(query(Position)).subscribe((u) => {
|
|
||||||
changeCount += u.changed.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
world.markDirty(e, Position);
|
|
||||||
world.flush();
|
|
||||||
expect(changeCount).toBe(1);
|
|
||||||
|
|
||||||
world.flush();
|
|
||||||
expect(changeCount).toBe(1); // no new emissions
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── TypeScript inference ────────────────────────────
|
|
||||||
describe("Type safety", () => {
|
|
||||||
it("infers component type from defaults", () => {
|
|
||||||
const Shield = defineComponent("shield", { armor: 5, broken: false });
|
|
||||||
const s = Shield.defaults;
|
|
||||||
// compile-time check: these should be the inferred types
|
|
||||||
const _armor: number = s.armor;
|
|
||||||
const _broken: boolean = s.broken;
|
|
||||||
expect(typeof _armor).toBe("number");
|
|
||||||
expect(typeof _broken).toBe("boolean");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ["test/**/*.test.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue