Skip to content
Open
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
87 changes: 87 additions & 0 deletions harness/adapters/melvor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# harness/adapters/melvor/ — the Melvor Idle adapter

A `Bridge<MelvorState>` 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<MelvorState>`:

- `state.skills` — every skill with `id` / `name` / `level` / `xp`.
- `state.activeActionId` — the one running action, or `null` when idle.
- `affordances` — one `train:<skillId>` 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:<skillId>` (→ 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).
5 changes: 5 additions & 0 deletions harness/adapters/melvor/index.ts
Original file line number Diff line number Diff line change
@@ -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";
162 changes: 162 additions & 0 deletions harness/adapters/melvor/melvor-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
130 changes: 130 additions & 0 deletions harness/adapters/melvor/melvor-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// MelvorBridge — a Bridge<MelvorState> 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<MelvorState> {
constructor(private readonly game: MelvorGameApi) {}

async observe(): Promise<Perception<MelvorState>> {
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<ActionResult> {
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<void> {
// In-process: nothing to release (the mod shares the page's lifetime).
}
}
Loading
Loading