Skip to content

Commit cdf3f15

Browse files
committed
feat(world): support component entities and relation cleanup
- Add storage for component entity components - Implement relation entity tracking by target - Clean up relation data when targets are deleted - Serialize component entities with world state - Add tests for component entity behavior
1 parent 3d0d8ac commit cdf3f15

File tree

3 files changed

+264
-1
lines changed

3 files changed

+264
-1
lines changed

src/__tests__/world-entity-management.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "bun:test";
2-
import { createEntityId } from "../core/entity";
2+
import { component, createEntityId, relation } from "../core/entity";
33
import { World } from "../core/world";
44

55
describe("World - Entity Management", () => {
@@ -30,4 +30,57 @@ describe("World - Entity Management", () => {
3030
// Should not throw
3131
world.delete(fakeEntity);
3232
});
33+
34+
it("should support component id as entity id with fast path storage", () => {
35+
const world = new World();
36+
const Meta = component<{ tag: string }>("Meta");
37+
const Payload = component<{ value: number }>("Payload");
38+
39+
expect(world.exists(Meta)).toBe(true);
40+
41+
world.set(Meta, Payload, { value: 42 });
42+
world.sync();
43+
44+
expect(world.has(Meta, Payload)).toBe(true);
45+
expect(world.get(Meta, Payload)).toEqual({ value: 42 });
46+
47+
const query = world.createQuery([Payload]);
48+
expect(query.getEntities()).toEqual([]);
49+
50+
let hookCalls = 0;
51+
world.hook(Payload, {
52+
on_init: () => {
53+
hookCalls++;
54+
},
55+
});
56+
57+
world.set(Meta, Payload, { value: 43 });
58+
world.sync();
59+
expect(hookCalls).toBe(0);
60+
61+
world.delete(Meta);
62+
world.sync();
63+
expect(world.has(Meta, Payload)).toBe(false);
64+
expect(world.getOptional(Meta, Payload)).toBeUndefined();
65+
expect(world.exists(Meta)).toBe(true);
66+
});
67+
68+
it("should clear relation-entity data when target entity is deleted", () => {
69+
const world = new World();
70+
const Link = component("Link");
71+
const Payload = component<{ value: number }>("Payload2");
72+
73+
const target = world.new();
74+
const relationEntity = relation(Link, target);
75+
76+
world.set(relationEntity, Payload, { value: 9 });
77+
world.sync();
78+
expect(world.has(relationEntity, Payload)).toBe(true);
79+
80+
world.delete(target);
81+
world.sync();
82+
expect(world.has(relationEntity, Payload)).toBe(false);
83+
expect(world.getOptional(relationEntity, Payload)).toBeUndefined();
84+
expect(world.exists(relationEntity)).toBe(true);
85+
});
3386
});

src/core/serialization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type SerializedWorld = {
1111
version: number;
1212
entityManager: any;
1313
entities: SerializedEntity[];
14+
componentEntities?: SerializedEntity[];
1415
};
1516

