diff --git a/examples/basic-dry-run/controller-hub.ts b/examples/basic-dry-run/controller-hub.ts new file mode 100644 index 0000000..2737b03 --- /dev/null +++ b/examples/basic-dry-run/controller-hub.ts @@ -0,0 +1,21 @@ +import { createControllerHub } from "@opencontroller/core"; + +// Typed counterpart to the Multi-Controller Hubs example in the core README. +const hub = await createControllerHub(); + +await hub.add({ + id: "player-1", + profile: "xbox", + adapter: "dry-run", +}); + +await hub.add({ + id: "player-2", + profile: "xbox", + adapter: "dry-run", +}); + +await hub.get("player-1").press("A", 80); +await hub.get("player-2").press("B", 80); +// Await every in-flight hub.add() before disconnecting registered controllers. +await hub.disconnectAll(); diff --git a/packages/core/README.md b/packages/core/README.md index 8dc45a1..d330811 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,23 +51,29 @@ import { createControllerHub } from "@opencontroller/core"; const hub = await createControllerHub(); -const playerOne = await hub.createController({ +await hub.add({ id: "player-1", profile: "xbox", adapter: "dry-run", }); -const playerTwo = await hub.createController({ +await hub.add({ id: "player-2", profile: "xbox", adapter: "dry-run", }); -await playerOne.press("A", 80); -await playerTwo.press("B", 80); +await hub.get("player-1").press("A", 80); +await hub.get("player-2").press("B", 80); await hub.disconnectAll(); ``` +Hub IDs are caller-assigned logical identifiers for controllers in this hub. +They do not identify physical devices or host/browser slots. Use `hub.get(id)` +to retrieve an added controller. Await every in-flight `hub.add()` before +calling `hub.disconnectAll()`: it disconnects the controllers currently +registered with the hub and clears those IDs so they can be added again. + ## Analog Button Pressure ```ts diff --git a/packages/core/src/__tests__/controller.test.ts b/packages/core/src/__tests__/controller.test.ts index 698e632..13e2ada 100644 --- a/packages/core/src/__tests__/controller.test.ts +++ b/packages/core/src/__tests__/controller.test.ts @@ -2461,13 +2461,96 @@ describe("controller runtime", () => { ], }); - await hub.get("player-1").press("A", 5); - await hub.get("player-2").press("X", 5); + const playerOne = hub.get("player-1"); + const playerTwo = hub.get("player-2"); + + await playerOne.setButton("A", true); + expect(playerOne.getState().buttons.A).toBe(true); + expect(playerTwo.getState().buttons.A).toBe(false); + + await playerTwo.setButton("X", true); + expect(playerTwo.getState().buttons.X).toBe(true); + expect(playerOne.getState().buttons.X).toBe(false); expect(hub.list()).toEqual(["player-1", "player-2"]); expect(hub.states()["player-1"]?.profile).toBe("xbox"); expect(hub.states()["player-2"]?.profile).toBe("xbox"); + await playerOne.setButton("A", false); + await playerTwo.setButton("X", false); + await hub.disconnectAll(); + }); + + test("rejects duplicate logical hub IDs and allows reuse after disconnectAll", async () => { + const hub = await createControllerHub(); + const options = { + id: "caller-owned-player", + profile: "xbox" as const, + adapter: "dry-run" as const, + replay: false, + }; + const first = await hub.add(options); + + await expect(hub.add(options)).rejects.toThrow( + "Controller caller-owned-player already exists", + ); + expect(hub.get(options.id)).toBe(first); + + await hub.disconnectAll(); + expect(hub.has(options.id)).toBe(false); + + const reused = await hub.add(options); + expect(hub.get(options.id)).toBe(reused); + expect(reused).not.toBe(first); + + await hub.disconnectAll(); + }); + + test("reserves hub IDs while controller creation is pending", async () => { + const hub = await createControllerHub(); + const options = { + id: "concurrent-player", + profile: "xbox" as const, + adapter: "dry-run" as const, + replay: false, + }; + + const results = await Promise.allSettled([ + hub.add(options), + hub.add(options), + ]); + const createdControllers = results.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + expect(results[0]?.status).toBe("fulfilled"); + expect(results[1]?.status).toBe("rejected"); + if (results[1]?.status === "rejected") { + expect(results[1].reason).toMatchObject({ + message: "Controller concurrent-player already exists", + }); + } + expect(createdControllers).toHaveLength(1); + expect(hub.get(options.id)).toBe(createdControllers[0]); + + await hub.disconnectAll(); + }); + + test("releases a reserved hub ID when controller creation fails", async () => { + const hub = await createControllerHub(); + const options = { + id: "retry-after-failure", + profile: "xbox" as const, + adapter: "websocket" as const, + replay: false, + }; + + await expect(hub.add(options)).rejects.toThrow( + "WebSocket adapter requires a url option", + ); + + const controller = await hub.add({ ...options, adapter: "dry-run" }); + expect(hub.get(options.id)).toBe(controller); await hub.disconnectAll(); }); }); diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index 7c848e4..18996e2 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -11,15 +11,21 @@ export type ControllerHubOptions = { export class ControllerHub { private readonly controllers = new Map(); + private readonly pendingIds = new Set(); async add(options: ControllerHubEntry): Promise { - if (this.controllers.has(options.id)) { + if (this.controllers.has(options.id) || this.pendingIds.has(options.id)) { throw new Error(`Controller ${options.id} already exists`); } - const controller = await createController(options); - this.controllers.set(options.id, controller); - return controller; + this.pendingIds.add(options.id); + try { + const controller = await createController(options); + this.controllers.set(options.id, controller); + return controller; + } finally { + this.pendingIds.delete(options.id); + } } get(id: string): Controller {