Compare commits
No commits in common. "365b2c4d13fa50e6f12487ce332c90d55db37370" and "a97488e8d69d614edbe2851dd210067847969d27" have entirely different histories.
365b2c4d13
...
a97488e8d6
|
|
@ -29,7 +29,6 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,24 @@
|
||||||
// ── Relationship ─────────────────────────────────────
|
// ── Relationship ─────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* A relationship definition — like a component, but represents a directed
|
* A relationship definition — like a component, but represents a directed
|
||||||
* link between two entities. Every relationship carries an optional data
|
* link between two entities.
|
||||||
* payload (defaults to `{}` for bare edges).
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const ChildOf = defineRelationship('childOf');
|
* const ChildOf = defineRelationship('childOf');
|
||||||
* const Health = defineRelationship('health', { hp: 100 });
|
* world.relate(child, ChildOf, parent);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export interface RelationshipDef<T extends Record<string, any> = {}> {
|
export interface RelationshipDef {
|
||||||
/** 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. */
|
/** Human-readable name, used for serialization. */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
/** Default values used when no data override is provided. */
|
|
||||||
readonly defaults: T;
|
|
||||||
/** Phantom type for inference. */
|
|
||||||
readonly type: T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a named relationship between entities.
|
* Define a named relationship between entities.
|
||||||
*
|
|
||||||
* When `defaults` is omitted the relationship is a bare edge (no data).
|
|
||||||
* When `defaults` is provided the relationship carries data accessible
|
|
||||||
* via `world.getRelData()` / `world.setRelData()`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Bare edge
|
|
||||||
* const ChildOf = defineRelationship('childOf');
|
|
||||||
*
|
|
||||||
* // With data
|
|
||||||
* const Health = defineRelationship('health', { hp: 100 });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function defineRelationship(name: string): RelationshipDef<{}>;
|
export function defineRelationship(name: string): RelationshipDef {
|
||||||
export function defineRelationship<T extends Record<string, any>>(
|
return { _key: Symbol(), name };
|
||||||
name: string,
|
|
||||||
defaults: T,
|
|
||||||
): RelationshipDef<T>;
|
|
||||||
export function defineRelationship<T extends Record<string, any>>(
|
|
||||||
name: string,
|
|
||||||
defaults?: T,
|
|
||||||
): RelationshipDef<{}> | RelationshipDef<T> {
|
|
||||||
return {
|
|
||||||
_key: Symbol(),
|
|
||||||
name,
|
|
||||||
defaults: (defaults ?? {}) as any,
|
|
||||||
type: undefined as unknown as any,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@
|
||||||
export interface WorldSnapshot {
|
export interface WorldSnapshot {
|
||||||
/** Entity stable ID → component map (component name → data). */
|
/** Entity stable ID → component map (component name → data). */
|
||||||
entities: Record<string, Record<string, unknown>>;
|
entities: Record<string, Record<string, unknown>>;
|
||||||
/** Relationship name → (source ID → target ID or edge object). */
|
/** Relationship name → (source ID → target ID). */
|
||||||
relationships: Record<
|
relationships: Record<string, Record<string, string>>;
|
||||||
string,
|
|
||||||
Record<string, string | { target: string; data: unknown }>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
src/world.ts
102
src/world.ts
|
|
@ -35,8 +35,6 @@ export class World {
|
||||||
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
|
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
|
||||||
/** Key → RelationshipDef for event emission. */
|
/** Key → RelationshipDef for event emission. */
|
||||||
private _relKeyToDef = new Map<symbol, RelationshipDef>();
|
private _relKeyToDef = new Map<symbol, RelationshipDef>();
|
||||||
/** Relationship data: relationship._key → SparseSet<data> (keyed by source index). */
|
|
||||||
private _relData = new Map<symbol, SparseSet<any>>();
|
|
||||||
|
|
||||||
// ── Change tracking ───────────────────────────────
|
// ── Change tracking ───────────────────────────────
|
||||||
private _dirty = new Map<symbol, Set<number>>();
|
private _dirty = new Map<symbol, Set<number>>();
|
||||||
|
|
@ -95,8 +93,6 @@ export class World {
|
||||||
if (fwd.has(idx)) {
|
if (fwd.has(idx)) {
|
||||||
const target = fwd.get(idx);
|
const target = fwd.get(idx);
|
||||||
const rel = this._relKeyToDef.get(key)!;
|
const rel = this._relKeyToDef.get(key)!;
|
||||||
// Clean up relationship data if applicable
|
|
||||||
this._relData.get(key)?.remove(idx);
|
|
||||||
this._relRemoveEdge(entity, target, rel);
|
this._relRemoveEdge(entity, target, rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,17 +322,8 @@ export class World {
|
||||||
* Create a directed relationship from `source` to `target`.
|
* Create a directed relationship from `source` to `target`.
|
||||||
* Each source can only have one target per relationship type.
|
* Each source can only have one target per relationship type.
|
||||||
* If a relationship already exists, it is replaced.
|
* If a relationship already exists, it is replaced.
|
||||||
*
|
|
||||||
* An optional `data` payload can be provided to store data along
|
|
||||||
* with the edge (accessible via `getRelData` / `setRelData`).
|
|
||||||
* Data is stored lazily — bare edges without data use no storage.
|
|
||||||
*/
|
*/
|
||||||
relate<T extends Record<string, any> = {}>(
|
relate(source: Entity, rel: RelationshipDef, target: Entity): void {
|
||||||
source: Entity,
|
|
||||||
rel: RelationshipDef<T>,
|
|
||||||
target: Entity,
|
|
||||||
data?: Partial<T>,
|
|
||||||
): void {
|
|
||||||
const si = entityIndex(source);
|
const si = entityIndex(source);
|
||||||
const ti = entityIndex(target);
|
const ti = entityIndex(target);
|
||||||
this._assertAlive(si, source);
|
this._assertAlive(si, source);
|
||||||
|
|
@ -364,16 +351,6 @@ export class World {
|
||||||
this._relCounts[si]++;
|
this._relCounts[si]++;
|
||||||
this._relCounts[ti]++;
|
this._relCounts[ti]++;
|
||||||
|
|
||||||
// Lazy data storage — only allocate when data is provided
|
|
||||||
if (data !== undefined) {
|
|
||||||
let dataStore = this._relData.get(rel._key);
|
|
||||||
if (!dataStore) {
|
|
||||||
dataStore = new SparseSet<any>();
|
|
||||||
this._relData.set(rel._key, dataStore);
|
|
||||||
}
|
|
||||||
dataStore.set(si, { ...rel.defaults, ...data });
|
|
||||||
}
|
|
||||||
|
|
||||||
this._emit({
|
this._emit({
|
||||||
type: "relationshipAdded",
|
type: "relationshipAdded",
|
||||||
source,
|
source,
|
||||||
|
|
@ -393,48 +370,9 @@ export class World {
|
||||||
const target = this.getRelated(source, rel);
|
const target = this.getRelated(source, rel);
|
||||||
if (target === undefined) return;
|
if (target === undefined) return;
|
||||||
|
|
||||||
this._relData.get(rel._key)?.remove(si);
|
|
||||||
this._relRemoveEdge(source, target, rel);
|
this._relRemoveEdge(source, target, rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the data stored alongside a relationship.
|
|
||||||
* Returns the relationship's defaults if no data was explicitly set.
|
|
||||||
*/
|
|
||||||
getRelData<T extends Record<string, any> = {}>(
|
|
||||||
source: Entity,
|
|
||||||
rel: RelationshipDef<T>,
|
|
||||||
): T {
|
|
||||||
const si = entityIndex(source);
|
|
||||||
this._assertAlive(si, source);
|
|
||||||
|
|
||||||
const store = this._relData.get(rel._key);
|
|
||||||
if (!store || !store.has(si)) {
|
|
||||||
return { ...rel.defaults };
|
|
||||||
}
|
|
||||||
return store.get(si);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the data for a relationship edge.
|
|
||||||
* Creates storage lazily if this is the first data set on this relationship type.
|
|
||||||
*/
|
|
||||||
setRelData<T extends Record<string, any> = {}>(
|
|
||||||
source: Entity,
|
|
||||||
rel: RelationshipDef<T>,
|
|
||||||
data: T,
|
|
||||||
): void {
|
|
||||||
const si = entityIndex(source);
|
|
||||||
this._assertAlive(si, source);
|
|
||||||
|
|
||||||
let store = this._relData.get(rel._key);
|
|
||||||
if (!store) {
|
|
||||||
store = new SparseSet<any>();
|
|
||||||
this._relData.set(rel._key, store);
|
|
||||||
}
|
|
||||||
store.set(si, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the target entity for a relationship, or undefined.
|
* Get the target entity for a relationship, or undefined.
|
||||||
*/
|
*/
|
||||||
|
|
@ -567,26 +505,14 @@ export class World {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
const relationships: Record<
|
const relationships: Record<string, Record<string, string>> = {};
|
||||||
string,
|
|
||||||
Record<string, string | { target: string; data: unknown }>
|
|
||||||
> = {};
|
|
||||||
for (const [key, fwd] of this._relForward) {
|
for (const [key, fwd] of this._relForward) {
|
||||||
const rel = this._relKeyToDef.get(key)!;
|
const rel = this._relKeyToDef.get(key)!;
|
||||||
const edges: Record<string, string | { target: string; data: unknown }> =
|
const edges: Record<string, string> = {};
|
||||||
{};
|
|
||||||
const dataStore = this._relData.get(key);
|
|
||||||
for (const [si, target] of fwd.entries()) {
|
for (const [si, target] of fwd.entries()) {
|
||||||
const ti = entityIndex(target);
|
const ti = entityIndex(target);
|
||||||
if (ids[si] !== undefined && ids[ti] !== undefined) {
|
if (ids[si] !== undefined && ids[ti] !== undefined) {
|
||||||
if (dataStore?.has(si)) {
|
edges[ids[si]] = ids[ti];
|
||||||
edges[ids[si]] = {
|
|
||||||
target: ids[ti],
|
|
||||||
data: dataStore.get(si),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
edges[ids[si]] = ids[ti];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(edges).length > 0) {
|
if (Object.keys(edges).length > 0) {
|
||||||
|
|
@ -646,23 +572,11 @@ export class World {
|
||||||
`Pass it in the relationships array.`,
|
`Pass it in the relationships array.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const [srcId, value] of Object.entries(edges)) {
|
for (const [srcId, tgtId] of Object.entries(edges)) {
|
||||||
const source = idToEntity.get(srcId);
|
const source = idToEntity.get(srcId);
|
||||||
if (!source) continue;
|
const target = idToEntity.get(tgtId);
|
||||||
|
if (source && target) {
|
||||||
if (typeof value === "string") {
|
world.relate(source, rel, target);
|
||||||
// Pure edge (no data)
|
|
||||||
const target = idToEntity.get(value);
|
|
||||||
if (target) {
|
|
||||||
world.relate(source, rel, target);
|
|
||||||
}
|
|
||||||
} else if (typeof value === "object" && value !== null) {
|
|
||||||
// Edge with data
|
|
||||||
const edge = value as { target: string; data?: unknown };
|
|
||||||
const target = idToEntity.get(edge.target);
|
|
||||||
if (target) {
|
|
||||||
world.relate(source, rel, target, edge.data as any);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -379,156 +379,3 @@ describe("Dead entity safety", () => {
|
||||||
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
|
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Data-carrying relationships ───────────────────────
|
|
||||||
describe("Data-carrying relationships", () => {
|
|
||||||
let world: World;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defines a data-carrying relationship", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
|
|
||||||
expect(Health.name).toBe("health");
|
|
||||||
expect(Health.defaults).toEqual({ hp: 100, maxHp: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relate stores defaults as data", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game);
|
|
||||||
const data = world.getRelData(player, Health);
|
|
||||||
expect(data).toEqual({ hp: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("relate accepts data override", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game, { hp: 50 });
|
|
||||||
const data = world.getRelData(player, Health);
|
|
||||||
expect(data).toEqual({ hp: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setRelData updates relationship data", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game);
|
|
||||||
world.setRelData(player, Health, { hp: 75 });
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 75 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRelData returns defaults when no data was set", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
|
|
||||||
// Even without an edge, getRelData returns a copy of defaults
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setRelData works even without prior relate", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
|
|
||||||
world.setRelData(player, Health, { hp: 50 });
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("data survives unrelate and re-relate", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game, { hp: 50 });
|
|
||||||
world.unrelate(player, Health);
|
|
||||||
|
|
||||||
// After unrelate, stored data is gone, returns defaults
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
|
|
||||||
|
|
||||||
world.relate(player, Health, game, { hp: 80 });
|
|
||||||
// Note: this is a *new* relate with data, so stored data is { hp: 80 }
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 80 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("data is cleaned up on destroy", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game, { hp: 50 });
|
|
||||||
world.destroy(player);
|
|
||||||
|
|
||||||
// After destroy the entity is dead — assertAlive throws, not the data lookup
|
|
||||||
expect(() => world.getRelData(player, Health)).toThrow("not alive");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setRelData with no prior edge stores data that getsRelated does not see", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
|
|
||||||
world.setRelData(player, Health, { hp: 50 });
|
|
||||||
// No edge exists yet
|
|
||||||
expect(world.getRelated(player, Health)).toBeUndefined();
|
|
||||||
// But data is stored (decoupled storage)
|
|
||||||
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("data-carrying relationships serialize and deserialize", () => {
|
|
||||||
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
|
|
||||||
const player = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(player, Health, game, { hp: 50, maxHp: 100 });
|
|
||||||
|
|
||||||
const snapshot = world.toJSON();
|
|
||||||
|
|
||||||
// Verify the snapshot has data in the relationship structure
|
|
||||||
const relSection = snapshot.relationships["health"];
|
|
||||||
expect(relSection).toBeDefined();
|
|
||||||
|
|
||||||
// Find the player source ID — it's the entity without components
|
|
||||||
const playerId = Object.keys(snapshot.entities).find(
|
|
||||||
(id) => Object.keys(snapshot.entities[id]).length === 0,
|
|
||||||
)!;
|
|
||||||
const edgeValue = relSection[playerId];
|
|
||||||
expect(typeof edgeValue).toBe("object");
|
|
||||||
expect((edgeValue as any).target).toBeDefined();
|
|
||||||
expect((edgeValue as any).data).toEqual({ hp: 50, maxHp: 100 });
|
|
||||||
|
|
||||||
// Check JSON round-trip preserves data
|
|
||||||
const parsed = JSON.parse(JSON.stringify(snapshot));
|
|
||||||
const world2 = World.fromJSON(parsed, [], [Health]);
|
|
||||||
|
|
||||||
const snap2 = world2.toJSON();
|
|
||||||
const playerId2 = Object.keys(snap2.entities).find(
|
|
||||||
(id) => Object.keys(snap2.entities[id]).length === 0,
|
|
||||||
)!;
|
|
||||||
expect(snap2.relationships["health"]).toBeDefined();
|
|
||||||
const edgeValue2 = snap2.relationships["health"][playerId2];
|
|
||||||
expect((edgeValue2 as any).data).toEqual({ hp: 50, maxHp: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pure edge-defined relationships still work alongside data relationships", () => {
|
|
||||||
const ChildOf2 = defineRelationship("childOf2");
|
|
||||||
const Score = defineRelationship("score", { points: 0 });
|
|
||||||
|
|
||||||
const parent = world.spawn();
|
|
||||||
const child = world.spawn();
|
|
||||||
const game = world.spawn();
|
|
||||||
|
|
||||||
world.relate(child, ChildOf2, parent);
|
|
||||||
world.relate(child, Score, game, { points: 42 });
|
|
||||||
|
|
||||||
// Pure edge still works
|
|
||||||
expect(world.getRelated(child, ChildOf2)).toBe(parent);
|
|
||||||
|
|
||||||
// Data edge still works
|
|
||||||
expect(world.getRelData(child, Score)).toEqual({ points: 42 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -277,9 +277,7 @@ describe("Serialization — complex state", () => {
|
||||||
const comps = snap.entities[id];
|
const comps = snap.entities[id];
|
||||||
return comps.position && comps.velocity && !comps.health;
|
return comps.position && comps.velocity && !comps.health;
|
||||||
})!;
|
})!;
|
||||||
const playerEdge = snap.relationships.ownedBy[bulletId];
|
const playerId = snap.relationships.ownedBy[bulletId];
|
||||||
const playerId =
|
|
||||||
typeof playerEdge === "string" ? playerEdge : playerEdge.target;
|
|
||||||
|
|
||||||
// Player should have a name
|
// Player should have a name
|
||||||
const playerComps = snap.entities[playerId];
|
const playerComps = snap.entities[playerId];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
"include": ["src", "test", "examples"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"],
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue