Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
27 changes: 7 additions & 20 deletions docs/ai-agent-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
32 changes: 8 additions & 24 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 29 additions & 0 deletions examples/basic-dry-run/ai-agent-integration.ts
Original file line number Diff line number Diff line change
@@ -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());
}
97 changes: 97 additions & 0 deletions examples/basic-dry-run/check.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
analogButtons: Record<string, number>;
sticks: { left: { x: number; y: number } };
};
};

type ReplayState = {
type: "state";
state?: { connected: boolean };
};

async function readEvents<T>(
replayDir: string,
filename: "commands.jsonl" | "events.jsonl",
): Promise<T[]> {
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<ReplayCommand>(
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<ReplayState>(
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<ReplayCommand>(
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<ReplayCommand>(
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 });
}
28 changes: 28 additions & 0 deletions examples/basic-dry-run/getting-started.ts
Original file line number Diff line number Diff line change
@@ -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<ControllerState> {
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());
}
42 changes: 26 additions & 16 deletions examples/basic-dry-run/index.ts
Original file line number Diff line number Diff line change
@@ -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());
}
14 changes: 14 additions & 0 deletions examples/basic-dry-run/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"composite": true
},
"include": ["*.ts"],
"references": [
{
"path": "../../packages/core"
}
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
},
{
"path": "./examples/agent-fighter"
},
{
"path": "./examples/basic-dry-run"
}
]
}
Loading