refactor: improve type safety in World and tests

Replace `any` types with specific interfaces like `WorldEvent`,
`QueryUpdate`, and `Entity` to strengthen type checking. This includes
refining the deserialization logic in `World.fromSnapshot` to use
properly typed component definitions.
This commit is contained in:
hypercross 2026-05-31 16:24:59 +08:00
parent 9953c7c556
commit 05674a349f
3 changed files with 19 additions and 11 deletions

View File

@ -7,6 +7,7 @@ import { ObservableLayer } from "./observable/observe";
import type { QueryUpdate, RelationshipUpdate } from "./observable/events"; import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
import type { RelationshipDef } from "./relationship"; import type { RelationshipDef } from "./relationship";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import type { WorldEvent } from "./observable/events";
import type { WorldSnapshot } from "./serialization"; import type { WorldSnapshot } from "./serialization";
// ── World ───────────────────────────────────────────── // ── World ─────────────────────────────────────────────
@ -40,7 +41,7 @@ export class World {
private _observable = new ObservableLayer(); private _observable = new ObservableLayer();
/** Global event stream. */ /** Global event stream. */
get events$(): Observable<any> { get events$(): Observable<WorldEvent> {
return this._observable.events$.asObservable(); return this._observable.events$.asObservable();
} }
@ -435,7 +436,10 @@ export class World {
): World { ): World {
const world = new World(); const world = new World();
const compByName = new Map(components.map((c) => [c.name, c])); const compByName = new Map(components.map((c) => [c.name, c])) as Map<
string,
ComponentDef<Record<string, unknown>>
>;
const relByName = new Map((relationships ?? []).map((r) => [r.name, r])); const relByName = new Map((relationships ?? []).map((r) => [r.name, r]));
// Map string ids → real Entity handles // Map string ids → real Entity handles
@ -453,7 +457,8 @@ export class World {
`Pass it in the components array.`, `Pass it in the components array.`,
); );
} }
world.add(entity, def, value as any); // Unknown at deserialization boundary; shape matches ComponentDef.defaults
world.add(entity, def, value as Partial<Record<string, unknown>>);
} }
} }

View File

@ -6,6 +6,7 @@ import {
type WorldSnapshot, type WorldSnapshot,
query, query,
type RelationshipUpdate, type RelationshipUpdate,
type WorldEvent,
} from "../src/index"; } from "../src/index";
// ── Definitions ───────────────────────────────────── // ── Definitions ─────────────────────────────────────
@ -269,8 +270,9 @@ describe("Serialization — complex state", () => {
const playerId = snap.relationships.ownedBy[bulletId]; const playerId = snap.relationships.ownedBy[bulletId];
// Player should have a name // Player should have a name
expect(snap.entities[playerId]).toHaveProperty("name"); const playerComps = snap.entities[playerId];
expect((snap.entities[playerId].name as any).value).toBe("Hero"); expect(playerComps).toHaveProperty("name");
expect((playerComps.name as { value: string }).value).toBe("Hero");
}); });
}); });
@ -403,8 +405,8 @@ describe("Serialization — observables after load", () => {
w.spawn(); w.spawn();
const loaded = roundTrip(w); const loaded = roundTrip(w);
const events: any[] = []; const events: WorldEvent[] = [];
loaded.events$.subscribe((e: any) => events.push(e)); loaded.events$.subscribe((e) => events.push(e));
const e = loaded.spawn(); const e = loaded.spawn();
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
@ -417,8 +419,8 @@ describe("Serialization — observables after load", () => {
w.add(e, Position, { x: 1, y: 2 }); w.add(e, Position, { x: 1, y: 2 });
const loaded = roundTrip(w); const loaded = roundTrip(w);
const updates: any[] = []; const updates: QueryUpdate[] = [];
loaded.observe(query(Position)).subscribe((u: any) => { loaded.observe(query(Position)).subscribe((u) => {
if (u.added.length || u.removed.length || u.changed.length) { if (u.added.length || u.removed.length || u.changed.length) {
updates.push(u); updates.push(u);
} }

View File

@ -5,6 +5,7 @@ import {
query, query,
type QueryUpdate, type QueryUpdate,
type WorldEvent, type WorldEvent,
type Entity,
} from "../src/index"; } from "../src/index";
// ── Components ────────────────────────────────────── // ── Components ──────────────────────────────────────
@ -84,11 +85,11 @@ describe("Entity lifecycle", () => {
// ── Components ────────────────────────────────────── // ── Components ──────────────────────────────────────
describe("Components", () => { describe("Components", () => {
let world: World; let world: World;
let entity = 0 as any; let entity: Entity;
beforeEach(() => { beforeEach(() => {
world = new World(); world = new World();
entity = world.spawn(); entity = world.spawn() as Entity;
}); });
it("add returns defaults", () => { it("add returns defaults", () => {