Compare commits

...

2 Commits

Author SHA1 Message Date
hypercross 365b2c4d13 fix: handle edge object in serialization tests
Add `typecheck` script to package.json, expand tsconfig include
paths to cover tests and examples, and update serialization tests
to correctly resolve player IDs from relationship edges.
2026-06-02 18:05:47 +08:00
hypercross cd6350e0b1 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`.
2026-06-02 17:56:12 +08:00
7 changed files with 294 additions and 19 deletions

View File

@ -29,6 +29,7 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build"
},

View File

@ -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,
};
}

View File

@ -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 }>
>;
}

View File

@ -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);
}
}
}
}

View File

@ -379,3 +379,156 @@ describe("Dead entity safety", () => {
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 });
});
});

View File

@ -277,7 +277,9 @@ describe("Serialization — complex state", () => {
const comps = snap.entities[id];
return comps.position && comps.velocity && !comps.health;
})!;
const playerId = snap.relationships.ownedBy[bulletId];
const playerEdge = snap.relationships.ownedBy[bulletId];
const playerId =
typeof playerEdge === "string" ? playerEdge : playerEdge.target;
// Player should have a name
const playerComps = snap.entities[playerId];

View File

@ -11,8 +11,7 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"include": ["src", "test", "examples"],
"exclude": ["node_modules", "dist"],
}