Skip to content

Commit 88c3f3d

Browse files
Copilotcodehz
andcommitted
Implement wildcard markers for dontFragment relations
Co-authored-by: codehz <13158903+codehz@users.noreply.github.com>
1 parent e3a2dfe commit 88c3f3d

File tree

2 files changed

+312
-6
lines changed

2 files changed

+312
-6
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { component, relation } from "./entity";
3+
import { World } from "./world";
4+
5+
describe("DontFragment Query Notification Issue", () => {
6+
it("should notify queries when new archetypes with dontFragment relations are created", () => {
7+
const world = new World();
8+
9+
const PositionId = component();
10+
const ChildOf = component({ dontFragment: true });
11+
12+
// Create a query BEFORE any entities with ChildOf relations exist
13+
const wildcardChildOf = relation(ChildOf, "*");
14+
const query = world.createQuery([wildcardChildOf, PositionId]);
15+
16+
// Initially, no entities match
17+
expect(query.getEntities().length).toBe(0);
18+
19+
// Now create entities with ChildOf relations
20+
const parent1 = world.new();
21+
const parent2 = world.new();
22+
23+
const child1 = world.new();
24+
world.set(child1, PositionId);
25+
world.set(child1, relation(ChildOf, parent1));
26+
27+
const child2 = world.new();
28+
world.set(child2, PositionId);
29+
world.set(child2, relation(ChildOf, parent2));
30+
31+
world.sync();
32+
33+
// The query should now find both children
34+
// This is the key test: the query was created before the archetype existed
35+
const entities = query.getEntities();
36+
expect(entities.length).toBe(2);
37+
expect(entities).toContain(child1);
38+
expect(entities).toContain(child2);
39+
});
40+
41+
it("should separate archetypes with and without wildcard markers", () => {
42+
const world = new World();
43+
44+
const PositionId = component();
45+
const VelocityId = component();
46+
const ChildOf = component({ dontFragment: true });
47+
48+
// Create entities without ChildOf relation
49+
const entity1 = world.new();
50+
world.set(entity1, PositionId);
51+
world.set(entity1, VelocityId);
52+
53+
world.sync();
54+
55+
// Create a query for entities with ChildOf relation
56+
const wildcardChildOf = relation(ChildOf, "*");
57+
const query = world.createQuery([wildcardChildOf, PositionId]);
58+
59+
// Entity1 should NOT match because it has no ChildOf relation
60+
expect(query.getEntities()).not.toContain(entity1);
61+
62+
// Create entities with ChildOf relation
63+
const parent = world.new();
64+
const entity2 = world.new();
65+
world.set(entity2, PositionId);
66+
world.set(entity2, VelocityId);
67+
world.set(entity2, relation(ChildOf, parent));
68+
69+
world.sync();
70+
71+
// Now entity2 should match
72+
const entities = query.getEntities();
73+
expect(entities.length).toBe(1);
74+
expect(entities).toContain(entity2);
75+
expect(entities).not.toContain(entity1);
76+
77+
// Verify they're in different archetypes
78+
const archetypes = (world as any).archetypes;
79+
const archetypesWithPosition = archetypes.filter((arch: any) => {
80+
return arch.componentTypes.includes(PositionId) && arch.componentTypes.includes(VelocityId);
81+
});
82+
83+
// Should have 2 archetypes: one without wildcard marker, one with
84+
expect(archetypesWithPosition.length).toBe(2);
85+
86+
// One archetype should have the wildcard marker
87+
const withMarker = archetypesWithPosition.filter((arch: any) => arch.componentTypes.includes(wildcardChildOf));
88+
expect(withMarker.length).toBe(1);
89+
90+
// The other should not
91+
const withoutMarker = archetypesWithPosition.filter((arch: any) => !arch.componentTypes.includes(wildcardChildOf));
92+
expect(withoutMarker.length).toBe(1);
93+
});
94+
95+
it("should handle adding dontFragment relations to existing entities", () => {
96+
const world = new World();
97+
98+
const PositionId = component();
99+
const ChildOf = component({ dontFragment: true });
100+
101+
// Create entity without ChildOf
102+
const entity = world.new();
103+
world.set(entity, PositionId);
104+
world.sync();
105+
106+
// Create query for entities with ChildOf
107+
const wildcardChildOf = relation(ChildOf, "*");
108+
const query = world.createQuery([wildcardChildOf, PositionId]);
109+
110+
// Entity should not match initially
111+
expect(query.getEntities()).not.toContain(entity);
112+
113+
// Add ChildOf relation
114+
const parent = world.new();
115+
world.set(entity, relation(ChildOf, parent));
116+
world.sync();
117+
118+
// Entity should now match
119+
expect(query.getEntities()).toContain(entity);
120+
});
121+
122+
it("should handle removing last dontFragment relation", () => {
123+
const world = new World();
124+
125+
const PositionId = component();
126+
const ChildOf = component({ dontFragment: true });
127+
128+
const parent = world.new();
129+
const entity = world.new();
130+
world.set(entity, PositionId);
131+
world.set(entity, relation(ChildOf, parent));
132+
world.sync();
133+
134+
// Create query
135+
const wildcardChildOf = relation(ChildOf, "*");
136+
const query = world.createQuery([wildcardChildOf, PositionId]);
137+
138+
// Entity should match
139+
expect(query.getEntities()).toContain(entity);
140+
141+
// Remove the relation
142+
world.remove(entity, relation(ChildOf, parent));
143+
world.sync();
144+
145+
// Entity should no longer match
146+
expect(query.getEntities()).not.toContain(entity);
147+
148+
// Verify entity moved to archetype without wildcard marker
149+
const archetype = (world as any).entityToArchetype.get(entity);
150+
expect(archetype.componentTypes).not.toContain(wildcardChildOf);
151+
});
152+
153+
it("should handle multiple dontFragment relations on same entity", () => {
154+
const world = new World();
155+
156+
const PositionId = component();
157+
const ChildOf = component({ dontFragment: true });
158+
159+
const parent1 = world.new();
160+
const parent2 = world.new();
161+
const entity = world.new();
162+
163+
world.set(entity, PositionId);
164+
world.set(entity, relation(ChildOf, parent1));
165+
world.set(entity, relation(ChildOf, parent2));
166+
world.sync();
167+
168+
// Create query
169+
const wildcardChildOf = relation(ChildOf, "*");
170+
const query = world.createQuery([wildcardChildOf, PositionId]);
171+
172+
// Entity should match
173+
expect(query.getEntities()).toContain(entity);
174+
175+
// Remove one relation
176+
world.remove(entity, relation(ChildOf, parent1));
177+
world.sync();
178+
179+
// Entity should still match (still has one relation)
180+
expect(query.getEntities()).toContain(entity);
181+
182+
// Wildcard marker should still be present
183+
const archetype = (world as any).entityToArchetype.get(entity);
184+
expect(archetype.componentTypes).toContain(wildcardChildOf);
185+
186+
// Remove the last relation
187+
world.remove(entity, relation(ChildOf, parent2));
188+
world.sync();
189+
190+
// Entity should no longer match
191+
expect(query.getEntities()).not.toContain(entity);
192+
193+
// Wildcard marker should be removed
194+
const newArchetype = (world as any).entityToArchetype.get(entity);
195+
expect(newArchetype.componentTypes).not.toContain(wildcardChildOf);
196+
});
197+
198+
it("should allow false positives but filter correctly during iteration", () => {
199+
const world = new World();
200+
201+
const PositionId = component();
202+
const TagA = component({ dontFragment: true });
203+
const TagB = component({ dontFragment: true });
204+
205+
// Create entities with different dontFragment relations
206+
const parent1 = world.new();
207+
const parent2 = world.new();
208+
209+
const entity1 = world.new();
210+
world.set(entity1, PositionId);
211+
world.set(entity1, relation(TagA, parent1));
212+
213+
const entity2 = world.new();
214+
world.set(entity2, PositionId);
215+
world.set(entity2, relation(TagB, parent2));
216+
217+
world.sync();
218+
219+
// Both entities should be in the same archetype (Position + wildcard for TagA/TagB)
220+
// But queries should still work correctly
221+
const wildcardTagA = relation(TagA, "*");
222+
const queryA = world.createQuery([wildcardTagA, PositionId]);
223+
224+
const wildcardTagB = relation(TagB, "*");
225+
const queryB = world.createQuery([wildcardTagB, PositionId]);
226+
227+
// QueryA should only find entity1
228+
expect(queryA.getEntities()).toContain(entity1);
229+
expect(queryA.getEntities()).not.toContain(entity2);
230+
231+
// QueryB should only find entity2
232+
expect(queryB.getEntities()).not.toContain(entity1);
233+
expect(queryB.getEntities()).toContain(entity2);
234+
});
235+
});

