@crewhaus/target-onchain-game 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +184 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/target-onchain-game",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Codegen for the §47 `onchain-game` perceive-act-perceive loop. Emits a daemon that reads game state, asks the model for a move, broadcasts the move, awaits confirmation, and repeats.",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0",
|
|
16
|
+
"@crewhaus/infra-utils": "0.0.0",
|
|
17
|
+
"@crewhaus/ir": "0.0.0"
|
|
18
|
+
},
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "Max Meier",
|
|
22
|
+
"email": "max@studiomax.io",
|
|
23
|
+
"url": "https://studiomax.io"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
28
|
+
"directory": "packages/target-onchain-game"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/target-onchain-game#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "restricted"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { IrChainGameV0 } from "@crewhaus/ir";
|
|
3
|
+
import { TargetEmitError, emitOnchainGame } from "./index";
|
|
4
|
+
|
|
5
|
+
function baseIr(overrides: Partial<IrChainGameV0> = {}): IrChainGameV0 {
|
|
6
|
+
return {
|
|
7
|
+
version: 0,
|
|
8
|
+
name: "tic-tac-toe-agent",
|
|
9
|
+
target: "onchain-game",
|
|
10
|
+
agent: { model: "claude-opus-4-7", instructions: "Play optimal moves." },
|
|
11
|
+
chain: {
|
|
12
|
+
id: "base-sepolia",
|
|
13
|
+
kind: "evm",
|
|
14
|
+
rpcUrls: [{ kind: "env", name: "BASE_SEPOLIA_RPC" }],
|
|
15
|
+
rpcPolicy: "single",
|
|
16
|
+
finality: { kind: "confirmations", count: 1 },
|
|
17
|
+
reorgTolerant: true,
|
|
18
|
+
},
|
|
19
|
+
wallet: {
|
|
20
|
+
id: "player",
|
|
21
|
+
chainId: "base-sepolia",
|
|
22
|
+
custody: "local",
|
|
23
|
+
signingPolicy: "automated",
|
|
24
|
+
keyRef: { kind: "env", name: "PLAYER_KEY" },
|
|
25
|
+
},
|
|
26
|
+
game: {
|
|
27
|
+
contract: {
|
|
28
|
+
id: "tictactoe",
|
|
29
|
+
chainId: "base-sepolia",
|
|
30
|
+
address: "0xgame",
|
|
31
|
+
abiRef: "abi://tictactoe",
|
|
32
|
+
},
|
|
33
|
+
stateReader: "9af14a16",
|
|
34
|
+
turnSemantics: "turn-based",
|
|
35
|
+
objective: "Win or draw — never lose.",
|
|
36
|
+
},
|
|
37
|
+
transactionPolicy: {
|
|
38
|
+
defaultWriteApproval: "policy",
|
|
39
|
+
allowedContracts: ["tictactoe"],
|
|
40
|
+
simulationRequired: true,
|
|
41
|
+
},
|
|
42
|
+
tools: [],
|
|
43
|
+
toolConfigs: {},
|
|
44
|
+
mcp_servers: {},
|
|
45
|
+
permissions: { rules: [] },
|
|
46
|
+
compaction: {},
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("emitOnchainGame — happy path", () => {
|
|
52
|
+
test("emits a single agent.ts with game metadata", () => {
|
|
53
|
+
const bundle = emitOnchainGame(baseIr());
|
|
54
|
+
expect(bundle.files).toHaveLength(1);
|
|
55
|
+
const file = bundle.files[0];
|
|
56
|
+
expect(file?.path).toBe("agent.ts");
|
|
57
|
+
expect(file?.content).toContain('SPEC_NAME = "tic-tac-toe-agent"');
|
|
58
|
+
expect(file?.content).toContain('AGENT_MODEL = "claude-opus-4-7"');
|
|
59
|
+
expect(file?.content).toContain('AGENT_INSTRUCTIONS = "Play optimal moves."');
|
|
60
|
+
expect(file?.content).toContain('chainId: "base-sepolia"');
|
|
61
|
+
expect(file?.content).toContain('id: "tictactoe"');
|
|
62
|
+
expect(file?.content).toContain('stateReader: "9af14a16"');
|
|
63
|
+
expect(file?.content).toContain('turnSemantics: "turn-based"');
|
|
64
|
+
expect(file?.content).toContain('objective: "Win or draw');
|
|
65
|
+
expect(file?.content).toContain("createEvmAdapter");
|
|
66
|
+
expect(file?.content).toContain("classifyBoundary");
|
|
67
|
+
expect(file?.content).toContain('origin: "chain"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("wallet keyRef env-secret renders as process.env lookup", () => {
|
|
71
|
+
const bundle = emitOnchainGame(baseIr());
|
|
72
|
+
const c = bundle.files[0]?.content ?? "";
|
|
73
|
+
expect(c).toContain('process.env["PLAYER_KEY"]');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("renders the transaction_policy literal", () => {
|
|
77
|
+
const bundle = emitOnchainGame(baseIr());
|
|
78
|
+
const c = bundle.files[0]?.content ?? "";
|
|
79
|
+
expect(c).toContain('"allowedContracts":["tictactoe"]');
|
|
80
|
+
expect(c).toContain('"simulationRequired":true');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("emitOnchainGame — validation", () => {
|
|
85
|
+
test("rejects when wallet.chainId differs from chain.id", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
emitOnchainGame(
|
|
88
|
+
baseIr({
|
|
89
|
+
wallet: {
|
|
90
|
+
id: "player",
|
|
91
|
+
chainId: "polygon-mainnet",
|
|
92
|
+
custody: "local",
|
|
93
|
+
signingPolicy: "automated",
|
|
94
|
+
keyRef: { kind: "env", name: "PLAYER_KEY" },
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
).toThrow(TargetEmitError);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects when game.contract.chainId differs from chain.id", () => {
|
|
102
|
+
const ir = baseIr();
|
|
103
|
+
expect(() =>
|
|
104
|
+
emitOnchainGame({
|
|
105
|
+
...ir,
|
|
106
|
+
game: {
|
|
107
|
+
...ir.game,
|
|
108
|
+
contract: { ...ir.game.contract, chainId: "polygon-mainnet" },
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
).toThrow(TargetEmitError);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects real-time games without moveTimeoutMs", () => {
|
|
115
|
+
const ir = baseIr();
|
|
116
|
+
expect(() =>
|
|
117
|
+
emitOnchainGame({
|
|
118
|
+
...ir,
|
|
119
|
+
game: { ...ir.game, turnSemantics: "real-time" },
|
|
120
|
+
}),
|
|
121
|
+
).toThrow(/moveTimeoutMs/);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog F2 `target-onchain-game` — §47.
|
|
3
|
+
*
|
|
4
|
+
* Codegen for the `onchain-game` perceive-act-perceive loop. The
|
|
5
|
+
* emitted bundle is a single `agent.ts` file that:
|
|
6
|
+
*
|
|
7
|
+
* 1. Constructs the chain adapter from `chain` (slice-0
|
|
8
|
+
* `@crewhaus/chain-adapter-evm` is the only adapter shipped
|
|
9
|
+
* today).
|
|
10
|
+
* 2. Constructs a wallet engine bound to the single player wallet.
|
|
11
|
+
* 3. Defines a `playOneTurn()` function that:
|
|
12
|
+
* a. Reads game state via `game.stateReader` (a view function).
|
|
13
|
+
* b. Classifies the raw state payload via
|
|
14
|
+
* `classifyBoundary({origin: "chain"})`.
|
|
15
|
+
* c. Calls the agent with the state + objective in the user
|
|
16
|
+
* message (one `runChatLoop({singleTurn: true})` call).
|
|
17
|
+
* d. Parses the agent's terminal output for a move action and
|
|
18
|
+
* dispatches via the wallet-engine
|
|
19
|
+
* `requestSignAndBroadcast()` flow.
|
|
20
|
+
* e. Waits for confirmation up to `moveTimeoutMs` (real-time)
|
|
21
|
+
* or indefinitely (turn-based / async).
|
|
22
|
+
* 4. For `turn-based`: loops `playOneTurn()` until the agent
|
|
23
|
+
* reports the objective met or the move count hits a configured
|
|
24
|
+
* ceiling. For `real-time`: drives the loop on a fixed cadence.
|
|
25
|
+
* For `async`: subscribes to a state-change event and runs one
|
|
26
|
+
* turn per inbound state mutation.
|
|
27
|
+
* 5. Emits JSON events for turn-start / turn-end / move-broadcast /
|
|
28
|
+
* move-confirmed / game-over.
|
|
29
|
+
*
|
|
30
|
+
* Slice 2 ships the codegen surface — the emitted file references
|
|
31
|
+
* runtime packages by name. A follow-up slice wires the agent run
|
|
32
|
+
* loop end-to-end against an anvil fork.
|
|
33
|
+
*/
|
|
34
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
35
|
+
import { escapeJsonString } from "@crewhaus/infra-utils";
|
|
36
|
+
import type { Bundle, IrChainGameV0, IrSecretRef } from "@crewhaus/ir";
|
|
37
|
+
|
|
38
|
+
export class TargetEmitError extends CrewhausError {
|
|
39
|
+
override readonly name = "TargetEmitError";
|
|
40
|
+
constructor(message: string, cause?: unknown) {
|
|
41
|
+
super("compiler", message, cause);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderSecretRef(ref: IrSecretRef): string {
|
|
46
|
+
if (ref.kind === "literal") return JSON.stringify(ref.value);
|
|
47
|
+
return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(
|
|
48
|
+
`missing required env var ${ref.name}`,
|
|
49
|
+
)}); })()`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function emitOnchainGame(ir: IrChainGameV0): Bundle {
|
|
53
|
+
if (ir.chain.id !== ir.wallet.chainId) {
|
|
54
|
+
throw new TargetEmitError(
|
|
55
|
+
`wallet.chainId "${ir.wallet.chainId}" does not match chain.id "${ir.chain.id}"`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (ir.chain.id !== ir.game.contract.chainId) {
|
|
59
|
+
throw new TargetEmitError(
|
|
60
|
+
`game.contract.chainId "${ir.game.contract.chainId}" does not match chain.id "${ir.chain.id}"`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (ir.game.turnSemantics === "real-time" && ir.game.moveTimeoutMs === undefined) {
|
|
64
|
+
throw new TargetEmitError(
|
|
65
|
+
"real-time games require game.moveTimeoutMs so the loop can bound per-move spend",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rpcs = ir.chain.rpcUrls.map(renderSecretRef).join(", ");
|
|
70
|
+
const finality =
|
|
71
|
+
ir.chain.finality.kind === "confirmations"
|
|
72
|
+
? `{ kind: "confirmations", count: ${ir.chain.finality.count} }`
|
|
73
|
+
: `{ kind: ${JSON.stringify(ir.chain.finality.kind)} }`;
|
|
74
|
+
|
|
75
|
+
const policy = ir.transactionPolicy;
|
|
76
|
+
const policyLiteral = JSON.stringify({
|
|
77
|
+
defaultWriteApproval: policy.defaultWriteApproval,
|
|
78
|
+
allowedContracts: policy.allowedContracts,
|
|
79
|
+
simulationRequired: policy.simulationRequired,
|
|
80
|
+
...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const walletKeyRef =
|
|
84
|
+
ir.wallet.keyRef !== undefined ? `, keyRef: ${renderSecretRef(ir.wallet.keyRef)}` : "";
|
|
85
|
+
|
|
86
|
+
const objectiveField =
|
|
87
|
+
ir.game.objective !== undefined ? `\n objective: ${escapeJsonString(ir.game.objective)},` : "";
|
|
88
|
+
const moveTimeoutField =
|
|
89
|
+
ir.game.moveTimeoutMs !== undefined ? `\n moveTimeoutMs: ${ir.game.moveTimeoutMs},` : "";
|
|
90
|
+
const actionsContractField =
|
|
91
|
+
ir.game.actionsContract !== undefined
|
|
92
|
+
? `\n actionsContract: ${JSON.stringify(ir.game.actionsContract)},`
|
|
93
|
+
: "";
|
|
94
|
+
|
|
95
|
+
const instructions = escapeJsonString(ir.agent.instructions);
|
|
96
|
+
const name = escapeJsonString(ir.name);
|
|
97
|
+
|
|
98
|
+
const content = `/**
|
|
99
|
+
* Generated by @crewhaus/target-onchain-game (§47 onchain-game).
|
|
100
|
+
* Compiled from spec: ${ir.name}
|
|
101
|
+
* DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
|
|
102
|
+
*/
|
|
103
|
+
import { classifyBoundary } from "@crewhaus/boundary-classifier";
|
|
104
|
+
import { createEvmAdapter } from "@crewhaus/chain-adapter-evm";
|
|
105
|
+
|
|
106
|
+
export const SPEC_NAME = ${name};
|
|
107
|
+
export const AGENT_MODEL = ${JSON.stringify(ir.agent.model)};
|
|
108
|
+
export const AGENT_INSTRUCTIONS = ${instructions};
|
|
109
|
+
|
|
110
|
+
export const CHAIN = {
|
|
111
|
+
chainId: ${JSON.stringify(ir.chain.id)},
|
|
112
|
+
rpcUrls: [${rpcs}],
|
|
113
|
+
rpcPolicy: ${JSON.stringify(ir.chain.rpcPolicy)},
|
|
114
|
+
finality: ${finality},
|
|
115
|
+
reorgTolerant: ${ir.chain.reorgTolerant},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const WALLET = {
|
|
119
|
+
id: ${JSON.stringify(ir.wallet.id)},
|
|
120
|
+
chainId: ${JSON.stringify(ir.wallet.chainId)},
|
|
121
|
+
custody: ${JSON.stringify(ir.wallet.custody)},
|
|
122
|
+
signingPolicy: ${JSON.stringify(ir.wallet.signingPolicy)}${walletKeyRef},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const GAME = {
|
|
126
|
+
contract: {
|
|
127
|
+
id: ${JSON.stringify(ir.game.contract.id)},
|
|
128
|
+
chainId: ${JSON.stringify(ir.game.contract.chainId)},
|
|
129
|
+
address: ${JSON.stringify(ir.game.contract.address)},
|
|
130
|
+
abiRef: ${JSON.stringify(ir.game.contract.abiRef)},
|
|
131
|
+
},
|
|
132
|
+
stateReader: ${JSON.stringify(ir.game.stateReader)},
|
|
133
|
+
turnSemantics: ${JSON.stringify(ir.game.turnSemantics)},${actionsContractField}${moveTimeoutField}${objectiveField}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const TRANSACTION_POLICY = ${policyLiteral};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build the chain adapter (one per game; games are single-chain). The
|
|
140
|
+
* runtime reads CHAIN at boot, constructs the adapter, and feeds it
|
|
141
|
+
* into the wallet engine + the tool catalog.
|
|
142
|
+
*/
|
|
143
|
+
export function buildAdapter(): ReturnType<typeof createEvmAdapter> {
|
|
144
|
+
return createEvmAdapter(CHAIN);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Defense-in-depth: classify the raw game state payload before
|
|
149
|
+
* injecting into the model's user message. The chain adapter already
|
|
150
|
+
* classified the RPC envelope; this catches any inner content.
|
|
151
|
+
*/
|
|
152
|
+
export async function readAndClassifyState(adapter: ReturnType<typeof createEvmAdapter>): Promise<{
|
|
153
|
+
accept: boolean;
|
|
154
|
+
state: string;
|
|
155
|
+
}> {
|
|
156
|
+
const raw = await adapter.rpcRead("eth_call", [
|
|
157
|
+
{ to: GAME.contract.address, data: "0x" + selectorOf(GAME.stateReader) },
|
|
158
|
+
"latest",
|
|
159
|
+
]);
|
|
160
|
+
const text = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
161
|
+
const verdict = await classifyBoundary(text, { origin: "chain" });
|
|
162
|
+
if (verdict.action === "redact") {
|
|
163
|
+
return { accept: false, state: verdict.redacted ?? "[game state redacted]" };
|
|
164
|
+
}
|
|
165
|
+
return { accept: true, state: text };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute the 4-byte selector for an ABI method name. Slice 2 ships
|
|
170
|
+
* a stub that errors — the real keccak-256 lives in a follow-up
|
|
171
|
+
* (alongside the calldata-encoder slot in tool-contract-gateway).
|
|
172
|
+
* Until then, the spec author can pre-compute the selector and pass
|
|
173
|
+
* it as the stateReader value (e.g. "70a08231").
|
|
174
|
+
*/
|
|
175
|
+
function selectorOf(method: string): string {
|
|
176
|
+
if (/^[0-9a-fA-F]{8}$/.test(method)) return method.toLowerCase();
|
|
177
|
+
throw new Error(
|
|
178
|
+
\`selectorOf: pass a precomputed 4-byte hex selector for "\${method}" until the keccak helper ships\`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
return { files: [{ path: "agent.ts", content }] };
|
|
184
|
+
}
|