diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cf125b..6c18e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: - run: bun run typecheck - run: bun test - run: bun run build + - run: bun run check:dry-run-examples - run: bun audit - run: bun run pack:check - run: | diff --git a/docs/ai-agent-integration.md b/docs/ai-agent-integration.md index b15435d..4078f7b 100644 --- a/docs/ai-agent-integration.md +++ b/docs/ai-agent-integration.md @@ -37,27 +37,14 @@ The patch is partial: controls not listed keep their current state. The runtime still performs profile normalization, safety checks, replay logging, and one adapter state-sync message. -For safer model-facing control, prefer action maps: +For safer model-facing control, use named action maps. Run the canonical +hold-and-release example from the repository root: -```ts -const actions = createActionMap(controller, { - interact: [{ type: "press", button: "A", durationMs: 100 }], - holdBlock: [{ type: "setButton", button: "LB", pressed: true }], - combatTick: [ - { - type: "setState", - state: { - buttons: { LB: true }, - triggers: { RT: 0.25 }, - sticks: { LEFT: { x: 0.5, y: 0 } } - } - } - ], - releaseBlock: [{ type: "setButton", button: "LB", pressed: false }], - stop: [{ type: "neutral" }] -}); - -await actions.run("interact"); +```bash +bun run examples/basic-dry-run/ai-agent-integration.ts ``` +The [runnable source](../examples/basic-dry-run/ai-agent-integration.ts) holds +and releases a button through named actions. CI executes that same source and +checks both states and replay intents with `bun run check:dry-run-examples`. Action maps keep model output constrained to named, reviewed behaviors. diff --git a/docs/getting-started.md b/docs/getting-started.md index a14eeb5..9c8d58c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,33 +8,17 @@ bun test bun run build ``` -Create a dry-run controller: +Run the standalone Getting Started dry-run example from the repository root: -```ts -import { createController } from "@opencontroller/core"; - -const controller = await createController({ - profile: "xbox", - adapter: "dry-run" -}); - -await controller.press("A", 100); -await controller.press("RT", { durationMs: 120, pressure: 0.35 }); -await controller.moveStick("LEFT", { x: 0, y: -1 }, 300); -await controller.setButton("LB", true); -await controller.setTrigger("RT", 0.25); -await controller.setDpad("UP_RIGHT"); -await controller.setState({ - buttons: { LB: true }, - triggers: { RT: 0.1 }, - sticks: { LEFT: { x: 0.4, y: 0 } }, - dpad: "NEUTRAL" -}); -await controller.setButton("LB", false); -await controller.neutral(); -await controller.disconnect(); +```bash +bun run examples/basic-dry-run/getting-started.ts ``` +The [runnable source](../examples/basic-dry-run/getting-started.ts) creates a +dry-run controller, applies a button, trigger, and stick state patch, then +neutralizes and disconnects. CI runs this same source and checks its state and +replay output with `bun run check:dry-run-examples`. + Dry-run is the default adapter because it requires no native permissions and still updates state, safety, and replay logs. diff --git a/examples/basic-dry-run/ai-agent-integration.ts b/examples/basic-dry-run/ai-agent-integration.ts new file mode 100644 index 0000000..46c9fa1 --- /dev/null +++ b/examples/basic-dry-run/ai-agent-integration.ts @@ -0,0 +1,29 @@ +import { randomUUID } from "node:crypto"; +import type { ControllerState } from "@opencontroller/core"; +import { createActionMap, createController } from "@opencontroller/core"; + +export async function runAgentActionMapExample( + replayDir = `replays/ai-agent-action-map-${randomUUID()}`, +): Promise<{ heldState: ControllerState; releasedState: ControllerState }> { + const controller = await createController({ + profile: "xbox", + adapter: "dry-run", + replay: { dir: replayDir, source: "ai-agent-action-map" }, + }); + const actions = createActionMap(controller, { + holdBlock: [{ type: "setButton", button: "LB", pressed: true }], + releaseBlock: [{ type: "setButton", button: "LB", pressed: false }], + }); + + await actions.run("holdBlock"); + const heldState = controller.getState(); + await actions.run("releaseBlock"); + const releasedState = controller.getState(); + + await controller.disconnect(); + return { heldState, releasedState }; +} + +if (import.meta.main) { + console.log(await runAgentActionMapExample()); +} diff --git a/examples/basic-dry-run/check.ts b/examples/basic-dry-run/check.ts new file mode 100644 index 0000000..2f98968 --- /dev/null +++ b/examples/basic-dry-run/check.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runAgentActionMapExample } from "./ai-agent-integration"; +import { runGettingStartedDryRun } from "./getting-started"; +import { runBasicDryRunExample } from "./index"; + +type ReplayCommand = { + type: "command"; + command: { type: string; button?: string; intent?: string }; + intent?: string; + stateAfter?: { + buttons: Record; + analogButtons: Record; + sticks: { left: { x: number; y: number } }; + }; +}; + +type ReplayState = { + type: "state"; + state?: { connected: boolean }; +}; + +async function readEvents( + replayDir: string, + filename: "commands.jsonl" | "events.jsonl", +): Promise { + const contents = await readFile(join(replayDir, filename), "utf8"); + return contents + .trim() + .split("\n") + .map((line) => JSON.parse(line) as T); +} + +const replayRoot = await mkdtemp( + join(tmpdir(), "opencontroller-dry-run-docs-"), +); + +try { + const gettingStartedDir = join(replayRoot, "getting-started"); + const gettingStartedState = await runGettingStartedDryRun(gettingStartedDir); + assert.equal(gettingStartedState.buttons.A, true); + assert.equal(gettingStartedState.analogButtons.RT, 0.25); + assert.deepEqual(gettingStartedState.sticks.left, { x: 0.4, y: 0 }); + const gettingStartedCommands = await readEvents( + gettingStartedDir, + "commands.jsonl", + ); + assert.equal(gettingStartedCommands[0]?.command.type, "setState"); + assert.equal(gettingStartedCommands[0]?.stateAfter?.buttons.A, true); + assert.equal(gettingStartedCommands[0]?.stateAfter?.analogButtons.RT, 0.25); + const neutralCommand = gettingStartedCommands.find( + (event) => event.command.type === "neutral", + ); + assert.ok(neutralCommand); + assert.equal(neutralCommand.stateAfter?.buttons.A, false); + const gettingStartedStates = await readEvents( + gettingStartedDir, + "events.jsonl", + ); + assert.equal(gettingStartedStates.at(-1)?.state?.connected, false); + + const agentDir = join(replayRoot, "agent-action-map"); + const { heldState, releasedState } = await runAgentActionMapExample(agentDir); + assert.equal(heldState.buttons.LB, true); + assert.equal(releasedState.buttons.LB, false); + const agentCommands = await readEvents( + agentDir, + "commands.jsonl", + ); + assert.deepEqual( + agentCommands.slice(0, 2).map((event) => event.intent), + ["holdBlock", "releaseBlock"], + ); + assert.equal(agentCommands[0]?.command.button, "LB"); + + const basicDir = join(replayRoot, "basic"); + const basicState = await runBasicDryRunExample(basicDir); + assert.equal(basicState.buttons.B, false); + assert.equal(basicState.sticks.left.y, 0); + const basicCommands = await readEvents( + basicDir, + "commands.jsonl", + ); + assert.ok(basicCommands.some((event) => event.intent === "interact")); + assert.ok(basicCommands.some((event) => event.intent === "move_forward")); + assert.ok( + basicCommands.some( + (event) => event.intent === "dodge" && event.command.button === "B", + ), + ); + + console.log("Dry-run documentation examples updated state and replay logs."); +} finally { + await rm(replayRoot, { recursive: true, force: true }); +} diff --git a/examples/basic-dry-run/getting-started.ts b/examples/basic-dry-run/getting-started.ts new file mode 100644 index 0000000..0dcf8d7 --- /dev/null +++ b/examples/basic-dry-run/getting-started.ts @@ -0,0 +1,28 @@ +import { randomUUID } from "node:crypto"; +import type { ControllerState } from "@opencontroller/core"; +import { createController } from "@opencontroller/core"; + +export async function runGettingStartedDryRun( + replayDir = `replays/getting-started-dry-run-${randomUUID()}`, +): Promise { + const controller = await createController({ + profile: "xbox", + adapter: "dry-run", + replay: { dir: replayDir, source: "getting-started-dry-run" }, + }); + + await controller.setState({ + buttons: { A: true }, + triggers: { RT: 0.25 }, + sticks: { LEFT: { x: 0.4, y: 0 } }, + }); + const state = controller.getState(); + + await controller.neutral(); + await controller.disconnect(); + return state; +} + +if (import.meta.main) { + console.log(await runGettingStartedDryRun()); +} diff --git a/examples/basic-dry-run/index.ts b/examples/basic-dry-run/index.ts index a76bed5..d0fcd17 100644 --- a/examples/basic-dry-run/index.ts +++ b/examples/basic-dry-run/index.ts @@ -1,26 +1,36 @@ +import { randomUUID } from "node:crypto"; import { createActionMap, createController, xboxActionPreset, } from "@opencontroller/core"; -const controller = await createController({ - profile: "xbox", - adapter: "dry-run", - replay: { - source: "basic-dry-run-example", - }, -}); +export async function runBasicDryRunExample( + replayDir = `replays/basic-dry-run-example-${randomUUID()}`, +) { + const controller = await createController({ + profile: "xbox", + adapter: "dry-run", + replay: { + dir: replayDir, + source: "basic-dry-run-example", + }, + }); -const actions = createActionMap(controller, xboxActionPreset); + const actions = createActionMap(controller, xboxActionPreset); -await controller.press("A", 100, { intent: "interact" }); -await controller.moveStick("LEFT", { x: 0, y: -1 }, 250, { - intent: "move_forward", -}); -await actions.run("dodge"); -await controller.neutral(); + await controller.press("A", 100, { intent: "interact" }); + await controller.moveStick("LEFT", { x: 0, y: -1 }, 250, { + intent: "move_forward", + }); + await actions.run("dodge"); + const state = controller.getState(); + await controller.neutral(); -console.log(controller.getState()); + await controller.disconnect(); + return state; +} -await controller.disconnect(); +if (import.meta.main) { + console.log(await runBasicDryRunExample()); +} diff --git a/examples/basic-dry-run/tsconfig.json b/examples/basic-dry-run/tsconfig.json new file mode 100644 index 0000000..b4f1282 --- /dev/null +++ b/examples/basic-dry-run/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "composite": true + }, + "include": ["*.ts"], + "references": [ + { + "path": "../../packages/core" + } + ] +} diff --git a/package.json b/package.json index d3679c6..eac70cf 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dev:fighter": "bun --cwd examples/agent-fighter dev", "dev:overlay": "bun --cwd examples/obs-overlay dev", "test": "bun test packages/core/src/__tests__ packages/cli/src/__tests__ packages/native/src/__tests__ packages/native-linux-uinput/src/__tests__ packages/native-windows-virtual-gamepad/src/__tests__ packages/native-macos-driverkit/src/__tests__", + "check:dry-run-examples": "bun run examples/basic-dry-run/check.ts", "typecheck": "tsc -b", "pack:check": "node scripts/check-npm-packages.mjs", "npm:status": "node scripts/check-npm-registry.mjs", diff --git a/tsconfig.json b/tsconfig.json index 14cbbdb..ffbe919 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "./examples/agent-fighter" + }, + { + "path": "./examples/basic-dry-run" } ] }