diff --git a/harness/adapters/melvor/README.md b/harness/adapters/melvor/README.md new file mode 100644 index 0000000..04e9be3 --- /dev/null +++ b/harness/adapters/melvor/README.md @@ -0,0 +1,87 @@ +# harness/adapters/melvor/ — the Melvor Idle adapter + +A `Bridge` over a running **Melvor Idle** instance (`TASKS.md` T2). +It implements the substrate-agnostic contract from `../../bridge/` for Melvor: +`observe()` snapshots the game; `act()` performs a skill action. Game specifics +live here and never leak back into `bridge/`. + +``` +melvor/ +├── melvor-bridge.ts MelvorBridge — observe()/act(); MelvorState + MelvorGameApi (the port) +├── melvor-mod.ts mod glue — binds the in-process `game` global to the port; createMelvorBridge() +├── index.ts public surface +└── melvor-bridge.test.ts +``` + +The shape mirrors `../../bridge/synthetic-bridge.ts`: a per-affordance switch in +`act()` and a snapshot in `observe()`. The difference is the world is **external** +(a live game), so the bridge holds a reference to the game rather than owning +in-memory state. + +## Perception & affordances + +`observe()` returns a `Perception`: + +- `state.skills` — every skill with `id` / `name` / `level` / `xp`. +- `state.activeActionId` — the one running action, or `null` when idle. +- `affordances` — one `train:` per skill (the target is in the id, so an + `Action` needs no params), plus `stop`. This is the menu the autotelic selector + chooses from. + +`act()` accepts `train:` (→ start that skill) or `stop`; anything else is +`rejected`. `observedAt` is the wall clock (Melvor is real-time, unlike the +synthetic fixture's logical tick). + +## Act path: mod API (in-process), not CDP — decided by ADR-0006 + +The act path was **already chosen at the architectural level by `adr-0006`** +(polyglot: TS harness, Python cognition). That ADR picked the TypeScript harness +*precisely so the adapter reads the `game` global in-process*, and explicitly +rejected single-language Python because it "forces the Melvor adapter through +Playwright / CDP `eval`, making `Perception` extraction … slower and more +brittle than an in-process mod." So **no new ADR is filed** for T2 — this is the +ADR's decision, applied: + +| | In-process mod (chosen) | CDP / headless-Chromium (rejected for perception) | +|---|---|---| +| Read | direct property access on `game` — no serialization | `Runtime.evaluate` round-trip returning a serialized snapshot | +| Latency | none (same process) | one round-trip per read | +| Fidelity | full object graph in scope | deep/cyclic game objects serialize poorly | + +CDP / OpenClaw stays relevant for the **operator gateway** (an orthogonal +concern; `exploration/prior-art-l2-l3.md`), not for perception. + +The L2 **runtime** choice (ElizaOS-core / OpenClaw-gateway / Hermes-fallback lean) +is *not* made here — T2 only needs the Bridge contract. It is committed via its +own ADR when the loop needs it, **deferred to T5** (issue #25). + +## Shared-avatar concurrency — surfaced, not solved + +Melvor is **single-avatar**: one active action on one save. If the operator and +the agent both drive the same instance, their `act()` calls race on that single +slot (start-skill is last-writer-wins), and the agent's next `observe()` reports +an `activeActionId` it did not set. This adapter does **not** arbitrate that — +no lock, no turn-taking, no operator/agent identity on `Action`. + +Per `adr-0007`, the dyad's *operator-acts-in-world* half is staged to **Stardew +(P1)**, whose native co-op gives separate avatars in one world and sidesteps the +shared-avatar race entirely. Melvor (P0) proves only the agent-side loop. +`SyntheticBridge.operatorAct()` is where dual-control is exercised +deterministically in the meantime. + +## IP boundary — mod/bridge code only (AGENTS.md rule 4 · adr-0003) + +The Melvor game body is **never vendored**. The interfaces in `melvor-mod.ts` +declare only the *shape* of the small in-process surface we bind to (clean-room); +they are not copied from Melvor's source. Concrete field/method names are +confirmed against the live `game` object at integration (the implementer runs a +real Melvor + its mod toolchain — issue #25 preconditions). All game-coupling is +isolated in `melvor-mod.ts`, so a name change touches one file. + +## Loading & testing + +- **In Melvor:** load the mod in-page and call `createMelvorBridge()`, which reads + `globalThis.game`. It throws off-page (e.g. plain Node) — the adapter only + works in-process. +- **Tests:** a fake stands in for `game` (no Melvor needed). Run from `harness/`: + `npm run ci` (typecheck + tests + 100% coverage of the runtime surface). diff --git a/harness/adapters/melvor/index.ts b/harness/adapters/melvor/index.ts new file mode 100644 index 0000000..9d7b03f --- /dev/null +++ b/harness/adapters/melvor/index.ts @@ -0,0 +1,5 @@ +// Public surface of the Melvor adapter. +export { MelvorBridge } from "./melvor-bridge"; +export type { MelvorState, MelvorSkillView, MelvorGameApi } from "./melvor-bridge"; +export { bindMelvorGlobal, createMelvorBridge } from "./melvor-mod"; +export type { MelvorGlobal, MelvorSkillObject, MelvorActiveAction } from "./melvor-mod"; diff --git a/harness/adapters/melvor/melvor-bridge.test.ts b/harness/adapters/melvor/melvor-bridge.test.ts new file mode 100644 index 0000000..a0f3702 --- /dev/null +++ b/harness/adapters/melvor/melvor-bridge.test.ts @@ -0,0 +1,162 @@ +// Tests for the Melvor adapter. Melvor itself is never loaded — a fake stands in +// for the in-process `game` global (issue #25: "tests as feasible — mock the +// game object"). Covers schema conformance, ≥1 real skill action through to a +// changed Perception, every rejection path, the mod glue binder, and the +// entrypoint's game-present / game-absent branches. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import { MelvorBridge } from "./melvor-bridge"; +import { bindMelvorGlobal, createMelvorBridge } from "./melvor-mod"; +import type { MelvorGlobal, MelvorSkillObject, MelvorActiveAction } from "./melvor-mod"; + +const here = dirname(fileURLToPath(import.meta.url)); +const schema = (name: string): object => + JSON.parse(readFileSync(join(here, "..", "..", "bridge", "schema", name), "utf8")) as object; + +const ajv = new Ajv2020({ strict: true, allErrors: true }); +const validatePerception = ajv.compile(schema("perception.schema.json")); +const validateActionResult = ajv.compile(schema("action-result.schema.json")); + +// A minimal fake of the in-process `game` global: one wired skill (Woodcutting, +// has a start hook) and one unwired skill (Fishing, no start hook). Starting +// Woodcutting sets the active action; stopping it clears it. +function makeFakeGame(): MelvorGlobal { + let active: MelvorActiveAction | null = null; + const woodcutting: MelvorSkillObject = { + id: "melvorD:Woodcutting", + name: "Woodcutting", + level: 1, + xp: 0, + start: () => { + active = { id: "melvorD:Woodcutting", stop: () => (active = null) }; + return true; + }, + }; + const fishing: MelvorSkillObject = { + id: "melvorD:Fishing", + name: "Fishing", + level: 1, + xp: 0, + // intentionally no start hook — an "unwired" skill + }; + const skills = [woodcutting, fishing]; + return { + skills: { + get allObjects() { + return skills; + }, + }, + get activeAction() { + return active; + }, + }; +} + +test("observe() returns a populated Perception that conforms to the canonical schema", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + const p = await bridge.observe(); + + assert.ok(validatePerception(p), ajv.errorsText(validatePerception.errors)); + assert.equal(p.substrate, "melvor"); + assert.ok(Number.isInteger(p.observedAt) && p.observedAt >= 0); + assert.equal(p.state.activeActionId, null); // idle to start + assert.deepEqual( + p.state.skills.map((s) => s.id), + ["melvorD:Woodcutting", "melvorD:Fishing"], + ); + // one "train:" affordance per skill, plus the stop affordance + assert.equal(p.affordances.length, 3); + assert.deepEqual( + p.affordances.map((a) => a.id), + ["train:melvorD:Woodcutting", "train:melvorD:Fishing", "stop"], + ); +}); + +test("act() performs a skill action and the next observe() reflects it", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + + const r = await bridge.act({ + affordance: "train:melvorD:Woodcutting", + reason: "what does it feel like to gather wood rather than grind?", + }); + assert.ok(validateActionResult(r), ajv.errorsText(validateActionResult.errors)); + assert.equal(r.status, "ok"); + assert.equal((await bridge.observe()).state.activeActionId, "melvorD:Woodcutting"); + + const stop = await bridge.act({ affordance: "stop" }); + assert.equal(stop.status, "ok"); + assert.equal((await bridge.observe()).state.activeActionId, null); +}); + +test("stop on an idle world is an accepted no-op", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + const r = await bridge.act({ affordance: "stop" }); + assert.equal(r.status, "ok"); + assert.equal((await bridge.observe()).state.activeActionId, null); +}); + +test("training an unwired skill is rejected, leaving the world idle", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + const r = await bridge.act({ affordance: "train:melvorD:Fishing" }); + assert.equal(r.status, "rejected"); + assert.match(r.detail ?? "", /cannot start skill: melvorD:Fishing/); + assert.equal((await bridge.observe()).state.activeActionId, null); +}); + +test("training an unknown skill id is rejected", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + const r = await bridge.act({ affordance: "train:melvorD:Nope" }); + assert.equal(r.status, "rejected"); + assert.match(r.detail ?? "", /cannot start skill: melvorD:Nope/); +}); + +test("an affordance the adapter does not recognize is rejected", async () => { + const bridge = new MelvorBridge(bindMelvorGlobal(makeFakeGame())); + const r = await bridge.act({ affordance: "teleport" }); + assert.equal(r.status, "rejected"); + assert.match(r.detail ?? "", /unknown affordance: teleport/); +}); + +test("bindMelvorGlobal reports the active action id when one is running", async () => { + const game = makeFakeGame(); + const api = bindMelvorGlobal(game); + assert.equal(api.activeActionId(), null); + api.startSkill("melvorD:Woodcutting"); + assert.equal(api.activeActionId(), "melvorD:Woodcutting"); + api.stopActive(); + assert.equal(api.activeActionId(), null); +}); + +test("close() resolves", async () => { + await assert.doesNotReject(() => new MelvorBridge(bindMelvorGlobal(makeFakeGame())).close()); +}); + +test("createMelvorBridge builds a bridge from the page's game global", async () => { + const slot = globalThis as { game?: MelvorGlobal }; + const prior = slot.game; + try { + slot.game = makeFakeGame(); + const bridge = createMelvorBridge(); + assert.ok(bridge instanceof MelvorBridge); + assert.equal((await bridge.observe()).substrate, "melvor"); + } finally { + if (prior === undefined) delete slot.game; + else slot.game = prior; + } +}); + +test("createMelvorBridge throws off-page where no game global exists", () => { + const slot = globalThis as { game?: MelvorGlobal }; + const prior = slot.game; + try { + delete slot.game; + assert.throws(() => createMelvorBridge(), /game` global not found/); + } finally { + if (prior !== undefined) slot.game = prior; + } +}); diff --git a/harness/adapters/melvor/melvor-bridge.ts b/harness/adapters/melvor/melvor-bridge.ts new file mode 100644 index 0000000..6e3ec67 --- /dev/null +++ b/harness/adapters/melvor/melvor-bridge.ts @@ -0,0 +1,130 @@ +// MelvorBridge — a Bridge over a running Melvor Idle instance. +// Mirrors the structure of bridge/synthetic-bridge.ts (the worked reference): +// observe() snapshots the world, act() applies one affordance via a private +// switch. The difference is the world is *external* — a live game — so instead +// of owning in-memory state, the bridge holds a reference to the game and reads +// through it on every observe(). +// +// ── Act path: mod API (in-process), NOT CDP / headless-Chromium ─────────────── +// This is already decided at the architectural level by ADR-0006 (polyglot: +// TypeScript harness, Python cognition), which chose the TS harness *precisely +// so the adapter can read the `game` global in-process* and rejected the +// single-language-Python alternative because it "forces the Melvor adapter +// through Playwright / CDP `eval`, making `Perception` extraction from the +// `game` global slower and more brittle than an in-process mod." So no new ADR +// is filed here — this comment records the act-path choice the ADR already made: +// +// - In-process mod: direct property reads off `game` (no serialization, no +// round-trip), synchronous control calls, and the richest perception — the +// whole object graph is in scope. This is the path. +// - CDP / headless-Chromium: every read is a `Runtime.evaluate` round-trip +// returning a serialized snapshot; deep/cyclic game objects serialize +// poorly and each observe() pays latency. Rejected for perception. (CDP/ +// OpenClaw remains a candidate for the *operator gateway*, an orthogonal +// concern — see exploration/prior-art-l2-l3.md.) +// +// ── Runtime (ElizaOS / OpenClaw / Hermes) is NOT chosen here ────────────────── +// T2 is just an adapter implementing the Bridge contract; it does not need the +// L2 runtime selected. The lean (ElizaOS-core + OpenClaw-gateway + Hermes- +// fallback, exploration/prior-art-l2-l3.md) is committed via its own ADR when +// the loop needs it — deferred to T5 per issue #25. +// +// ── Shared-avatar concurrency (SURFACED, not solved) ────────────────────────── +// Melvor is single-avatar: there is exactly one active action on one save. If +// the operator and the agent both drive the same instance, their act() calls +// race on that single slot — start-skill is last-writer-wins, and the agent's +// next observe() will report an activeActionId it did not set (a change it +// didn't cause). This bridge does NOT arbitrate that: there is no lock, no +// turn-taking, no operator/agent identity on Action. Per ADR-0007 the dyad's +// operator-acts-in-world half is staged to Stardew (P1), whose native co-op +// gives separate avatars in one world and sidesteps this shared-avatar race +// entirely. On Melvor (P0) we only prove the agent-side loop. SyntheticBridge +// .operatorAct() is where dual-control is exercised deterministically meanwhile. +// +// ── IP boundary (AGENTS.md rule 4 · ADR-0003) ───────────────────────────────── +// Mod/bridge code only — the Melvor game body is never vendored. The types in +// melvor-mod.ts declare only the *shape* of the small in-process surface we +// bind to (clean-room), not Melvor's source. + +import type { Bridge } from "../../bridge/bridge"; +import type { Perception, ActionSpec } from "../../bridge/perception"; +import type { Action, ActionResult } from "../../bridge/action"; + +/** One skill as seen by the agent. JSON-serializable, like any substrate state. */ +export interface MelvorSkillView { + /** Namespaced skill id, e.g. "melvorD:Woodcutting". */ + id: string; + /** Display name, e.g. "Woodcutting". */ + name: string; + level: number; + xp: number; +} + +/** Melvor's world snapshot — the typed `state` inside a Perception. */ +export interface MelvorState { + /** Id of the currently active action, or null if the avatar is idle. */ + activeActionId: string | null; + /** Every skill, with its current level and xp. */ + skills: MelvorSkillView[]; +} + +/** + * The minimal control+read surface the bridge needs from Melvor, implemented by + * the mod over the in-process `game` global (see `bindMelvorGlobal` in + * melvor-mod.ts). Stated as a port so the bridge logic is unit-testable with a + * fake, and the brittle game-coupling is confined to one documented place. + */ +export interface MelvorGameApi { + /** Snapshot every skill. */ + listSkills(): MelvorSkillView[]; + /** Id of the active action, or null when idle. */ + activeActionId(): string | null; + /** Begin training a skill; false if the game refused (unknown / not wired / reqs unmet). */ + startSkill(skillId: string): boolean; + /** Stop whatever is active; a no-op when already idle. */ + stopActive(): void; +} + +const TRAIN_PREFIX = "train:"; + +export class MelvorBridge implements Bridge { + constructor(private readonly game: MelvorGameApi) {} + + async observe(): Promise> { + const skills = this.game.listSkills(); + const affordances: ActionSpec[] = [ + // One "train this skill" affordance per skill — the menu the autotelic + // selector chooses from. The target skill is encoded in the id so an + // Action needs no params (mirrors the bare-id affordances in synthetic). + ...skills.map((s) => ({ id: `${TRAIN_PREFIX}${s.id}`, label: `Train ${s.name}` })), + { id: "stop", label: "Stop the active action" }, + ]; + return { + substrate: "melvor", + // Melvor is real-time (unlike the synthetic fixture's logical tick), so + // observedAt is the wall clock — an epoch-ms integer per the schema. + observedAt: Date.now(), + state: { activeActionId: this.game.activeActionId(), skills }, + affordances, + }; + } + + async act(action: Action): Promise { + const { affordance } = action; + if (affordance === "stop") { + this.game.stopActive(); + return { status: "ok" }; + } + if (affordance.startsWith(TRAIN_PREFIX)) { + const skillId = affordance.slice(TRAIN_PREFIX.length); + return this.game.startSkill(skillId) + ? { status: "ok" } + : { status: "rejected", detail: `cannot start skill: ${skillId}` }; + } + return { status: "rejected", detail: `unknown affordance: ${affordance}` }; + } + + async close(): Promise { + // In-process: nothing to release (the mod shares the page's lifetime). + } +} diff --git a/harness/adapters/melvor/melvor-mod.ts b/harness/adapters/melvor/melvor-mod.ts new file mode 100644 index 0000000..87aedd4 --- /dev/null +++ b/harness/adapters/melvor/melvor-mod.ts @@ -0,0 +1,83 @@ +// melvor-mod.ts — the mod glue: binds Melvor's in-process `game` global to the +// MelvorGameApi port that MelvorBridge consumes. This is the one place that +// names concrete game members, so all game-coupling (and its integration risk) +// lives here, isolated from the bridge logic. +// +// CLEAN-ROOM (AGENTS.md rule 4 · ADR-0003): the interfaces below declare only +// the *shape* of the small surface we touch — they are not copied from Melvor's +// source, and the game body is never vendored. Field/method names are confirmed +// against the live `game` object at integration time (the implementer runs a +// real Melvor + its mod toolchain; see issue #25 preconditions). Keeping the +// dependency this thin means a name change touches only this file. + +import { MelvorBridge } from "./melvor-bridge"; +import type { MelvorGameApi, MelvorSkillView } from "./melvor-bridge"; + +/** A skill object as exposed on the in-process `game`. Reads only, except `start`. */ +export interface MelvorSkillObject { + /** Namespaced id, e.g. "melvorD:Woodcutting". */ + readonly id: string; + readonly name: string; + readonly level: number; + readonly xp: number; + /** + * The mod's start hook for this skill. Melvor has no single uniform "start a + * skill" entrypoint — each skill begins differently (Woodcutting selects a + * tree, Mining a rock, Fishing an area, …). The mod wires this per skill at + * integration; an unwired skill leaves `start` absent and startSkill() reports + * `rejected` rather than guessing. Returns false if the game itself refused + * (e.g. level/requirement unmet). + */ + start?: () => boolean; +} + +/** The active action, when one is running. */ +export interface MelvorActiveAction { + readonly id: string; + /** Halt this action. Present on Melvor's skill/action objects. */ + stop(): void; +} + +/** The subset of the Melvor `game` global this adapter binds to. */ +export interface MelvorGlobal { + readonly skills: { readonly allObjects: readonly MelvorSkillObject[] }; + /** Currently active action, or null when the avatar is idle. */ + readonly activeAction: MelvorActiveAction | null; +} + +/** Adapt the real `game` global to the port the bridge consumes. */ +export function bindMelvorGlobal(game: MelvorGlobal): MelvorGameApi { + const view = (s: MelvorSkillObject): MelvorSkillView => ({ + id: s.id, + name: s.name, + level: s.level, + xp: s.xp, + }); + return { + listSkills: () => game.skills.allObjects.map(view), + activeActionId: () => game.activeAction?.id ?? null, + startSkill: (skillId) => { + const skill = game.skills.allObjects.find((s) => s.id === skillId); + if (!skill || !skill.start) return false; + return skill.start(); + }, + stopActive: () => { + game.activeAction?.stop(); + }, + }; +} + +/** + * Mod entrypoint: build a MelvorBridge from the page's `game` global. Call this + * from the loaded mod, where `game` is in scope. Throws off-page (e.g. in a + * plain Node process) where no game exists — the adapter only works in-process. + */ +export function createMelvorBridge(): MelvorBridge { + const g = (globalThis as { game?: MelvorGlobal }).game; + if (!g) { + throw new Error( + "Melvor `game` global not found — load this adapter as a Melvor mod, in-page.", + ); + } + return new MelvorBridge(bindMelvorGlobal(g)); +} diff --git a/harness/package.json b/harness/package.json index f61b6b8..c0509a6 100644 --- a/harness/package.json +++ b/harness/package.json @@ -7,8 +7,8 @@ "license": "AGPL-3.0-only", "scripts": { "typecheck": "tsc --noEmit", - "test": "tsx --test bridge/*.test.ts", - "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='**/*.test.ts' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 bridge/*.test.ts", + "test": "tsx --test bridge/*.test.ts adapters/*/*.test.ts", + "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='**/*.test.ts' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 bridge/*.test.ts adapters/*/*.test.ts", "ci": "npm run typecheck && npm run test:coverage" }, "devDependencies": { diff --git a/harness/tsconfig.json b/harness/tsconfig.json index 98e0336..e145e0d 100644 --- a/harness/tsconfig.json +++ b/harness/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["bridge/**/*.ts"] + "include": ["bridge/**/*.ts", "adapters/**/*.ts"] }