1617
export type SerializedEntity = {

src/core/world.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export class World {
6363
private archetypesByComponent = new Map<EntityId<any>, Archetype[]>();
6464
private entityReferences: EntityReferencesMap = new Map();
6565
private dontFragmentRelations: Map<EntityId, Map<EntityId<any>, any>> = new Map();
66+
private componentEntityComponents: Map<EntityId, Map<EntityId<any>, any>> = new Map();
67+
private relationEntityIdsByTarget: Map<EntityId, Set<EntityId>> = new Map();
6668

6769
// Query management
6870
private queries: Query[] = [];
@@ -86,6 +88,24 @@ export class World {
8688
this.entityIdManager.deserializeState(snapshot.entityManager);
8789
}
8890

91+
if (Array.isArray(snapshot.componentEntities)) {
92+
for (const entry of snapshot.componentEntities) {
93+
const entityId = decodeSerializedId(entry.id);
94+
if (!this.isComponentEntityId(entityId)) continue;
95+
96+
const componentsArray: SerializedComponent[] = entry.components || [];
97+
const componentMap = new Map<EntityId<any>, any>();
98+
99+
for (const componentEntry of componentsArray) {
100+
const componentType = decodeSerializedId(componentEntry.type);
101+
componentMap.set(componentType, componentEntry.value);
102+
}
103+
104+
this.componentEntityComponents.set(entityId, componentMap);
105+
this.registerRelationEntityId(entityId);
106+
}
107+
}
108+
89109
if (Array.isArray(snapshot.entities)) {
90110
for (const entry of snapshot.entities) {
91111
const entityId = decodeSerializedId(entry.id);
@@ -140,6 +160,69 @@ export class World {
140160
return entityId as EntityId<T>;
141161
}
142162

163+
private isComponentEntityId(entityId: EntityId): boolean {
164+
const detailed = getDetailedIdType(entityId);
165+
return detailed.type !== "entity" && detailed.type !== "invalid";
166+
}
167+
168+
private registerRelationEntityId(entityId: EntityId): void {
169+
const detailed = getDetailedIdType(entityId);
170+
if (detailed.type !== "entity-relation") return;
171+
172+
const targetId = detailed.targetId;
173+
if (targetId === undefined) return;
174+
175+
const existing = this.relationEntityIdsByTarget.get(targetId);
176+
if (existing) {
177+
existing.add(entityId);
178+
return;
179+
}
180+
181+
this.relationEntityIdsByTarget.set(targetId, new Set([entityId]));
182+
}
183+
184+
private unregisterRelationEntityId(entityId: EntityId): void {
185+
const detailed = getDetailedIdType(entityId);
186+
if (detailed.type !== "entity-relation") return;
187+
188+
const targetId = detailed.targetId;
189+
if (targetId === undefined) return;
190+
191+
const existing = this.relationEntityIdsByTarget.get(targetId);
192+
if (!existing) return;
193+
194+
existing.delete(entityId);
195+
if (existing.size === 0) {
196+
this.relationEntityIdsByTarget.delete(targetId);
197+
}
198+
}
199+
200+
private getComponentEntityComponents(entityId: EntityId, create: boolean): Map<EntityId<any>, any> | undefined {
201+
let data = this.componentEntityComponents.get(entityId);
202+
if (!data && create) {
203+
data = new Map();
204+
this.componentEntityComponents.set(entityId, data);
205+
this.registerRelationEntityId(entityId);
206+
}
207+
return data;
208+
}
209+
210+
private clearComponentEntityComponents(entityId: EntityId): void {
211+
if (this.componentEntityComponents.delete(entityId)) {
212+
this.unregisterRelationEntityId(entityId);
213+
}
214+
}
215+
216+
private cleanupComponentEntitiesReferencingEntity(targetId: EntityId): void {
217+
const relationEntities = this.relationEntityIdsByTarget.get(targetId);
218+
if (!relationEntities) return;
219+
220+
for (const relationEntityId of relationEntities) {
221+
this.componentEntityComponents.delete(relationEntityId);
222+
}
223+
this.relationEntityIdsByTarget.delete(targetId);
224+
}
225+
143226
private destroyEntityImmediate(entityId: EntityId): void {
144227
const queue: EntityId[] = [entityId];
145228
const visited = new Set<EntityId>();
@@ -176,6 +259,7 @@ export class World {
176259

177260
this.cleanupArchetypesReferencingEntity(cur);
178261
this.entityIdManager.deallocate(cur);
262+
this.cleanupComponentEntitiesReferencingEntity(cur);
179263
}
180264
}
181265

@@ -191,6 +275,7 @@ export class World {
191275
* }
192276
*/
193277
exists(entityId: EntityId): boolean {
278+
if (this.isComponentEntityId(entityId)) return true;
194279
return this.entityToArchetype.has(entityId);
195280
}
196281

@@ -290,6 +375,23 @@ export class World {
290375
* }
291376
*/
292377
has<T>(entityId: EntityId, componentType: EntityId<T>): boolean {
378+
if (this.isComponentEntityId(entityId)) {
379+
if (isWildcardRelationId(componentType)) {
380+
const componentId = getComponentIdFromRelationId(componentType);
381+
if (componentId === undefined) return false;
382+
383+
const data = this.componentEntityComponents.get(entityId);
384+
if (!data) return false;
385+
386+
for (const key of data.keys()) {
387+
if (getComponentIdFromRelationId(key) === componentId) return true;
388+
}
389+
return false;
390+
}
391+
392+
return this.componentEntityComponents.get(entityId)?.has(componentType) ?? false;
393+
}
394+
293395
const archetype = this.entityToArchetype.get(entityId);
294396
if (!archetype) return false;
295397

@@ -330,6 +432,35 @@ export class World {
330432
entityId: EntityId,
331433
componentType: EntityId<T> | WildcardRelationId<T> = entityId as EntityId<T>,
332434
): T | [EntityId<unknown>, any][] {
435+
if (this.isComponentEntityId(entityId)) {
436+
if (isWildcardRelationId(componentType as EntityId<any>)) {
437+
const componentId = getComponentIdFromRelationId(componentType as EntityId<any>);
438+
const data = this.componentEntityComponents.get(entityId);
439+
const relations: [EntityId<unknown>, any][] = [];
440+
441+
if (componentId !== undefined && data) {
442+
for (const [key, value] of data.entries()) {
443+
if (getComponentIdFromRelationId(key) === componentId) {
444+
const detailed = getDetailedIdType(key);
445+
if (detailed.type === "entity-relation" || detailed.type === "component-relation") {
446+
relations.push([detailed.targetId!, value]);
447+
}
448+
}
449+
}
450+
}
451+
452+
return relations;
453+
}
454+
455+
const data = this.componentEntityComponents.get(entityId);
456+
if (!data || !data.has(componentType as EntityId<any>)) {
457+
throw new Error(
458+
`Entity ${entityId} does not have component ${componentType}. Use has() to check component existence before calling get().`,
459+
);
460+
}
461+
return data.get(componentType as EntityId<any>);
462+
}
463+
333464
const archetype = this.entityToArchetype.get(entityId);
334465
if (!archetype) {
335466
throw new Error(`Entity ${entityId} does not exist`);
@@ -374,6 +505,33 @@ export class World {
374505
getOptional<T>(entityId: EntityId<T>): { value: T } | undefined;
375506
getOptional<T>(entityId: EntityId, componentType: EntityId<T>): { value: T } | undefined;
376507
getOptional<T>(entityId: EntityId, componentType: EntityId<T> = entityId as EntityId<T>): { value: T } | undefined {
508+
if (this.isComponentEntityId(entityId)) {
509+
if (isWildcardRelationId(componentType)) {
510+
const componentId = getComponentIdFromRelationId(componentType);
511+
if (componentId === undefined) return undefined;
512+
513+
const data = this.componentEntityComponents.get(entityId);
514+
if (!data) return undefined;
515+
516+
const relations: [EntityId<unknown>, any][] = [];
517+
for (const [key, value] of data.entries()) {
518+
if (getComponentIdFromRelationId(key) === componentId) {
519+
const detailed = getDetailedIdType(key);
520+
if (detailed.type === "entity-relation" || detailed.type === "component-relation") {
521+
relations.push([detailed.targetId!, value]);
522+
}
523+
}
524+
}
525+
526+
if (relations.length === 0) return undefined;
527+
return { value: relations as T };
528+
}
529+
530+
const data = this.componentEntityComponents.get(entityId);
531+
if (!data || !data.has(componentType)) return undefined;
532+
return { value: data.get(componentType) };
533+
}
534+
377535
const archetype = this.entityToArchetype.get(entityId);
378536
if (!archetype) {
379537
throw new Error(`Entity ${entityId} does not exist`);
@@ -811,6 +969,11 @@ export class World {
811969
executeEntityCommands(entityId: EntityId, commands: Command[]): ComponentChangeset {
812970
const changeset = new ComponentChangeset();
813971

972+
if (this.isComponentEntityId(entityId)) {
973+
this.executeComponentEntityCommands(entityId, commands);
974+
return changeset;
975+
}
976+
814977
if (commands.some((cmd) => cmd.type === "destroy")) {
815978
this.destroyEntityImmediate(entityId);
816979
return changeset;
@@ -846,6 +1009,40 @@ export class World {
8461009
return changeset;
8471010
}
8481011

1012+
private executeComponentEntityCommands(entityId: EntityId, commands: Command[]): void {
1013+
if (commands.some((cmd) => cmd.type === "destroy")) {
1014+
this.clearComponentEntityComponents(entityId);
1015+
return;
1016+
}
1017+
1018+
for (const command of commands) {
1019+
if (command.type === "set" && command.componentType) {
1020+
const data = this.getComponentEntityComponents(entityId, true)!;
1021+
data.set(command.componentType, command.component);
1022+
} else if (command.type === "delete" && command.componentType) {
1023+
const data = this.componentEntityComponents.get(entityId);
1024+
if (!data) continue;
1025+
1026+
if (isWildcardRelationId(command.componentType)) {
1027+
const componentId = getComponentIdFromRelationId(command.componentType);
1028+
if (componentId !== undefined) {
1029+
for (const key of Array.from(data.keys())) {
1030+
if (getComponentIdFromRelationId(key) === componentId) {
1031+
data.delete(key);
1032+
}
1033+
}
1034+
}
1035+
} else {
1036+
data.delete(command.componentType);
1037+
}
1038+
1039+
if (data.size === 0) {
1040+
this.clearComponentEntityComponents(entityId);
1041+
}
1042+
}
1043+
}
1044+
}
1045+
8491046
private createHooksContext(): HooksContext {
8501047
return {
8511048
hooks: this.legacyHooks,
@@ -1033,10 +1230,22 @@ export class World {
10331230
}
10341231
}
10351232

1233+
const componentEntities: SerializedEntity[] = [];
1234+
for (const [entityId, components] of this.componentEntityComponents.entries()) {
1235+
componentEntities.push({
1236+
id: encodeEntityId(entityId),
1237+
components: Array.from(components.entries()).map(([rawType, value]) => ({
1238+
type: encodeEntityId(rawType),
1239+
value: value === MISSING_COMPONENT ? undefined : value,
1240+
})),
1241+
});
1242+
}
1243+
10361244
return {
10371245
version: 1,
10381246
entityManager: this.entityIdManager.serializeState(),
10391247
entities,
1248+
componentEntities,
10401249
};
10411250
}
10421251
}

0 commit comments

Comments
 (0)