Skip to content

Commit 8d1b333

Browse files
committed
feat(core): implement component merge behavior for repeated sets
- Add merge option to ComponentOptions interface - Store and retrieve merge callbacks in component registry - Apply merge logic in world commands and sync phases - Support merge for relations and singleton components - Add comprehensive tests for merge functionality
1 parent a169591 commit 8d1b333

File tree

6 files changed

+157
-10
lines changed

6 files changed

+157
-10
lines changed

src/__tests__/entity.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,15 @@ describe("Component Options", () => {
503503
expect(isDontFragmentComponent(combinedComp)).toBe(true);
504504
});
505505

506+
it("should store and retrieve component merge callback", () => {
507+
const merge = (prev: number[], next: number[]) => [...prev, ...next];
508+
const mailboxComp = component<number[]>({ merge });
509+
510+
const options = getComponentOptions(mailboxComp);
511+
expect(options.merge).toBeDefined();
512+
expect(options.merge?.([1], [2, 3])).toEqual([1, 2, 3]);
513+
});
514+
506515
it("should throw error for invalid component ID", () => {
507516
expect(() => getComponentOptions(0 as ComponentId)).toThrow("Invalid component ID");
508517
expect(() => getComponentOptions(1025 as ComponentId)).toThrow("Invalid component ID");

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,89 @@ describe("World - Component Management", () => {
4141
expect(world.get(entity, positionComponent)).toEqual(position2);
4242
});
4343

44+
it("should keep last value for repeated set in one sync when merge is not configured", () => {
45+
const world = new World();
46+
const entity = world.new();
47+
const position1: Position = { x: 10, y: 20 };
48+
const position2: Position = { x: 30, y: 40 };
49+
50+
world.set(entity, positionComponent, position1);
51+
world.set(entity, positionComponent, position2);
52+
world.sync();
53+
54+
expect(world.get(entity, positionComponent)).toEqual(position2);
55+
});
56+
57+
it("should merge repeated sets in one sync for merge-enabled components", () => {
58+
const world = new World();
59+
const entity = world.new();
60+
const Mailbox = component<string[]>({
61+
merge: (prev, next) => [...prev, ...next],
62+
});
63+
64+
world.set(entity, Mailbox, ["A"]);
65+
world.set(entity, Mailbox, ["B", "C"]);
66+
world.sync();
67+
68+
expect(world.get(entity, Mailbox)).toEqual(["A", "B", "C"]);
69+
});
70+
71+
it("should reset merge accumulation after remove in one sync", () => {
72+
const world = new World();
73+
const entity = world.new();
74+
const Mailbox = component<string[]>({
75+
merge: (prev, next) => [...prev, ...next],
76+
});
77+
78+
world.set(entity, Mailbox, ["A1"]);
79+
world.set(entity, Mailbox, ["A2"]);
80+
world.remove(entity, Mailbox);
81+
world.set(entity, Mailbox, ["B1"]);
82+
world.set(entity, Mailbox, ["B2"]);
83+
world.sync();
84+
85+
expect(world.get(entity, Mailbox)).toEqual(["B1", "B2"]);
86+
});
87+
88+
it("should merge relation sets by exact component type only", () => {
89+
const world = new World();
90+
const entity = world.new();
91+
const target1 = world.new();
92+
const target2 = world.new();
93+
const MailRel = component<string[]>({
94+
merge: (prev, next) => [...prev, ...next],
95+
});
96+
const rel1 = relation(MailRel, target1);
97+
const rel2 = relation(MailRel, target2);
98+
99+
world.set(entity, rel1, ["T1-A"]);
100+
world.set(entity, rel2, ["T2-A"]);
101+
world.set(entity, rel1, ["T1-B"]);
102+
world.set(entity, rel2, ["T2-B"]);
103+
world.sync();
104+
105+
expect(world.get(entity, rel1)).toEqual(["T1-A", "T1-B"]);
106+
expect(world.get(entity, rel2)).toEqual(["T2-A", "T2-B"]);
107+
});
108+
109+
it("should apply merge for singleton(component entity) sets", () => {
110+
const world = new World();
111+
const Inbox = component<string[]>({
112+
merge: (prev, next) => [...prev, ...next],
113+
});
114+
115+
world.set(Inbox, ["A"]);
116+
world.set(Inbox, ["B"]);
117+
world.sync();
118+
expect(world.get(Inbox)).toEqual(["A", "B"]);
119+
120+
world.remove(Inbox);
121+
world.set(Inbox, ["C"]);
122+
world.set(Inbox, ["D"]);
123+
world.sync();
124+
expect(world.get(Inbox)).toEqual(["C", "D"]);
125+
});
126+
44127
it("should remove components from entities", () => {
45128
const world = new World();
46129
const entity = world.new();

src/core/component-registry.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
const globalComponentIdAllocator = new ComponentIdAllocator();
1414

1515
const ComponentIdForNames: Map<string, ComponentId<any>> = new Map();
16+
type ComponentMerge<T = any> = (prev: T, next: T) => T;
1617

1718
/**
1819
* Component options that define intrinsic properties
1920
*/
20-
export interface ComponentOptions {
21+
export interface ComponentOptions<T = any> {
2122
/**
2223
* Optional name for the component (for serialization/debugging)
2324
*/
@@ -43,6 +44,10 @@ export interface ComponentOptions {
4344
* Inspired by Flecs' DontFragment trait.
4445
*/
4546
dontFragment?: boolean;
47+
/**
48+
* Custom merge behavior for repeated set() of the same componentType in a single sync batch.
49+
*/
50+
merge?: ComponentMerge<T>;
4651
}
4752

4853
// Array for component names (Component ID range: 1-1023)
@@ -52,6 +57,7 @@ const componentNames: (string | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
5257
const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
5358
const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
5459
const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
60+
const componentMerges: (ComponentMerge<any> | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
5561

5662
/**
5763
* Allocate a new component ID from the global allocator.
@@ -67,11 +73,11 @@ const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
6773
* // With name and options
6874
* const ChildOf = component({ name: "ChildOf", exclusive: true });
6975
*/
70-
export function component<T = void>(nameOrOptions?: string | ComponentOptions): ComponentId<T> {
76+
export function component<T = void>(nameOrOptions?: string | ComponentOptions<T>): ComponentId<T> {
7177
const id = globalComponentIdAllocator.allocate<T>();
7278

7379
let name: string | undefined;
74-
let options: ComponentOptions | undefined;
80+
let options: ComponentOptions<T> | undefined;
7581

7682
// Parse the parameter
7783
if (typeof nameOrOptions === "string") {
@@ -97,6 +103,7 @@ export function component<T = void>(nameOrOptions?: string | ComponentOptions):
97103
if (options.exclusive) exclusiveFlags.set(id);
98104
if (options.cascadeDelete) cascadeDeleteFlags.set(id);
99105
if (options.dontFragment) dontFragmentFlags.set(id);
106+
if (options.merge) componentMerges[id] = options.merge;
100107
}
101108

102109
return id;
@@ -124,7 +131,7 @@ export function getComponentNameById(id: ComponentId<any>): string | undefined {
124131
* @param id The component ID
125132
* @returns The component options
126133
*/
127-
export function getComponentOptions(id: ComponentId<any>): ComponentOptions {
134+
export function getComponentOptions<T = any>(id: ComponentId<T>): ComponentOptions<T> {
128135
if (!isComponentId(id)) {
129136
throw new Error("Invalid component ID");
130137
}
@@ -137,9 +144,30 @@ export function getComponentOptions(id: ComponentId<any>): ComponentOptions {
137144
exclusive: hasExclusive ? true : undefined,
138145
cascadeDelete: hasCascadeDelete ? true : undefined,
139146
dontFragment: hasDontFragment ? true : undefined,
147+
merge: componentMerges[id] as ComponentMerge<T> | undefined,
140148
};
141149
}
142150

151+
function getBaseComponentId(componentType: EntityId<any>): ComponentId<any> | undefined {
152+
if (isComponentId(componentType)) {
153+
return componentType;
154+
}
155+
156+
const decoded = decodeRelationRaw(componentType);
157+
if (decoded === null) return undefined;
158+
return isValidComponentId(decoded.componentId) ? (decoded.componentId as ComponentId<any>) : undefined;
159+
}
160+
161+
/**
162+
* Get merge callback for a componentType (including relation component types).
163+
* Returns undefined if the base component has no merge callback.
164+
*/
165+
export function getComponentMerge<T = any>(componentType: EntityId<any>): ComponentMerge<T> | undefined {
166+
const baseComponentId = getBaseComponentId(componentType);
167+
if (baseComponentId === undefined) return undefined;
168+
return componentMerges[baseComponentId] as ComponentMerge<T> | undefined;
169+
}
170+
143171
/**
144172
* Check if a component is marked as exclusive
145173
* @param id The component ID

src/core/entity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type { ComponentOptions } from "./component-registry";
4949
export {
5050
component,
5151
getComponentIdByName,
52+
getComponentMerge,
5253
getComponentNameById,
5354
getComponentOptions,
5455
isCascadeDeleteComponent,

src/core/world-commands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Archetype } from "./archetype";
44
import { normalizeComponentTypes } from "./component-type-utils";
55
import {
66
getComponentIdFromRelationId,
7+
getComponentMerge,
78
isDontFragmentComponent,
89
isDontFragmentRelation,
910
isDontFragmentWildcard,
@@ -67,6 +68,13 @@ function processSetCommand(
6768
}
6869
}
6970

71+
const merge = getComponentMerge(componentType);
72+
if (merge !== undefined && changeset.adds.has(componentType)) {
73+
const prev = changeset.adds.get(componentType);
74+
changeset.set(componentType, merge(prev, component));
75+
return;
76+
}
77+
7078
changeset.set(componentType, component);
7179
}
7280

src/core/world.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
EntityIdManager,
1414
RELATION_SHIFT,
1515
getComponentIdFromRelationId,
16+
getComponentMerge,
1617
getDetailedIdType,
1718
getTargetIdFromRelationId,
1819
isCascadeDeleteRelation,
@@ -1157,28 +1158,45 @@ export class World {
11571158
return;
11581159
}
11591160

1161+
const pendingSetValues = new Map<EntityId<any>, any>();
1162+
11601163
for (const command of commands) {
11611164
if (command.type === "set" && command.componentType) {
1165+
const merge = getComponentMerge(command.componentType);
1166+
let nextValue = command.component;
1167+
if (merge !== undefined && pendingSetValues.has(command.componentType)) {
1168+
const prevValue = pendingSetValues.get(command.componentType);
1169+
nextValue = merge(prevValue, command.component);
1170+
}
1171+
1172+
pendingSetValues.set(command.componentType, nextValue);
11621173
const data = this.getComponentEntityComponents(entityId, true)!;
1163-
data.set(command.componentType, command.component);
1174+
data.set(command.componentType, nextValue);
11641175
} else if (command.type === "delete" && command.componentType) {
11651176
const data = this.componentEntityComponents.get(entityId);
1166-
if (!data) continue;
11671177

11681178
if (isWildcardRelationId(command.componentType)) {
11691179
const componentId = getComponentIdFromRelationId(command.componentType);
11701180
if (componentId !== undefined) {
1171-
for (const key of Array.from(data.keys())) {
1181+
if (data) {
1182+
for (const key of Array.from(data.keys())) {
1183+
if (getComponentIdFromRelationId(key) === componentId) {
1184+
data.delete(key);
1185+
}
1186+
}
1187+
}
1188+
for (const key of Array.from(pendingSetValues.keys())) {
11721189
if (getComponentIdFromRelationId(key) === componentId) {
1173-
data.delete(key);
1190+
pendingSetValues.delete(key);
11741191
}
11751192
}
11761193
}
11771194
} else {
1178-
data.delete(command.componentType);
1195+
data?.delete(command.componentType);
1196+
pendingSetValues.delete(command.componentType);
11791197
}
11801198

1181-
if (data.size === 0) {
1199+
if (data?.size === 0) {
11821200
this.clearComponentEntityComponents(entityId);
11831201
}
11841202
}

0 commit comments

Comments
 (0)