Skip to content

Commit 56190dc

Browse files
committed
feat: Enhance event system and add comprehensive tests for event handling and history management
1 parent 8713d7c commit 56190dc

File tree

12 files changed

+860
-54
lines changed

12 files changed

+860
-54
lines changed

src/core/ecs/Entity.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@ export class Entity {
5555
}
5656

5757
public addComponent<T extends JsonSchema>(componentType: ComponentConstructor<T>, data: T): this {
58-
const world = this.world ?? ServiceRegistry.get<World>(World.name);
59-
60-
if (!world.hasComponent(componentType)) {
58+
if (!this.world.hasComponent(componentType)) {
6159
throw new GameError(`${componentType.name} not defined in world`);
6260
}
6361

@@ -104,9 +102,7 @@ export class Entity {
104102
}
105103

106104
public addState(stateType: GameStateConstructor): this {
107-
const world = this.world ?? ServiceRegistry.get<World>(World.name);
108-
109-
if (!world.hasEntityState(stateType)) {
105+
if (!this.world.hasEntityState(stateType)) {
110106
throw new GameError(`${stateType.name} not defined in world`);
111107
}
112108

src/core/ecs/World.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ export class World {
188188
binaryInsert(this.updateSchedule, system, System.byPriority);
189189
} else if (system instanceof RenderSystem) {
190190
binaryInsert(this.renderSchedule, system, System.byPriority);
191+
} else {
192+
throw new GameError(`System ${system.constructor.name} is neither an UpdateSystem nor a RenderSystem`);
191193
}
192194
}
193195

@@ -205,6 +207,8 @@ export class World {
205207
this.updateSchedule = this.updateSchedule.filter(s => s !== system);
206208
} else if (system instanceof RenderSystem) {
207209
this.renderSchedule = this.renderSchedule.filter(s => s !== system);
210+
} else {
211+
throw new GameError(`System ${system.constructor.name} is neither an UpdateSystem nor a RenderSystem`);
208212
}
209213
}
210214

test/core/ecs/Component.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,40 @@
1-
import { test, expect, suite } from "vitest";import { Component } from "../../../src/core/ecs/Component";import { JsonSchema } from "../../../src/core/ecs/JsonSchema";suite("Component Test Suite", () => { interface Point extends JsonSchema { x: number; y: number; } test("Parse a component from json", () => { const json = '{"x": 10, "y": 20}'; const component = Component.parse<Point>(json); expect(component.toObject()).toEqual({ x: 10, y: 20 }); }); test("Clone a component", () => { const component = new Component<Point>({ x: 10, y: 20 }); const clone = component.clone(); expect(clone.toObject()).toEqual({ x: 10, y: 20 }); }); test("Copy a component", () => { const component = new Component<Point>({ x: 10, y: 20 }); const other = new Component<Point>({ x: 50, y: 50 }); component.copy(other); expect(component.toObject()).toEqual({ x: 50, y: 50 }); }); test("Update a component", () => { const component = new Component<Point>({ x: 10, y: 20 }); component.update({ x: 50, y: 50 }); expect(component.toObject()).toEqual({ x: 50, y: 50 }); }); test("Convert a component to string", () => { const component = new Component<Point>({ x: 10, y: 20 }); expect(component.toString()).toEqual('{\n\t"x": 10,\n\t"y": 20\n}'); });});
1+
import { test, expect, suite } from "vitest";
2+
import { Component } from "@/core/ecs/Component";
3+
import { JsonSchema } from "@/core/ecs/JsonSchema";
4+
5+
suite("Component Test Suite", () => {
6+
interface Point extends JsonSchema {
7+
x: number;
8+
y: number;
9+
}
10+
11+
test("Parse a component from json", () => {
12+
const json = '{"x": 10, "y": 20}';
13+
const component = Component.parse<Point>(json);
14+
expect(component.toObject()).toEqual({ x: 10, y: 20 });
15+
});
16+
17+
test("Clone a component", () => {
18+
const component = new Component<Point>({ x: 10, y: 20 });
19+
const clone = component.clone();
20+
expect(clone.toObject()).toEqual({ x: 10, y: 20 });
21+
});
22+
23+
test("Copy a component", () => {
24+
const component = new Component<Point>({ x: 10, y: 20 });
25+
const other = new Component<Point>({ x: 50, y: 50 });
26+
component.copy(other);
27+
expect(component.toObject()).toEqual({ x: 50, y: 50 });
28+
});
29+
30+
test("Update a component", () => {
31+
const component = new Component<Point>({ x: 10, y: 20 });
32+
component.update({ x: 50, y: 50 });
33+
expect(component.toObject()).toEqual({ x: 50, y: 50 });
34+
});
35+
36+
test("Convert a component to string", () => {
37+
const component = new Component<Point>({ x: 10, y: 20 });
38+
expect(component.toString()).toEqual('{\n\t"x": 10,\n\t"y": 20\n}');
39+
});
40+
});

test/core/ecs/Entity.spec.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { test, expect, suite } from "vitest";
2-
import { Entity } from "../../../src/core/ecs/Entity";
3-
import { Component } from "../../../src/core/ecs/Component";
4-
import { World } from "../../../src/core/ecs/World";
5-
import { GameState } from "../../../src/core/GameState";
6-
import { GameStateManager } from "../../../src/core/GameStateManager";
7-
import { GameError } from "../../../src/core/GameError";
8-
import { ServiceRegistry } from "../../../src/core/service/ServiceRegistry";
2+
import { Entity } from "@/core/ecs/Entity";
3+
import { Component } from "@/core/ecs/Component";
4+
import { World } from "@/core/ecs/World";
5+
import { GameState } from "@/core/GameState";
6+
import { GameStateManager } from "@/core/GameStateManager";
7+
import { GameError } from "@/core/GameError";
8+
import { ServiceRegistry } from "@/core/service/ServiceRegistry";
99

1010
suite("Entity Test Suite", () => {
1111
class PointComponent extends Component<{
@@ -180,4 +180,16 @@ suite("Entity Test Suite", () => {
180180
entity.enable();
181181
expect(entity.isEnabled()).toBeTruthy();
182182
});
183+
184+
test("Add component and state without world service", () => {
185+
// Test that accessing world property directly works when injected
186+
const entity = new Entity();
187+
entity.addComponent(PointComponent, { x: 100, y: 200 });
188+
expect(entity.getComponentData(PointComponent)).toEqual({ x: 100, y: 200 });
189+
190+
entity.addState(JumpState);
191+
const stateManager = entity.getStateManager();
192+
const registeredStates = stateManager.getRegisteredStates();
193+
expect(registeredStates[0]).toBeInstanceOf(JumpState);
194+
});
183195
});

test/core/ecs/Query.spec.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { test, expect, suite } from "vitest";
2-
import { Component } from "../../../src/core/ecs/Component";
3-
import { World } from "../../../src/core/ecs/World";
4-
import { Query } from "../../../src/core/ecs/Query";
5-
import { ServiceRegistry } from "../../../src/core/service/ServiceRegistry";
6-
import { EventSystem } from "../../../src/core/events/EventSystem";
2+
import { Component } from "@/core/ecs/Component";
3+
import { World } from "@/core/ecs/World";
4+
import { Query } from "@/core/ecs/Query";
5+
import { ServiceRegistry } from "@/core/service/ServiceRegistry";
6+
import { EventSystem } from "@/core/events/EventSystem";
77

88
suite("Query Test Suite", () => {
99
class PointComponent extends Component<{
@@ -59,4 +59,101 @@ suite("Query Test Suite", () => {
5959
expect(results[0].getID()).toEqual("1337");
6060
expect(results[1].getID()).toEqual("7331");
6161
});
62+
63+
test("Query removes entity when component is added that is in blocklist", () => {
64+
const query = new Query({
65+
allowlist: ["PointComponent"],
66+
blocklist: ["MoveComponent"]
67+
});
68+
69+
const entity = world.createEntity("remove-test-1");
70+
entity.addComponent(PointComponent, { x: 5, y: 5 });
71+
eventSystem.processQueue();
72+
73+
let results = query.getResult();
74+
expect(results.some(e => e.getID() === "remove-test-1")).toBeTruthy();
75+
76+
// Add a component from the blocklist - entity should be removed from query
77+
entity.addComponent(MoveComponent, { dx: 1, dy: 1 });
78+
eventSystem.processQueue();
79+
80+
results = query.getResult();
81+
expect(results.some(e => e.getID() === "remove-test-1")).toBeFalsy();
82+
83+
query.dispose();
84+
});
85+
86+
test("Query removes entity when entity is removed from world", () => {
87+
const query = new Query({
88+
allowlist: ["PointComponent"]
89+
});
90+
91+
const entity = world.createEntity("remove-test-2");
92+
entity.addComponent(PointComponent, { x: 3, y: 3 });
93+
eventSystem.processQueue();
94+
95+
let results = query.getResult();
96+
expect(results.some(e => e.getID() === "remove-test-2")).toBeTruthy();
97+
98+
// Remove entity from world - should trigger onEntityRemoved
99+
world.unregisterEntity(entity);
100+
eventSystem.processQueue();
101+
102+
results = query.getResult();
103+
expect(results.some(e => e.getID() === "remove-test-2")).toBeFalsy();
104+
105+
query.dispose();
106+
});
107+
108+
test("Query getSingleResult returns first entity or null", () => {
109+
const query = new Query({
110+
allowlist: ["PointComponent"],
111+
blocklist: ["MoveComponent"]
112+
});
113+
114+
eventSystem.processQueue();
115+
116+
// Should return first entity
117+
const singleResult = query.getSingleResult();
118+
expect(singleResult).not.toBeNull();
119+
expect(singleResult?.getID()).toEqual("1337");
120+
121+
// Test with empty query
122+
const emptyQuery = new Query({
123+
allowlist: ["NonExistentComponent"]
124+
});
125+
126+
const noResult = emptyQuery.getSingleResult();
127+
expect(noResult).toBeNull();
128+
129+
query.dispose();
130+
emptyQuery.dispose();
131+
});
132+
133+
test("Query onEntityRemoved when entity is not in query (else branch)", () => {
134+
// Create a query that only matches PointComponent
135+
const query = new Query({
136+
allowlist: ["PointComponent"]
137+
});
138+
139+
// Create an entity that DOESN'T match the query
140+
const entity = world.createEntity("non-matching-entity");
141+
entity.addComponent(MoveComponent, { dx: 5, dy: 5 });
142+
eventSystem.processQueue();
143+
144+
// Verify entity is NOT in the query
145+
let results = query.getResult();
146+
expect(results.some(e => e.getID() === "non-matching-entity")).toBeFalsy();
147+
148+
// Now remove the entity - this triggers onEntityRemoved
149+
// Since entity is not in query, exists will be false
150+
world.unregisterEntity(entity);
151+
eventSystem.processQueue();
152+
153+
// Query should still not contain it (nothing should change)
154+
results = query.getResult();
155+
expect(results.some(e => e.getID() === "non-matching-entity")).toBeFalsy();
156+
157+
query.dispose();
158+
});
62159
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { test, expect, suite, vi } from "vitest";
2+
import { ReactiveSystem } from "@/core/ecs/ReactiveSystem";
3+
import { Query } from "@/core/ecs/Query";
4+
import { ServiceRegistry } from "@/core/service/ServiceRegistry";
5+
import { EventSystem } from "@/core/events/EventSystem";
6+
import { UpdateSystem } from "@/core/ecs/UpdateSystem";
7+
import { Entity } from "@/core/ecs/Entity";
8+
9+
suite("ReactiveSystem Test Suite", () => {
10+
let entityChanged = false;
11+
12+
class TestReactiveSystem extends ReactiveSystem {
13+
queries = {
14+
query: new Query({
15+
allowlist: [],
16+
blocklist: []
17+
})
18+
};
19+
20+
public initialize(): void {
21+
this.eventSystem.subscribeOnce("entityChanged", () => {
22+
entityChanged = true;
23+
});
24+
}
25+
}
26+
27+
test("ReactiveSystem extends UpdateSystem and has eventSystem", () => {
28+
const eventSystem = ServiceRegistry.get<EventSystem>(EventSystem);
29+
const system = new TestReactiveSystem(0);
30+
31+
expect(system).toBeDefined();
32+
expect(system.getPriority()).toBe(0);
33+
expect(system.isEnabled()).toBeTruthy();
34+
expect(system).toBeInstanceOf(UpdateSystem);
35+
36+
eventSystem.dispatch("entityChanged", {
37+
entity: new Entity("1337")
38+
});
39+
eventSystem.processQueue();
40+
41+
expect(entityChanged).toBeTruthy();
42+
});
43+
44+
test("ReactiveSystem execute method does nothing by default", () => {
45+
const system = new TestReactiveSystem(0);
46+
47+
// Execute should not throw and does nothing
48+
expect(() => system.execute(16, 1)).not.toThrow();
49+
50+
// Verify it was called (even though it does nothing)
51+
const executeSpy = vi.spyOn(system, 'execute');
52+
system.execute(16, 1);
53+
expect(executeSpy).toHaveBeenCalledWith(16, 1);
54+
});
55+
56+
test("ReactiveSystem can be enabled and disabled", () => {
57+
const system = new TestReactiveSystem(5);
58+
59+
expect(system.isEnabled()).toBeTruthy();
60+
61+
system.disable();
62+
expect(system.isEnabled()).toBeFalsy();
63+
64+
system.enable();
65+
expect(system.isEnabled()).toBeTruthy();
66+
});
67+
68+
test("ReactiveSystem can access queries", () => {
69+
const system = new TestReactiveSystem(0);
70+
71+
expect(system.queries).toBeDefined();
72+
expect(system.queries.query).toBeInstanceOf(Query);
73+
});
74+
});

test/core/ecs/System.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect, suite } from "vitest";
2-
import { System } from "../../../src/core/ecs/System";
3-
import { Query } from "../../../src/core/ecs/Query";
2+
import { System } from "@/core/ecs/System";
3+
import { Query } from "@/core/ecs/Query";
44

55
suite("System Test Suite", () => {
66
class MovementSystem extends System {

0 commit comments

Comments
 (0)