feat: add support for data-carrying relationships
Introduce the ability to attach optional data payloads to relationships. This includes: - Updating `defineRelationship` to accept default values. - Adding `getRelData` and `setRelData` to the `World` class. - Allowing `relate` to accept an optional data override. - Updating serialization to include relationship data in snapshots. - Implementing lazy storage for relationship data using `SparseSet`.
This commit is contained in:
+36
-5
@@ -1,24 +1,55 @@
|
||||
// ── Relationship ─────────────────────────────────────
|
||||
/**
|
||||
* A relationship definition — like a component, but represents a directed
|
||||
* link between two entities.
|
||||
* link between two entities. Every relationship carries an optional data
|
||||
* payload (defaults to `{}` for bare edges).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ChildOf = defineRelationship('childOf');
|
||||
* world.relate(child, ChildOf, parent);
|
||||
* const Health = defineRelationship('health', { hp: 100 });
|
||||
* ```
|
||||
*/
|
||||
export interface RelationshipDef {
|
||||
export interface RelationshipDef<T extends Record<string, any> = {}> {
|
||||
/** Unique symbol used as the storage key. */
|
||||
readonly _key: symbol;
|
||||
/** Human-readable name, used for serialization. */
|
||||
readonly name: string;
|
||||
/** Default values used when no data override is provided. */
|
||||
readonly defaults: T;
|
||||
/** Phantom type for inference. */
|
||||
readonly type: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return { _key: Symbol(), name };
|
||||
export function defineRelationship(name: string): RelationshipDef<{}>;
|
||||
export function defineRelationship<T extends Record<string, any>>(
|
||||
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,6 +5,9 @@
|
||||
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>>;
|
||||
/** Relationship name → (source ID → target ID or edge object). */
|
||||
relationships: Record<
|
||||
string,
|
||||
Record<string, string | { target: string; data: unknown }>
|
||||
>;
|
||||
}
|
||||
|
||||
+94
-8
@@ -35,6 +35,8 @@ export class World {
|
||||
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
|
||||
/** Key → RelationshipDef for event emission. */
|
||||
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 ───────────────────────────────
|
||||
private _dirty = new Map<symbol, Set<number>>();
|
||||
@@ -93,6 +95,8 @@ export class World {
|
||||
if (fwd.has(idx)) {
|
||||
const target = fwd.get(idx);
|
||||
const rel = this._relKeyToDef.get(key)!;
|
||||
// Clean up relationship data if applicable
|
||||
this._relData.get(key)?.remove(idx);
|
||||
this._relRemoveEdge(entity, target, rel);
|
||||
}
|
||||
|
||||
@@ -322,8 +326,17 @@ export class World {
|
||||
* 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.
|
||||
*
|
||||
* 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(source: Entity, rel: RelationshipDef, target: Entity): void {
|
||||
relate<T extends Record<string, any> = {}>(
|
||||
source: Entity,
|
||||
rel: RelationshipDef<T>,
|
||||
target: Entity,
|
||||
data?: Partial<T>,
|
||||
): void {
|
||||
const si = entityIndex(source);
|
||||
const ti = entityIndex(target);
|
||||
this._assertAlive(si, source);
|
||||
@@ -351,6 +364,16 @@ export class World {
|
||||
this._relCounts[si]++;
|
||||
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({
|
||||
type: "relationshipAdded",
|
||||
source,
|
||||
@@ -370,9 +393,48 @@ export class World {
|
||||
const target = this.getRelated(source, rel);
|
||||
if (target === undefined) return;
|
||||
|
||||
this._relData.get(rel._key)?.remove(si);
|
||||
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.
|
||||
*/
|
||||
@@ -505,14 +567,26 @@ export class World {
|
||||
}
|
||||
|
||||
// Relationships
|
||||
const relationships: Record<string, Record<string, string>> = {};
|
||||
const relationships: Record<
|
||||
string,
|
||||
Record<string, string | { target: string; data: unknown }>
|
||||
> = {};
|
||||
for (const [key, fwd] of this._relForward) {
|
||||
const rel = this._relKeyToDef.get(key)!;
|
||||
const edges: Record<string, string> = {};
|
||||
const edges: Record<string, string | { target: string; data: unknown }> =
|
||||
{};
|
||||
const dataStore = this._relData.get(key);
|
||||
for (const [si, target] of fwd.entries()) {
|
||||
const ti = entityIndex(target);
|
||||
if (ids[si] !== undefined && ids[ti] !== undefined) {
|
||||
edges[ids[si]] = ids[ti];
|
||||
if (dataStore?.has(si)) {
|
||||
edges[ids[si]] = {
|
||||
target: ids[ti],
|
||||
data: dataStore.get(si),
|
||||
};
|
||||
} else {
|
||||
edges[ids[si]] = ids[ti];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(edges).length > 0) {
|
||||
@@ -572,11 +646,23 @@ export class World {
|
||||
`Pass it in the relationships array.`,
|
||||
);
|
||||
}
|
||||
for (const [srcId, tgtId] of Object.entries(edges)) {
|
||||
for (const [srcId, value] of Object.entries(edges)) {
|
||||
const source = idToEntity.get(srcId);
|
||||
const target = idToEntity.get(tgtId);
|
||||
if (source && target) {
|
||||
world.relate(source, rel, target);
|
||||
if (!source) continue;
|
||||
|
||||
if (typeof value === "string") {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user