src/world.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -573,10 +573,25 @@ export class World<UpdateParams extends any[] = []> {
573573

574574
// Filter by wildcard relations
575575
for (const wildcard of wildcardRelations) {
576-
// Keep only archetypes that have the component (including dontFragment relations)
577-
matchingArchetypes = matchingArchetypes.filter((archetype) =>
578-
archetype.hasRelationWithComponentId(wildcard.componentId),
579-
);
576+
// For dontFragment components, check if wildcard marker is in archetypesByComponent
577+
if (isDontFragmentComponent(wildcard.componentId)) {
578+
// Use the wildcard marker for efficient lookup
579+
const archetypesWithMarker = this.archetypesByComponent.get(wildcard.relationId) || [];
580+
581+
if (matchingArchetypes.length === 0) {
582+
// No regular components, use all archetypes with the wildcard marker
583+
matchingArchetypes = archetypesWithMarker;
584+
} else {
585+
// Filter to only include archetypes that have the wildcard marker
586+
matchingArchetypes = matchingArchetypes.filter((archetype) => archetypesWithMarker.includes(archetype));
587+
}
588+
} else {
589+
// For regular (non-dontFragment) relations, fall back to entity-level checking
590+
// This is necessary because non-dontFragment relations are in the archetype signature
591+
matchingArchetypes = matchingArchetypes.filter((archetype) =>
592+
archetype.hasRelationWithComponentId(wildcard.componentId),
593+
);
594+
}
580595
}
581596

582597
return matchingArchetypes;
@@ -697,6 +712,18 @@ export class World<UpdateParams extends any[] = []> {
697712
this.removeExclusiveRelations(entityId, currentArchetype, detailedType.componentId!, changeset);
698713
}
699714

715+
// For dontFragment relations, ensure wildcard marker is in archetype signature
716+
if (
717+
(detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
718+
isDontFragmentComponent(detailedType.componentId!)
719+
) {
720+
const wildcardMarker = relation(detailedType.componentId!, "*");
721+
// Add wildcard marker to changeset if not already in archetype
722+
if (!currentArchetype.componentTypes.includes(wildcardMarker)) {
723+
changeset.set(wildcardMarker, undefined);
724+
}
725+
}
726+
700727
changeset.set(componentType, component);
701728
}
702729

@@ -754,6 +781,38 @@ export class World<UpdateParams extends any[] = []> {
754781
this.removeWildcardRelations(entityId, currentArchetype, detailedType.componentId!, changeset);
755782
} else {
756783
changeset.delete(componentType);
784+
785+
// If removing a dontFragment relation, check if we should remove the wildcard marker
786+
if (
787+
(detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
788+
isDontFragmentComponent(detailedType.componentId!)
789+
) {
790+
// Check if there are any other dontFragment relations with the same component ID
791+
const wildcardMarker = relation(detailedType.componentId!, "*");
792+
const entityData = currentArchetype.getEntity(entityId);
793+
let hasOtherRelations = false;
794+
795+
if (entityData) {
796+
for (const [otherComponentType] of entityData) {
797+
if (otherComponentType === componentType) continue; // Skip the one being removed
798+
if (changeset.removes.has(otherComponentType)) continue; // Skip if also being removed
799+
800+
const otherDetailedType = getDetailedIdType(otherComponentType);
801+
if (
802+
(otherDetailedType.type === "entity-relation" || otherDetailedType.type === "component-relation") &&
803+
otherDetailedType.componentId === detailedType.componentId
804+
) {
805+
hasOtherRelations = true;
806+
break;
807+
}
808+
}
809+
}
810+
811+
// If no other relations exist, remove the wildcard marker
812+
if (!hasOtherRelations) {
813+
changeset.delete(wildcardMarker);
814+
}
815+
}
757816
}
758817
}
759818

@@ -783,6 +842,12 @@ export class World<UpdateParams extends any[] = []> {
783842
}
784843
}
785844
}
845+
846+
// If removing dontFragment relations, also remove the wildcard marker
847+
if (isDontFragmentComponent(baseComponentId)) {
848+
const wildcardMarker = relation(baseComponentId, "*");
849+
changeset.delete(wildcardMarker);
850+
}
786851
}
787852

788853
/**
@@ -970,15 +1035,21 @@ export class World<UpdateParams extends any[] = []> {
9701035
}
9711036

9721037
/**
973-
* Filter out dontFragment relations from component types
1038+
* Filter out dontFragment relations from component types, but keep wildcard markers
9741039
*/
9751040
private filterRegularComponentTypes(componentTypes: Iterable<EntityId<any>>): EntityId<any>[] {
9761041
const regularTypes: EntityId<any>[] = [];
9771042

9781043
for (const componentType of componentTypes) {
9791044
const detailedType = getDetailedIdType(componentType);
9801045

981-
// Skip dontFragment relations from archetype signature
1046+
// Keep wildcard markers for dontFragment components (they mark the archetype)
1047+
if (detailedType.type === "wildcard-relation" && isDontFragmentComponent(detailedType.componentId!)) {
1048+
regularTypes.push(componentType);
1049+
continue;
1050+
}
1051+
1052+
// Skip specific dontFragment relations from archetype signature
9821053
if (
9831054
(detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
9841055
isDontFragmentComponent(detailedType.componentId!)

0 commit comments

Comments
 (0)