diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 0000000..542fbcd --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,33 @@ +# Runwave MCP server: lets an agent harness play browser games. +# +# The Playwright base image already carries every system library Chromium needs +# (libnss3, libnspr4, libgbm, the X client libs), which is the whole reason to +# start from it rather than a plain node image. +# +# No recording means no gstreamer, no PulseAudio, and no Xvfb: Chromium runs +# headless, so this image stays far smaller than the playtest runner's. +FROM mcr.microsoft.com/playwright:v1.61.1-noble + +ENV NODE_ENV=production \ + RUNWAVE_MCP_WORKSPACE=/var/lib/runwave-mcp + +WORKDIR /opt/runwave + +# Dependencies are copied first so a source edit does not invalidate the +# install layer. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY runwave ./runwave +COPY mcp ./mcp + +RUN mkdir -p "$RUNWAVE_MCP_WORKSPACE" + +# Chromium's sandbox needs privileges a default container does not grant. The +# base image ships a non-root user with the right setup; use it rather than +# disabling the sandbox. +USER pwuser + +# stdio transport: the host speaks MCP over stdin/stdout, so nothing is exposed +# on the network and no port is published. +ENTRYPOINT ["node", "/opt/runwave/mcp/bin/runwave-mcp.js"] diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..0d7f5fc --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,209 @@ +# Runwave MCP + +An MCP server that lets an agent harness such as Claude Code play a browser game +directly: look at a frame, send a timed sequence of inputs, look at the next +frame. + +This is the interactive counterpart to the `runwave` CLI. The CLI runs a VLM in a +loop by itself and produces a recorded video for playtesting. Here the connected +agent *is* the player, so the OpenRouter agent loop is not used. Interactive +play itself is not recorded: successful input sequences are persisted and can +be replayed afterwards in a separate Playwright-native recorder. This needs +none of runwave's gstreamer, PulseAudio, or Xvfb setup. + +## Requirements + +- Node 20+ +- Chromium's system libraries. On a bare Linux host: + ```sh + npx playwright install --with-deps chromium # needs sudo for the libs + ``` + In Docker, use `mcp/Dockerfile`, which starts from the Playwright base image + and already has them. + +No X server, no audio, no display. Chromium runs headless. + +## Run + +```sh +node mcp/bin/runwave-mcp.js +``` + +Artifacts (screenshots, per-step JSON, `playthrough.json`, and rendered videos) +are written under +`RUNWAVE_MCP_WORKSPACE`, defaulting to a directory in the system temp dir. Set +it explicitly if you want to keep them: + +```sh +RUNWAVE_MCP_WORKSPACE=./artifacts node mcp/bin/runwave-mcp.js +``` + +Register it with Claude Code: + +```sh +claude mcp add runwave -- node /absolute/path/to/mcp/bin/runwave-mcp.js +``` + +Or in Docker: + +```sh +docker build -f mcp/Dockerfile -t runwave-mcp . +claude mcp add runwave -- docker run --rm -i runwave-mcp +``` + +## Tools + +| Tool | Purpose | +| --- | --- | +| `launch_game` | Start a session from a `url`, or a `game_dir` containing `start.sh` plus a `port`. Returns the first frame. | +| `observe` | Fresh frame and state, no input sent. The game is paused while the agent reasons. | +| `act` | Resume, send a timed input sequence, pause, and return the resulting frame. | +| `zoom` | Full-resolution crop of a region, to read small UI without a full frame. | +| `capture` | Save a clean, full-resolution, un-annotated screenshot. The deliverable. | +| `focus_game` | Acquire pointer lock on the largest game canvas without rotating an FPS camera. | +| `reset_game` | Reload at the launch URL. | +| `render_playthrough` | Replay the current successful input timeline in a fresh browser and record a game-only WebM without agent reasoning gaps. | +| `journal` | Text log of what has been tried this session. | +| `list_sessions` | Running sessions. | +| `pause_game` | Immediately pause the browser game at the harness level. | +| `resume_game` | Release a manual pause. | +| `end_game` | Close the browser and stop the game process. | + +## Playthrough recording + +Every successful `act` is appended to an atomically replaced +`playthrough.json`. It stores the concrete pixel coordinates, resolved keys, +step durations, and optional intent notes needed to reproduce the current +attempt. `reset_game` starts a fresh attempt and clears its steps. + +Call `render_playthrough` once the attempt is worth keeping. It leaves the live +game paused, opens the same launch URL in a fresh isolated browser, executes the +timeline without screenshots or model turns, and records that browser viewport +with Playwright's native screencast. The result includes paths to the WebM and a +replay manifest under the session's `replays/` directory. + +This is intentionally an input replay, not a game-engine clock patch. It stays +agnostic across canvas, WebGL, and DOM games. Games with randomness, networked +state, or nondeterministic physics can diverge from the original run; the +timeline preserves the agent's inputs and timing, not private engine state. + +## Pause behavior + +The MCP automatically pauses the page after `launch_game`, `observe`, `act`, +`zoom`, `capture`, and `reset_game` return their frame. This keeps a live game +from progressing while the agent is inspecting the image and deciding its next +move. `act` and `reset_game` temporarily resume the page for their operation, +then pause it again before returning. + +The pause is implemented in the browser harness rather than by sending the +game's own pause key. It holds animation frames and timers, virtualizes +`performance.now()`/`Date.now()` so a long reasoning turn does not create a +large simulation delta, and blocks new gameplay input while paused. Screenshots +and state reads remain available. + +`pause_game` is an immediate, higher-authority manual override. It does not wait +behind an in-progress `act` call. A manually paused session rejects `act` and +`reset_game` until `resume_game` is called, so an emergency pause cannot be +silently undone by a queued gameplay action. + +## How `act` works + +An action sequence is timed, not a single keypress. Offsets are milliseconds +from the start of the sequence and actions may overlap, so one call can express +"hold right for 900ms and jump at 150ms": + +```json +{ + "session_id": "game-...", + "actions": [ + { "type": "key", "start": 0, "end": 900, "key": "ArrowRight" }, + { "type": "key", "start": 150, "end": 230, "key": "Space" } + ], + "duration_ms": 900 +} +``` + +This matters because an agent turn costs seconds, but the useful batch size +depends on uncertainty: + +- Use a short 300-1200ms probe when calibrating direction or camera sensitivity, + approaching a collider, or searching for an interaction range. +- Commit to a longer sequence once the heading and open route are visually + verified. +- On an uncertain sequence longer than about 1500ms, request 2-3 spaced + `captures`. If the same landmark or surface fills consecutive frames, stop + pushing forward and recover. Omit trajectory frames on known traversal to + keep context and capture overhead low. + +This gives the agent feedback where mistakes are expensive without turning all +gameplay into one tap per turn. + +`duration_ms` is normally inferred from the latest action end. Set it explicitly +when an action starts a load or animation that should run before the final pause; +for example, click a play button at 0ms and use `duration_ms: 3000` to receive a +settled game frame rather than the first loading frame. + +To advance a loading screen or animation without sending input, use an empty +sequence with a positive duration: + +```json +{ + "session_id": "game-...", + "actions": [], + "duration_ms": 2000 +} +``` + +Action types: `key`, `click`, `multi_click`, `drag`, `cursor_move`, `view_move`. +Pointer actions take either `x`/`y` in viewport pixels or an +`overlay_row`/`overlay_col` grid cell. The full schema is enforced on the tool +input, so a malformed sequence is rejected before any input is sent. + +In a pointer-locked game, a `click` ignores its coordinates and presses the +current mouse button. A click may be held for up to two seconds, which is useful +for continuous fire or aim and avoids a slow burst of separate browser calls. +When state reports `pointer_locked: false`, call `focus_game` before using +`view_move`; calibrate with a small delta because sensitivity is game-defined. + +## Notes on the design + +**Frames are downscaled by default.** A 1280x720 PNG is roughly 1200 tokens; at +half scale it is about 300. Over a long navigation that difference dominates +everything else. Pass `full_res: true` when detail genuinely matters, or use +`zoom` on the region you care about — usually cheaper than a full-resolution +frame. + +**One frame per turn.** Interval captures are off. Pass `captures` with explicit +offsets to see a trajectory within a sequence. + +**`act` reports whether the frame changed.** A byte-identical frame almost always +means the input never reached the game, rather than the game ignoring it. The +tool says so instead of leaving the agent to guess. + +**The grid overlay is off by default.** When enabled it enlarges the PNG with a +label margin on every side, so pixels read off the image no longer match pixels +sent back as `x`/`y`. The offset is reported in the response when the grid is on, +but exact coordinates or grid cells are the better targets. + +**Grid cells resolve to the cell centre.** Runwave's playtest path deliberately +scatters clicks inside a cell to vary footage; that is wrong when aiming at a +specific target, and it makes a run unreproducible. Playtest behaviour is +unchanged — this server opts into `markGridSampleMode: 'center'`. + +**Calls are serialized per session.** There is one Playwright page and one shared +step counter, so concurrent calls would interleave keypresses. Subagents may +share a `session_id` safely. + +**Sessions close on shutdown.** Chromium and any spawned game process are +detached children; `SIGINT`, `SIGTERM`, `SIGHUP`, and an uncaught exception all +close them. Sessions also close after 30 minutes idle, so a forgotten +`end_game` does not leak a browser. + +## Tests + +```sh +npm run test:mcp +``` + +The integration test drives a real headless Chromium against a fixture game and +skips itself when Chromium cannot launch. diff --git a/mcp/bin/runwave-mcp.js b/mcp/bin/runwave-mcp.js new file mode 100644 index 0000000..b814bef --- /dev/null +++ b/mcp/bin/runwave-mcp.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +'use strict'; + +const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { createServer } = require('../src/server'); + +async function main() { + const { server, registry, workspace } = createServer(); + // stdout is the MCP transport, so diagnostics go to stderr only. + process.stderr.write(`runwave-mcp workspace: ${workspace}\n`); + + // Chromium and any game process are detached children. Without this a host + // shutting the server down orphans the whole tree, leaving browsers running. + let shuttingDown = false; + const shutdown = async (signal) => { + if (shuttingDown) return; + shuttingDown = true; + process.stderr.write(`runwave-mcp shutting down on ${signal}\n`); + await registry.closeAll(); + await server.close().catch(() => {}); + process.exit(0); + }; + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { shutdown(signal); }); + } + process.on('uncaughtException', async (error) => { + process.stderr.write(`runwave-mcp uncaught: ${error.stack || error.message}\n`); + await shutdown('uncaughtException'); + }); + process.on('unhandledRejection', (reason) => { + process.stderr.write(`runwave-mcp unhandled rejection: ${reason}\n`); + }); + + await server.connect(new StdioServerTransport()); +} + +main().catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exit(1); +}); diff --git a/mcp/src/config.js b/mcp/src/config.js new file mode 100644 index 0000000..67a4625 --- /dev/null +++ b/mcp/src/config.js @@ -0,0 +1,63 @@ +'use strict'; + +const path = require('path'); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +// Runwave scatters clicks within a cell to vary playtest footage. An agent +// aiming at a target needs the opposite: the same cell must mean the same pixel +// so a saved action trace replays identically. +const CELL_SAMPLE_MODE = 'center'; + +function normalizeViewport(viewport) { + const width = Number(viewport && viewport.width); + const height = Number(viewport && viewport.height); + return { + width: Number.isFinite(width) && width > 0 ? Math.round(width) : DEFAULT_VIEWPORT.width, + height: Number.isFinite(height) && height > 0 ? Math.round(height) : DEFAULT_VIEWPORT.height, + }; +} + +// The daemon hands createSession its raw CLI input, which is why grid-cell +// actions fail there when no viewport was passed. Building the config +// explicitly closes that gap: viewport is always present and always numeric. +function buildSessionConfig(options = {}) { + const viewport = normalizeViewport(options.viewport); + return { + kind: 'web', + ...(options.url ? { url: options.url } : {}), + ...(options.gameDir ? { gameDir: path.resolve(options.gameDir) } : {}), + ...(options.port ? { port: Number(options.port) } : {}), + viewport, + deviceScaleFactor: 1, + // No recording: no gstreamer, no PulseAudio, no headed Chromium. + record: false, + headless: true, + // Overlay is opt-in per call. It enlarges the PNG by a margin per side, + // which desyncs image coordinates from input coordinates. + gridScreenshots: false, + fullPageScreenshots: false, + // The agent asks for frames explicitly; interval captures would spend + // context on frames nobody requested. + autoCaptures: false, + // Install the renderer-level pause gate. This remains opt-in so the normal + // runwave playtest path is not affected by MCP-specific browser patches. + pauseController: true, + // Pointer-locked games need continuous mouse holds for actions such as + // firing and aiming. Keep the normal Runwave/UI click guard at 100ms; only + // MCP gameplay sessions opt into a bounded longer hold. + maxActionSpanMs: { click: 2000 }, + markGridSampleMode: CELL_SAMPLE_MODE, + ...(options.markGridRows ? { markGridRows: Number(options.markGridRows) } : {}), + ...(options.markGridCols ? { markGridCols: Number(options.markGridCols) } : {}), + ...(options.stateExpression ? { stateExpression: String(options.stateExpression) } : {}), + waitAfterLoad: Number(options.waitAfterLoad ?? 700), + }; +} + +module.exports = { + CELL_SAMPLE_MODE, + DEFAULT_VIEWPORT, + buildSessionConfig, + normalizeViewport, +}; diff --git a/mcp/src/diff.js b/mcp/src/diff.js new file mode 100644 index 0000000..b28e8c4 --- /dev/null +++ b/mcp/src/diff.js @@ -0,0 +1,29 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); + +function hashFile(file) { + try { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + } catch { + return null; + } +} + +// "Did anything happen?" is the single most useful signal after an input, and +// the cheapest: no second screenshot, no pixel walk. A false here usually means +// the input never reached the game, which is otherwise easy to misread as the +// game ignoring the move. +function changedSince(previousFile, nextFile) { + if (!previousFile || !nextFile) return null; + const before = hashFile(previousFile); + const after = hashFile(nextFile); + if (!before || !after) return null; + return before !== after; +} + +module.exports = { + changedSince, + hashFile, +}; diff --git a/mcp/src/frame.js b/mcp/src/frame.js new file mode 100644 index 0000000..a412532 --- /dev/null +++ b/mcp/src/frame.js @@ -0,0 +1,54 @@ +'use strict'; + +const { drawMarkGridOnScreenshot } = require('../../runwave/controller/src/grid-overlay'); +const { readPng } = require('./image'); +const { frameBlocks, gridNote, stateText, textBlock } = require('./result'); + +// The overlay writes the PNG larger than the capture by a fixed margin per +// side. gridLabelStyle is private, so the margin is recovered from the file +// itself rather than reimplementing the label metrics. +function overlayMargin(file, viewport) { + try { + const png = readPng(file); + const margin = Math.round((png.width - Number(viewport.width)) / 2); + return margin > 0 ? margin : 0; + } catch { + return 0; + } +} + +// Draws the overlay onto an existing capture and reports the margin it added. +function applyGrid(session, file) { + drawMarkGridOnScreenshot(file, session.config); + return overlayMargin(file, session.config.viewport); +} + +// Screenshots are taken clean. The grid is drawn only when a caller asks for +// it, so the deliverable frame is never annotated. +async function captureFrame(session, { name, grid = false }) { + const dir = session.actionDir(name); + const file = await session.browser.screenshot(dir, name); + if (!grid) return { file, margin: 0 }; + return { file, margin: applyGrid(session, file) }; +} + +// Assembles the per-turn payload: frame, trimmed state, and any coordinate +// caveat the model needs in order to aim correctly. +function frameResult({ file, margin, state, scale, fullRes, region, label, extra = [] }) { + const { blocks, image } = frameBlocks(file, { scale, fullRes, region, label }); + const notes = [stateText(state)]; + const caveat = gridNote(image, margin); + if (caveat) notes.push(caveat); + for (const note of extra) if (note) notes.push(note); + return { + content: [...blocks, textBlock(notes.join('\n'))], + image, + }; +} + +module.exports = { + applyGrid, + captureFrame, + frameResult, + overlayMargin, +}; diff --git a/mcp/src/image.js b/mcp/src/image.js new file mode 100644 index 0000000..5ddfe62 --- /dev/null +++ b/mcp/src/image.js @@ -0,0 +1,108 @@ +'use strict'; + +const fs = require('fs'); +const { PNG } = require('pngjs'); + +// Screenshots are the dominant context cost for an agent playing a game: a +// 1280x720 PNG is roughly 1200 tokens. Frames are downscaled by default so a +// long navigation stays affordable, and only widened on explicit request. +const DEFAULT_SCALE = 0.5; + +function readPng(file) { + return PNG.sync.read(fs.readFileSync(file)); +} + +function clampScale(scale) { + const value = Number(scale); + if (!Number.isFinite(value) || value <= 0) return DEFAULT_SCALE; + return Math.min(1, value); +} + +// Box filter. Averaging over the source rectangle keeps thin game sprites and +// small UI text legible, which nearest-neighbour sampling loses. +function resize(source, scale) { + const ratio = clampScale(scale); + if (ratio === 1) return source; + const width = Math.max(1, Math.round(source.width * ratio)); + const height = Math.max(1, Math.round(source.height * ratio)); + const target = new PNG({ width, height }); + + for (let y = 0; y < height; y += 1) { + const topEdge = Math.floor((y * source.height) / height); + const bottomEdge = Math.max(topEdge + 1, Math.floor(((y + 1) * source.height) / height)); + for (let x = 0; x < width; x += 1) { + const leftEdge = Math.floor((x * source.width) / width); + const rightEdge = Math.max(leftEdge + 1, Math.floor(((x + 1) * source.width) / width)); + let red = 0; + let green = 0; + let blue = 0; + let samples = 0; + for (let sourceY = topEdge; sourceY < bottomEdge; sourceY += 1) { + for (let sourceX = leftEdge; sourceX < rightEdge; sourceX += 1) { + const index = (source.width * sourceY + sourceX) << 2; + red += source.data[index]; + green += source.data[index + 1]; + blue += source.data[index + 2]; + samples += 1; + } + } + const out = (width * y + x) << 2; + target.data[out] = Math.round(red / samples); + target.data[out + 1] = Math.round(green / samples); + target.data[out + 2] = Math.round(blue / samples); + target.data[out + 3] = 255; + } + } + return target; +} + +// Clamped so a model-supplied region can never throw; an out-of-bounds ask +// yields the nearest valid rectangle instead of failing the turn. +function crop(source, region) { + const x = Math.max(0, Math.min(Math.round(Number(region.x) || 0), source.width - 1)); + const y = Math.max(0, Math.min(Math.round(Number(region.y) || 0), source.height - 1)); + const width = Math.max(1, Math.min(Math.round(Number(region.width) || 0), source.width - x)); + const height = Math.max(1, Math.min(Math.round(Number(region.height) || 0), source.height - y)); + const target = new PNG({ width, height }); + PNG.bitblt(source, target, x, y, width, height, 0, 0); + return { png: target, region: { x, y, width, height } }; +} + +function encode(png) { + return PNG.sync.write(png).toString('base64'); +} + +// Reads a screenshot off disk and returns an MCP image content block plus the +// dimensions actually sent, so a caller can map coordinates back if needed. +function imageBlock(file, options = {}) { + let png = readPng(file); + let region = null; + if (options.region) { + const cropped = crop(png, options.region); + png = cropped.png; + region = cropped.region; + } + const scale = options.fullRes ? 1 : clampScale(options.scale ?? DEFAULT_SCALE); + const sourceWidth = png.width; + const sourceHeight = png.height; + png = resize(png, scale); + return { + block: { type: 'image', data: encode(png), mimeType: 'image/png' }, + width: png.width, + height: png.height, + sourceWidth, + sourceHeight, + scale, + region, + }; +} + +module.exports = { + DEFAULT_SCALE, + clampScale, + crop, + encode, + imageBlock, + readPng, + resize, +}; diff --git a/mcp/src/registry.js b/mcp/src/registry.js new file mode 100644 index 0000000..1e6f74b --- /dev/null +++ b/mcp/src/registry.js @@ -0,0 +1,60 @@ +'use strict'; + +const { Session, newSessionId } = require('./session'); + +class SessionRegistry { + constructor({ workspace }) { + this.workspace = workspace; + this.sessions = new Map(); + } + + async create(options = {}) { + const id = options.sessionId ? String(options.sessionId) : newSessionId(); + if (this.sessions.has(id)) throw new Error(`session ${id} already exists`); + const session = new Session({ id, workspace: this.workspace, options }); + this.sessions.set(id, session); + try { + await session.start(); + } catch (error) { + this.sessions.delete(id); + // A game process or Chromium may already be up even though start threw. + await session.close().catch(() => {}); + throw error; + } + return session; + } + + get(id) { + const session = this.sessions.get(String(id)); + if (!session) { + const known = [...this.sessions.keys()]; + const hint = known.length ? ` known sessions: ${known.join(', ')}` : ' no sessions are running'; + throw new Error(`unknown session_id "${id}".${hint}`); + } + return session; + } + + async end(id) { + const session = this.get(id); + const summary = session.summary(); + await session.close(); + this.sessions.delete(session.id); + return { ...summary, closed: true }; + } + + list() { + return [...this.sessions.values()].map((session) => session.summary()); + } + + // Chromium and any spawned game process are detached children; without this + // a server shutdown orphans the whole tree. + async closeAll() { + const closing = [...this.sessions.values()].map((session) => session.close().catch(() => {})); + this.sessions.clear(); + await Promise.all(closing); + } +} + +module.exports = { + SessionRegistry, +}; diff --git a/mcp/src/replay.js b/mcp/src/replay.js new file mode 100644 index 0000000..7a92bd5 --- /dev/null +++ b/mcp/src/replay.js @@ -0,0 +1,124 @@ +'use strict'; + +const path = require('path'); +const { createSession } = require('../../runwave/controller/src/session-factory'); +const { ensureDir, sleep, timestamp, writeJson } = require('../../runwave/controller/src/file-utils'); +const { executeTimeline } = require('../../runwave/controller/src/step-executor'); +const { normalizeStep } = require('../../runwave/controller/src/step-normalizer'); +const { buildStepTimeline } = require('../../runwave/controller/src/step-timeline'); + +function replayConfigFor(session) { + return { + kind: 'web', + url: session.browser.launchUrl, + viewport: { ...session.config.viewport }, + videoSize: { ...session.config.viewport }, + deviceScaleFactor: Number(session.config.deviceScaleFactor ?? 1), + record: true, + recordAudio: false, + recordingBackend: 'playwright', + playwrightVideoFileName: 'playthrough', + headless: true, + gridScreenshots: false, + fullPageScreenshots: false, + autoCaptures: false, + // The controller also supplies trusted pointer-lock movement filtering. + // It remains unpaused unless the replay explicitly calls pause/resume. + pauseController: true, + // Replay must accept the same bounded input spans that were valid in the + // live MCP session. Otherwise a successfully persisted held button can + // fail only when the clean recording is rendered. + maxActionSpanMs: { ...session.config.maxActionSpanMs }, + markGridSampleMode: session.config.markGridSampleMode, + keyAliases: session.config.keyAliases, + waitAfterLoad: Number(session.config.waitAfterLoad ?? 700), + }; +} + +function replayStep(step, config, index) { + return normalizeStep({ + action: 'step', + action_name: `replay-${String(index).padStart(3, '0')}`, + actions: step.actions, + duration: step.duration_ms, + captures: [], + autoCaptures: false, + }, config, index); +} + +async function executeReplayStep(browser, step, config, index) { + const focusAction = step.actions.find((action) => action.type === 'focus_game'); + if (focusAction) { + if (step.actions.length !== 1) throw new Error('focus_game replay steps cannot contain other actions'); + const startedAt = Date.now(); + if (focusAction.start > 0) await sleep(focusAction.start); + const focused = await browser.focusGame(focusAction); + if (!focused.pointerLocked) throw new Error('replay could not reacquire pointer lock'); + const remaining = step.duration_ms - (Date.now() - startedAt); + if (remaining > 0) await sleep(remaining); + return; + } + const normalized = replayStep(step, config, index); + const events = buildStepTimeline(normalized).filter((event) => event.type !== 'capture'); + await executeTimeline({ + browser, + events, + duration: normalized.duration, + outputDir: null, + prefix: `replay-${String(index).padStart(3, '0')}`, + stateExpression: null, + beforeEndCapture: null, + profiler: null, + }); +} + +async function renderPlaythrough(session, { tailMs = 1000 } = {}) { + const source = JSON.parse(JSON.stringify(session.playthrough)); + if (!source.steps.length) throw new Error('playthrough has no successful act steps to render'); + + const replayId = `replay-${timestamp()}`; + const runDir = ensureDir(path.join(session.paths.runDir, 'replays', replayId)); + const sourcePlaythrough = writeJson(path.join(runDir, 'playthrough.json'), source); + const config = replayConfigFor(session); + const browser = createSession(config, { runDir }, null); + const startedAt = Date.now(); + let closeResult; + + try { + await browser.start(); + for (let index = 0; index < source.steps.length; index += 1) { + await executeReplayStep(browser, source.steps[index], config, index + 1); + } + if (tailMs > 0) await sleep(tailMs); + closeResult = await browser.close(); + } catch (error) { + await browser.close().catch(() => {}); + throw error; + } + + if (!closeResult.video) throw new Error('Playwright replay recorder did not produce a video'); + const result = { + version: 1, + replay_id: replayId, + source_playthrough: sourcePlaythrough, + session_playthrough: session.paths.playthrough, + source_attempt: source.attempt, + recording_backend: 'playwright', + step_count: source.steps.length, + playthrough_duration_ms: source.total_duration_ms, + tail_ms: tailMs, + render_elapsed_ms: Date.now() - startedAt, + video: closeResult.video, + run_dir: runDir, + }; + result.manifest = writeJson(path.join(runDir, 'replay.json'), result); + session.replays.push(result); + return result; +} + +module.exports = { + executeReplayStep, + renderPlaythrough, + replayConfigFor, + replayStep, +}; diff --git a/mcp/src/result.js b/mcp/src/result.js new file mode 100644 index 0000000..0950474 --- /dev/null +++ b/mcp/src/result.js @@ -0,0 +1,58 @@ +'use strict'; + +const { imageBlock } = require('./image'); +const { compactState } = require('./state'); + +function textBlock(text) { + return { type: 'text', text }; +} + +function errorResult(error) { + return { + isError: true, + content: [textBlock(String((error && error.message) || error))], + }; +} + +// Every frame goes back as an image block for the model and a path for tooling +// that wants the original PNG on disk. +function frameBlocks(file, options = {}) { + const image = imageBlock(file, options); + const scaleNote = image.scale === 1 + ? `${image.width}x${image.height}` + : `${image.width}x${image.height}, downscaled ${image.scale}x from ${image.sourceWidth}x${image.sourceHeight}`; + const label = options.label ? `${options.label} ` : ''; + return { + blocks: [image.block, textBlock(`${label}frame (${scaleNote})\npath: ${file}`)], + image, + }; +} + +function stateText(raw) { + const state = compactState(raw); + return Object.keys(state).length ? `state: ${JSON.stringify(state)}` : 'state: {}'; +} + +// Coordinate space warning matters: with the grid on, the saved PNG is larger +// than the viewport by a margin per side, so pixels read off the image do not +// match pixels sent back as x/y. +function gridNote(image, margin) { + if (!margin) return null; + return `grid overlay is on. The image includes a ${margin}px label margin on every side, so image pixel (px, py) is viewport (px - ${margin}, py - ${margin}). Prefer overlay_row/overlay_col targets while the grid is on.`; +} + +function pauseNote(mode) { + if (mode === 'manual') { + return 'game_status: paused by manual override; call resume_game before act or reset_game.'; + } + return 'game_status: paused at this frame; act or reset_game resumes only for its controlled duration.'; +} + +module.exports = { + errorResult, + frameBlocks, + gridNote, + pauseNote, + stateText, + textBlock, +}; diff --git a/mcp/src/schema.js b/mcp/src/schema.js new file mode 100644 index 0000000..26d30ce --- /dev/null +++ b/mcp/src/schema.js @@ -0,0 +1,113 @@ +'use strict'; + +const { z } = require('zod'); +const { MAX_ACTION_SPAN_MS } = require('../../runwave/protocol/src/action'); +const { DEFAULT_MARK_GRID } = require('../../runwave/protocol/src/mark-grid'); + +// Spans are pulled from the protocol rather than restated, so the tool contract +// cannot drift from what the executor actually enforces. +const span = (type) => (MAX_ACTION_SPAN_MS[type] ? ` Max ${MAX_ACTION_SPAN_MS[type]}ms.` : ''); +const MCP_CLICK_MAX_MS = 2000; + +const cell = z + .object({ + overlay_row: z.number().int().min(0).describe(`Grid row, 0-${DEFAULT_MARK_GRID.rows - 1}.`), + overlay_col: z.number().int().min(0).describe(`Grid column, 0-${DEFAULT_MARK_GRID.cols - 1}.`), + }) + .describe('Grid cell target. Resolves to the centre of that cell.'); + +const point = z.object({ + x: z.number().optional().describe('Viewport pixel X. Preferred for precise targets.'), + y: z.number().optional().describe('Viewport pixel Y.'), + overlay_row: z.number().int().min(0).optional(), + overlay_col: z.number().int().min(0).optional(), +}); + +const start = z.number().min(0).describe('Offset in ms from the start of the sequence.'); + +const keyAction = z.object({ + type: z.literal('key'), + start, + end: z.number().min(0).optional().describe('Release offset in ms. Omit for a ~50ms tap. Hold longer to move further.'), + key: z.string().describe('Key name, e.g. ArrowRight, Space, KeyW, Enter. Aliases: left/right/up/down/jump.'), +}); + +const clickAction = z.object({ + type: z.literal('click'), + start, + end: z.number().min(0).optional().describe(`Release offset. Omit for a short click; use a longer span for a held game button such as sustained fire. Max ${MCP_CLICK_MAX_MS}ms.`), + ...point.shape, + button: z.enum(['left', 'middle', 'right']).optional(), + clickCount: z.number().int().min(1).max(3).optional(), +}); + +const multiClickAction = z.object({ + type: z.literal('multi_click'), + start, + ...point.shape, + cells: z.array(cell).max(4).optional().describe('Up to 4 candidate cells; clicks scatter across them.'), + count: z.number().int().min(1).max(20).optional().describe('Number of clicks, default 10.'), + intervalMs: z.number().min(20).max(500).optional(), + button: z.enum(['left', 'middle', 'right']).optional(), +}); + +const dragAction = z.object({ + type: z.literal('drag'), + start, + end: z.number().min(0).optional().describe(`Drag duration.${span('drag')}`), + from: point.describe('Drag origin.'), + to: point.describe('Drag destination.'), + button: z.enum(['left', 'middle', 'right']).optional(), + mode: z.enum(['mouse', 'html5']).optional().describe('mouse for canvas games; html5 only for native draggable elements.'), + steps: z.number().int().min(1).max(80).optional(), +}); + +const cursorMoveAction = z.object({ + type: z.literal('cursor_move'), + start, + end: z.number().min(0).optional().describe(`Move duration.${span('cursor_move')}`), + ...point.shape, + steps: z.number().int().min(1).max(80).optional(), +}); + +const viewMoveAction = z.object({ + type: z.literal('view_move'), + start, + end: z.number().min(0).optional(), + dx: z.number().optional().describe('Relative pointer delta X. Positive is right.'), + dy: z.number().optional().describe('Relative pointer delta Y. Positive is down.'), + steps: z.number().int().min(1).max(80).optional(), +}).describe('Relative mouse movement for pointer-lock/FPS camera control. Start with a small calibration move because game sensitivity varies.'); + +const action = z + .discriminatedUnion('type', [ + keyAction, + clickAction, + multiClickAction, + dragAction, + cursorMoveAction, + viewMoveAction, + ]) + .describe('One timed input. Offsets are ms from sequence start; actions may overlap.'); + +const region = z.object({ + x: z.number().min(0), + y: z.number().min(0), + width: z.number().min(1), + height: z.number().min(1), +}); + +module.exports = { + action, + cell, + clickAction, + cursorMoveAction, + dragAction, + keyAction, + multiClickAction, + point, + region, + span, + start, + viewMoveAction, +}; diff --git a/mcp/src/server.js b/mcp/src/server.js new file mode 100644 index 0000000..d1a13ab --- /dev/null +++ b/mcp/src/server.js @@ -0,0 +1,56 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); +const { SessionRegistry } = require('./registry'); +const { registerAct, registerObserve, registerPlayTools } = require('./tools-play'); +const { registerCapture, registerSessionTools, registerZoom } = require('./tools-aux'); + +const VERSION = '0.1.0'; + +// An MCP server starts in whatever directory the host happens to use, so the +// workspace is resolved explicitly instead of from cwd. This is the same trap +// runwave's paths.js falls into by capturing cwd at require time. +function resolveWorkspace() { + const configured = process.env.RUNWAVE_MCP_WORKSPACE; + if (configured) return path.resolve(configured); + return path.join(os.tmpdir(), 'runwave-mcp'); +} + +function createServer({ workspace = resolveWorkspace() } = {}) { + const server = new McpServer( + { name: 'runwave', version: VERSION }, + { + instructions: [ + 'Play browser games by looking at frames and sending timed input sequences.', + 'Call launch_game once, then loop act/observe, then end_game. Returned frames automatically leave the game paused while you reason; act and reset_game resume for their operation and pause again before returning.', + 'Use pause_game for a higher-authority manual pause and resume_game to release it.', + 'Frames come back downscaled to save context; use zoom to inspect detail and full_res only when you must.', + 'When direction, distance, or collision is uncertain, use a short 300-1200ms act as a probe and inspect the result. Commit to longer batched movement only after the heading and open route are verified.', + 'For uncertain actions longer than about 1500ms, request 2-3 captures across the sequence; if landmarks stop changing or the same surface fills them, recover instead of repeating forward movement.', + 'If state reports pointer_locked=false in a mouse-look game, call focus_game before view_move. Begin with a small view_move because sensitivity varies by game.', + 'A click span may last up to two seconds, so use one held click for sustained fire or aiming instead of many rapid click actions.', + 'When a load or animation needs controlled time, set act duration_ms; actions may be an empty array for a wait with no input.', + 'If a result says the frame did not change, the input did not land: change approach rather than repeating it.', + 'When the run is worth keeping, call render_playthrough before end_game to replay the successful input timeline in a separate recorder without reasoning gaps.', + 'Use journal to recall what you have already tried, and capture to save the final screenshot.', + ].join(' '), + } + ); + + const registry = new SessionRegistry({ workspace }); + registerPlayTools(server, registry); + registerObserve(server, registry); + registerAct(server, registry); + registerZoom(server, registry); + registerCapture(server, registry); + registerSessionTools(server, registry); + return { server, registry, workspace }; +} + +module.exports = { + VERSION, + createServer, + resolveWorkspace, +}; diff --git a/mcp/src/session.js b/mcp/src/session.js new file mode 100644 index 0000000..3ff71d4 --- /dev/null +++ b/mcp/src/session.js @@ -0,0 +1,282 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { createSession } = require('../../runwave/controller/src/session-factory'); +const { OutputWriter } = require('../../runwave/controller/src/output-writer'); +const { ensureDir, timestamp } = require('../../runwave/controller/src/file-utils'); +const { buildSessionConfig } = require('./config'); + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; + +class Session { + constructor({ id, workspace, options }) { + this.id = id; + this.config = buildSessionConfig(options); + this.paths = { + runDir: path.join(workspace, 'sessions', id), + outputRoot: path.join(workspace, 'sessions', id, 'output'), + playthrough: path.join(workspace, 'sessions', id, 'playthrough.json'), + }; + ensureDir(this.paths.runDir); + this.output = new OutputWriter(this.paths.outputRoot); + this.browser = createSession(this.config, this.paths, null); + this.stepIndex = 0; + this.turn = 0; + this.journal = []; + this.playthrough = { + version: 1, + session_id: id, + attempt: 1, + created_at: new Date().toISOString(), + launch_url: null, + viewport: { ...this.config.viewport }, + total_duration_ms: 0, + steps: [], + }; + this.replays = []; + this.closed = false; + this.pauseMode = null; + this.manualPauseRevision = 0; + this.createdAt = Date.now(); + this.idleTimeoutMs = Number(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS); + this.idleTimer = null; + // Serializes work per session. One Playwright page, one shared step + // counter: concurrent steps would interleave keypresses and collide on + // output filenames. Subagents sharing a session_id hit this too. + this.queue = Promise.resolve(); + } + + async start() { + await this.browser.start(); + this.playthrough.launch_url = this.browser.launchUrl; + this.persistPlaythrough(); + this.touch(); + return this; + } + + // Every tool call runs through here, so ordering is guaranteed even when + // several agents hold the same session id. + run(fn) { + const result = this.queue.then(() => { + if (this.closed) throw new Error(`session ${this.id} has ended`); + return fn(); + }); + this.queue = result.then(() => undefined, () => undefined); + return result; + } + + // An agent that forgets to call end_game would otherwise leak Chromium for + // the lifetime of the server. + touch() { + if (this.idleTimer) clearTimeout(this.idleTimer); + if (!this.idleTimeoutMs || this.closed) return; + this.idleTimer = setTimeout(() => { + this.close().catch(() => {}); + }, this.idleTimeoutMs); + if (typeof this.idleTimer.unref === 'function') this.idleTimer.unref(); + } + + // Append-only text log. This is how an agent re-orients after its context is + // compacted, without paying to replay screenshots. + note(entry) { + this.journal.push({ turn: this.turn, at: Date.now() - this.createdAt, ...entry }); + return this.journal[this.journal.length - 1]; + } + + nextStepIndex() { + this.stepIndex += 1; + this.turn += 1; + return this.stepIndex; + } + + actionDir(name) { + return this.output.actionDir(name); + } + + // The live session writes screenshots and per-step result files. This + // compact, atomically replaced file is the reproducible input program used + // to render a clean playthrough in a separate browser later. + persistPlaythrough() { + const temporaryPath = `${this.paths.playthrough}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(this.playthrough, null, 2)); + fs.renameSync(temporaryPath, this.paths.playthrough); + return this.paths.playthrough; + } + + appendPlaythroughStep({ duration, actions, note }) { + const entry = { + index: this.playthrough.steps.length + 1, + duration_ms: Number(duration), + actions: actions.map(canonicalReplayAction), + ...(note ? { note: String(note) } : {}), + }; + this.playthrough.steps.push(entry); + this.playthrough.total_duration_ms += entry.duration_ms; + this.persistPlaythrough(); + return entry; + } + + resetPlaythrough() { + this.playthrough = { + ...this.playthrough, + attempt: this.playthrough.attempt + 1, + created_at: new Date().toISOString(), + total_duration_ms: 0, + steps: [], + }; + return this.persistPlaythrough(); + } + + async close() { + if (this.closed) return; + this.closed = true; + if (this.idleTimer) clearTimeout(this.idleTimer); + await this.browser.close(); + } + + // A manual pause is an override: normal gameplay calls must not silently + // resume it. Automatic pauses are released by the next act/reset call. + async pause({ mode = 'automatic', reason } = {}) { + if (this.closed) throw new Error(`session ${this.id} has ended`); + if (this.pauseMode === 'manual' && mode !== 'manual') return this.pauseMode; + if (this.pauseMode === mode) return this.pauseMode; + if (mode === 'manual') { + // Publish the override before awaiting the renderer so a concurrently + // starting act/reset cannot resume past it. + const previousMode = this.pauseMode; + this.manualPauseRevision += 1; + this.pauseMode = 'manual'; + try { + await this.browser.pause(); + } catch (error) { + if (this.pauseMode === 'manual') this.pauseMode = previousMode; + throw error; + } + } else { + await this.browser.pause(); + if (this.pauseMode === 'manual') return this.pauseMode; + this.pauseMode = mode; + } + if (mode === 'manual') this.note({ event: 'pause', mode, ...(reason ? { reason } : {}) }); + this.touch(); + return this.pauseMode; + } + + async resume({ force = false, reason } = {}) { + if (this.closed) throw new Error(`session ${this.id} has ended`); + if (this.pauseMode === 'manual' && !force) { + throw new Error('game is manually paused; call resume_game before sending gameplay input'); + } + if (this.pauseMode === null) return false; + const previousMode = this.pauseMode; + this.pauseMode = null; + const manualPauseRevision = this.manualPauseRevision; + await this.browser.resume(); + if (manualPauseRevision !== this.manualPauseRevision || this.pauseMode === 'manual') { + await this.browser.pause(); + this.pauseMode = 'manual'; + throw new Error('game was manually paused while resume was in progress'); + } + if (previousMode === 'manual' || force) { + this.note({ event: 'resume', ...(reason ? { reason } : {}) }); + } + this.touch(); + return true; + } + + async prepareForGameplay(reason = 'act') { + if (this.pauseMode === 'manual') { + throw new Error('game is manually paused; call resume_game before sending gameplay input'); + } + if (this.pauseMode === 'automatic') await this.resume({ reason }); + } + + async pauseForAgent(reason = 'awaiting_agent') { + if (this.pauseMode === 'manual') return this.pauseMode; + return this.pause({ mode: 'automatic', reason }); + } + + summary() { + return { + session_id: this.id, + url: this.browser.launchUrl, + viewport: this.config.viewport, + turns: this.turn, + steps: this.stepIndex, + paused: this.pauseMode !== null, + pause_mode: this.pauseMode, + uptime_ms: Date.now() - this.createdAt, + run_dir: this.paths.runDir, + playthrough_path: this.paths.playthrough, + playthrough_steps: this.playthrough.steps.length, + replay_count: this.replays.length, + closed: this.closed, + }; + } +} + +function canonicalPoint(point) { + return { x: Number(point.x), y: Number(point.y) }; +} + +function canonicalReplayAction(action) { + const common = { + type: action.type, + start: Number(action.start), + end: Number(action.end), + }; + if (action.type === 'key') { + return { ...common, key: action.resolvedKey || action.key }; + } + if (action.type === 'click') { + return { + ...common, + x: Number(action.x), + y: Number(action.y), + button: action.button, + clickCount: Number(action.clickCount), + }; + } + if (action.type === 'drag') { + return { + ...common, + from: canonicalPoint(action.from), + to: canonicalPoint(action.to), + button: action.button, + mode: action.mode, + steps: Number(action.steps), + }; + } + if (action.type === 'cursor_move') { + return { ...common, to: canonicalPoint(action.to), steps: Number(action.steps) }; + } + if (action.type === 'view_move') { + return { + ...common, + dx: Number(action.dx), + dy: Number(action.dy), + steps: Number(action.steps), + }; + } + if (action.type === 'focus_game') { + return { + ...common, + x: Number(action.x), + y: Number(action.y), + button: action.button, + }; + } + throw new Error(`cannot persist unsupported replay action: ${action.type}`); +} + +function newSessionId() { + return `game-${timestamp()}`; +} + +module.exports = { + DEFAULT_IDLE_TIMEOUT_MS, + Session, + canonicalReplayAction, + newSessionId, +}; diff --git a/mcp/src/state.js b/mcp/src/state.js new file mode 100644 index 0000000..b68e153 --- /dev/null +++ b/mcp/src/state.js @@ -0,0 +1,44 @@ +'use strict'; + +// Runwave's raw state carries a full WebGL renderer probe and every canvas on +// the page. Useful for a playtest report, mostly noise for an agent deciding a +// next move, and it is paid for on every single turn. Only the fields that +// change a decision survive. +function compactState(raw) { + const generic = (raw && raw.generic) || raw || {}; + const canvases = Array.isArray(generic.canvases) ? generic.canvases : []; + const state = {}; + if (generic.title) state.title = generic.title; + if (generic.url) state.url = generic.url; + + const active = generic.activeElement; + if (active && active.tagName && active.tagName !== 'BODY') { + state.focus = [active.tagName, active.id ? `#${active.id}` : ''].filter(Boolean).join(''); + } + + // The largest canvas is almost always the game surface. Its client rect tells + // an agent which part of the viewport is actually playable. + const surface = canvases + .filter((canvas) => canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0) + .sort((left, right) => right.clientWidth * right.clientHeight - left.clientWidth * left.clientHeight)[0]; + if (surface) { + state.pointer_locked = Boolean(generic.pointerLockElement && generic.pointerLockElement.tagName); + state.game_area = { + x: Math.round(surface.left ?? surface.x ?? 0), + y: Math.round(surface.top ?? surface.y ?? 0), + width: Math.round(surface.clientWidth), + height: Math.round(surface.clientHeight), + }; + } + if (canvases.length > 1) state.canvas_count = canvases.length; + + // A stateExpression is opt-in and game-specific, so whatever it returns is + // assumed relevant and passed through intact. + if (raw && raw.custom !== undefined) state.custom = raw.custom; + if (raw && raw.customError) state.custom_error = String(raw.customError).slice(0, 300); + return state; +} + +module.exports = { + compactState, +}; diff --git a/mcp/src/tools-aux.js b/mcp/src/tools-aux.js new file mode 100644 index 0000000..49dcc07 --- /dev/null +++ b/mcp/src/tools-aux.js @@ -0,0 +1,270 @@ +'use strict'; + +const { z } = require('zod'); +const { region } = require('./schema'); +const { captureFrame, frameResult } = require('./frame'); +const { renderPlaythrough } = require('./replay'); +const { errorResult, pauseNote, textBlock } = require('./result'); + +function registerZoom(server, registry) { + server.registerTool('zoom', { + title: 'Zoom', + description: 'Screenshot a rectangle of the viewport at full resolution. Use this to read small UI or confirm a target before clicking, instead of paying for a full-resolution frame.', + inputSchema: { + session_id: z.string(), + region: region.describe('Viewport rectangle in real pixels.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + try { + await session.pauseForAgent('awaiting_agent'); + const name = `zoom-${String(session.turn).padStart(3, '0')}`; + const { file } = await captureFrame(session, { name, grid: false }); + const state = await session.browser.state(session.config.stateExpression); + return frameResult({ + file, margin: 0, state, fullRes: true, region: args.region, + label: `zoom ${args.region.width}x${args.region.height} at (${args.region.x},${args.region.y})`, + extra: [pauseNote(session.pauseMode)], + }); + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); +} + +function registerCapture(server, registry) { + server.registerTool('capture', { + title: 'Capture deliverable', + description: 'Save a clean, full-resolution, un-annotated screenshot to disk and return its path. Use this for the final artifact once the target is reached.', + inputSchema: { + session_id: z.string(), + name: z.string().describe('File label, e.g. "target-reached".'), + preview: z.boolean().optional().describe('Also return a downscaled preview to confirm what was saved.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + try { + await session.pauseForAgent('awaiting_agent'); + const { file } = await captureFrame(session, { name: `capture-${args.name}`, grid: false }); + session.note({ event: 'capture', name: args.name, path: file }); + const summary = textBlock(`saved ${session.config.viewport.width}x${session.config.viewport.height} clean capture\npath: ${file}\n${pauseNote(session.pauseMode)}`); + if (!args.preview) return { content: [summary] }; + const state = await session.browser.state(session.config.stateExpression); + const preview = frameResult({ file, margin: 0, state, label: 'saved' }); + return { content: [summary, ...preview.content] }; + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); +} + +function registerSessionTools(server, registry) { + server.registerTool('focus_game', { + title: 'Focus game controls', + description: 'Acquire pointer lock on the game canvas without moving the FPS camera. Call this when state reports pointer_locked=false before using view_move. Middle click avoids firing in most games; retry with left only if the game requires it.', + inputSchema: { + session_id: z.string(), + button: z.enum(['left', 'middle', 'right']).optional() + .describe('Button used to acquire focus. Default middle.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + let focus; + try { + await session.prepareForGameplay('focus_game'); + session.nextStepIndex(); + focus = await session.browser.focusGame({ button: args.button || 'middle' }); + if (!focus.pointerLocked) { + throw new Error(`game canvas did not acquire pointer lock with ${focus.button} click`); + } + } finally { + await session.pauseForAgent('awaiting_agent'); + } + session.appendPlaythroughStep({ + duration: 75, + actions: [{ + type: 'focus_game', + start: 0, + end: 75, + x: focus.x, + y: focus.y, + button: focus.button, + }], + note: 'acquire pointer lock without camera movement', + }); + session.note({ event: 'focus_game', button: focus.button, pointer_locked: true }); + const { file } = await captureFrame(session, { + name: `focus-${String(session.stepIndex).padStart(3, '0')}`, + grid: false, + }); + session.lastFrame = file; + const state = await session.browser.state(session.config.stateExpression); + return frameResult({ + file, margin: 0, state, label: 'game focused', + extra: [pauseNote(session.pauseMode)], + }); + }); + } catch (error) { + return errorResult(error); + } + }); + + server.registerTool('reset_game', { + title: 'Reset game', + description: 'Reload the game at its launch URL. Use this when stuck or to start a fresh attempt.', + inputSchema: { session_id: z.string() }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + try { + await session.prepareForGameplay('reset_game'); + await session.browser.navigate({ url: session.browser.launchUrl }); + session.stepIndex = 0; + session.resetPlaythrough(); + session.note({ event: 'reset' }); + await session.pauseForAgent('awaiting_agent'); + const { file } = await captureFrame(session, { name: `reset-${session.turn}`, grid: false }); + session.lastFrame = file; + const state = await session.browser.state(session.config.stateExpression); + return frameResult({ + file, margin: 0, state, label: 'after reset', + extra: [pauseNote(session.pauseMode)], + }); + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); + + // Cheap way back into context after a compaction: text only, no frames. + server.registerTool('journal', { + title: 'Journal', + description: 'Read the log of what has been tried this session. Use this to re-orient without replaying screenshots.', + inputSchema: { + session_id: z.string(), + limit: z.number().int().positive().max(200).optional().describe('Most recent entries to return. Default 40.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + const limit = args.limit ?? 40; + const entries = session.journal.slice(-limit); + const lines = entries.map((entry) => { + const seconds = (entry.at / 1000).toFixed(1); + const rest = Object.entries(entry) + .filter(([key]) => !['turn', 'at', 'event'].includes(key)) + .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join('+') : value}`) + .join(' '); + return `[${seconds}s] turn ${entry.turn} ${entry.event}${rest ? ` ${rest}` : ''}`; + }); + const header = `${session.journal.length} entries, showing last ${entries.length}`; + return { content: [textBlock([header, ...lines].join('\n'))] }; + } catch (error) { + return errorResult(error); + } + }); + + server.registerTool('list_sessions', { + title: 'List sessions', + description: 'List running game sessions.', + inputSchema: {}, + }, async () => { + const sessions = registry.list(); + if (!sessions.length) return { content: [textBlock('no sessions running')] }; + return { content: [textBlock(JSON.stringify(sessions, null, 2))] }; + }); + + server.registerTool('end_game', { + title: 'End game', + description: 'Close the browser and stop the game process. Always call this when finished.', + inputSchema: { session_id: z.string() }, + }, async (args) => { + try { + const summary = await registry.end(args.session_id); + return { content: [textBlock(JSON.stringify(summary, null, 2))] }; + } catch (error) { + return errorResult(error); + } + }); + + server.registerTool('render_playthrough', { + title: 'Render clean playthrough', + description: 'Replay the successful act timeline in a fresh isolated browser and record a smooth game-only WebM. The live agent session remains paused, so model reasoning and screenshot latency do not appear in the video.', + inputSchema: { + session_id: z.string(), + tail_ms: z.number().int().min(0).max(5000).optional() + .describe('Extra live time after the final action. Default 1000ms.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + try { + await session.pauseForAgent('render_playthrough'); + const replay = await renderPlaythrough(session, { tailMs: args.tail_ms ?? 1000 }); + session.note({ + event: 'render_playthrough', + steps: replay.step_count, + video: replay.video, + }); + return { content: [textBlock(JSON.stringify(replay, null, 2))] }; + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); + + server.registerTool('pause_game', { + title: 'Pause game immediately', + description: 'Immediately pause the browser game at the harness level. This is a higher-authority override and does not wait behind an in-progress gameplay call. Call resume_game before act if you paused manually.', + inputSchema: { + session_id: z.string(), + reason: z.string().optional().describe('Why the pause was requested.'), + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + await session.pause({ mode: 'manual', reason: args.reason || 'manual_pause' }); + return { content: [textBlock(JSON.stringify(session.summary(), null, 2))] }; + } catch (error) { + return errorResult(error); + } + }); + + server.registerTool('resume_game', { + title: 'Resume game', + description: 'Release a manual pause so the next act or reset_game call can continue gameplay.', + inputSchema: { session_id: z.string() }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + await session.resume({ force: true, reason: 'manual_resume' }); + return { content: [textBlock(JSON.stringify(session.summary(), null, 2))] }; + } catch (error) { + return errorResult(error); + } + }); +} + +module.exports = { registerCapture, registerSessionTools, registerZoom }; diff --git a/mcp/src/tools-play.js b/mcp/src/tools-play.js new file mode 100644 index 0000000..3f7295a --- /dev/null +++ b/mcp/src/tools-play.js @@ -0,0 +1,218 @@ +'use strict'; + +const { z } = require('zod'); +const { inferDurationFromRawActions } = require('../../runwave/controller/src/action-normalizer'); +const { runStep } = require('../../runwave/controller/src/step-runner'); +const { action } = require('./schema'); +const { captureFrame, frameResult } = require('./frame'); +const { errorResult, pauseNote } = require('./result'); +const { changedSince } = require('./diff'); +const { applyGrid } = require('./frame'); + +// Returning several frames from one turn is occasionally worth it to see a +// trajectory, but each one costs context, so the count is capped. +const MAX_FRAMES_PER_TURN = 4; + +const frameOptions = { + full_res: z.boolean().optional().describe('Return the frame at full resolution. Costs ~4x the context of the default.'), + grid: z.boolean().optional().describe('Overlay a labelled row/column grid to help aim. Adds a label margin around the image.'), +}; + +function registerPlayTools(server, registry) { + server.registerTool('launch_game', { + title: 'Launch game', + description: 'Start a headless browser game session and return the first frame. Provide either url, or game_dir plus port for a directory containing start.sh.', + inputSchema: { + url: z.string().optional().describe('URL to open, e.g. http://127.0.0.1:3000/'), + game_dir: z.string().optional().describe('Directory containing start.sh. Launched with the given port.'), + port: z.number().int().positive().optional().describe('Port for game_dir, also used to build the URL.'), + viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(), + session_id: z.string().optional().describe('Reuse a specific id. Generated when omitted.'), + state_expression: z.string().optional().describe('JS expression evaluated in the page each turn for game-specific state.'), + ...frameOptions, + }, + }, async (args) => { + try { + if (!args.url && !args.game_dir) throw new Error('launch_game requires url or game_dir'); + const session = await registry.create({ + url: args.url, + gameDir: args.game_dir, + port: args.port, + viewport: args.viewport, + sessionId: args.session_id, + stateExpression: args.state_expression, + }); + return session.run(async () => { + try { + await session.pauseForAgent('awaiting_agent'); + const { file, margin } = await captureFrame(session, { name: 'launch', grid: args.grid }); + session.lastFrame = file; + session.note({ event: 'launch', url: session.browser.launchUrl }); + const state = await session.browser.state(session.config.stateExpression); + return frameResult({ + file, margin, state, fullRes: args.full_res, label: 'initial', + extra: [ + `session_id: ${session.id}`, + `viewport: ${session.config.viewport.width}x${session.config.viewport.height}`, + pauseNote(session.pauseMode), + ], + }); + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); +} + +// runStep writes clean captures. The grid, when asked for, is applied afterwards +// to the frames actually being returned, so the on-disk originals stay usable. +function actResult({ session, step, args, previousFrame, actionName }) { + const captures = Array.isArray(step.captures) ? step.captures : []; + if (!captures.length) throw new Error('step produced no frames'); + const wanted = captures.slice(-MAX_FRAMES_PER_TURN); + const finalCapture = wanted[wanted.length - 1]; + session.lastFrame = finalCapture.path; + + const changed = changedSince(previousFrame, finalCapture.path); + session.note({ + event: 'act', + action: actionName, + duration_ms: step.duration, + inputs: step.actions.map((item) => item.type === 'key' ? item.key : item.type), + changed, + ...(args.note ? { intent: args.note } : {}), + }); + + const content = []; + for (const capture of wanted.slice(0, -1)) { + const frame = frameResult({ + file: capture.path, margin: 0, state: capture.state, + fullRes: args.full_res, label: `t=${capture.at}ms`, + }); + content.push(...frame.content); + } + const last = frameResult({ + file: finalCapture.path, + margin: args.grid ? applyGrid(session, finalCapture.path) : 0, + state: step.endState, + fullRes: args.full_res, + label: wanted.length > 1 ? `t=${finalCapture.at}ms` : null, + extra: [ + `sequence ran ${step.duration}ms`, + changed === false + ? 'frame is byte-identical to the previous one: the input probably did not reach the game. Check focus, try a different key, or hold it longer.' + : null, + pauseNote(session.pauseMode), + ], + }); + session.touch(); + return { content: [...content, ...last.content] }; +} + +function registerObserve(server, registry) { + server.registerTool('observe', { + title: 'Observe', + description: 'Take a fresh screenshot and read game state without sending any input.', + inputSchema: { + session_id: z.string(), + ...frameOptions, + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + try { + await session.pauseForAgent('awaiting_agent'); + const name = `observe-${String(session.turn).padStart(3, '0')}`; + const { file, margin } = await captureFrame(session, { name, grid: args.grid }); + const state = await session.browser.state(session.config.stateExpression); + session.lastFrame = file; + return frameResult({ + file, margin, state, fullRes: args.full_res, + extra: [pauseNote(session.pauseMode)], + }); + } finally { + await session.pauseForAgent('awaiting_agent'); + } + }); + } catch (error) { + return errorResult(error); + } + }); +} + +function registerAct(server, registry) { + server.registerTool('act', { + title: 'Act', + description: [ + 'Send a timed sequence of inputs, then return the resulting frame.', + 'Offsets are milliseconds from the start of the sequence and actions may overlap, so one call can express "hold right for 900ms and jump at 150ms".', + 'This is the main way to play. When direction, distance, or collision is uncertain, use a short 300-1200ms probe and inspect the result. Batch longer sequences only after the route or target is verified.', + 'For uncertain actions longer than about 1500ms, request 2-3 trajectory captures so repeated landmarks or a filled screen reveal a collision before the whole sequence is wasted.', + ].join(' '), + inputSchema: { + session_id: z.string(), + actions: z.array(action).describe('Inputs to run. May be empty only when duration_ms is positive, to advance a load or animation without input.'), + duration_ms: z.number().min(0).max(8000).optional() + .describe('Total live-game time for the sequence. Use this to let a click-triggered load or animation settle before the final pause.'), + captures: z.array(z.number().min(0)).max(MAX_FRAMES_PER_TURN).optional() + .describe('Offsets in ms to screenshot at. Defaults to the end. For uncertain navigation longer than ~1500ms, use 2-3 spaced offsets to detect collisions or missed turns; omit them on verified traversal to save context.'), + note: z.string().optional().describe('Short intent for the journal, e.g. "cross bridge east".'), + ...frameOptions, + }, + }, async (args) => { + try { + if (!args.actions.length && !(args.duration_ms > 0)) { + throw new Error('act requires at least one input or a positive duration_ms'); + } + const session = registry.get(args.session_id); + return session.run(async () => { + let step; + let previousFrame; + let actionName; + try { + await session.prepareForGameplay('act'); + const stepIndex = session.nextStepIndex(); + actionName = `act-${String(stepIndex).padStart(3, '0')}`; + previousFrame = session.lastFrame; + const duration = args.duration_ms ?? inferDurationFromRawActions(args.actions); + const captures = args.captures + ? [...args.captures, duration] + : undefined; + step = await runStep({ + input: { + action: 'step', + action_name: actionName, + actions: args.actions, + ...(args.duration_ms !== undefined ? { duration: args.duration_ms } : {}), + ...(captures ? { captures } : {}), + autoCaptures: false, + }, + config: session.config, + browser: session.browser, + outputDir: session.actionDir(actionName), + nextStepIndex: session.stepIndex, + actionName, + beforeEndCapture: () => session.pauseForAgent('awaiting_agent'), + profiler: null, + }); + } finally { + await session.pauseForAgent('awaiting_agent'); + } + session.appendPlaythroughStep({ + duration: step.duration, + actions: step.actions, + note: args.note, + }); + return actResult({ session, step, args, previousFrame, actionName }); + }); + } catch (error) { + return errorResult(error); + } + }); +} + +module.exports = { MAX_FRAMES_PER_TURN, frameOptions, registerAct, registerObserve, registerPlayTools }; diff --git a/mcp/test/fixtures/game/index.html b/mcp/test/fixtures/game/index.html new file mode 100644 index 0000000..acaf3de --- /dev/null +++ b/mcp/test/fixtures/game/index.html @@ -0,0 +1,62 @@ + + + + + MCP Test Game + + + + + + + diff --git a/mcp/test/integration.test.js b/mcp/test/integration.test.js new file mode 100644 index 0000000..ebb224b --- /dev/null +++ b/mcp/test/integration.test.js @@ -0,0 +1,342 @@ +'use strict'; + +// Drives a real headless Chromium against a real game page through the MCP +// tool surface. Skipped automatically when Chromium's system libraries are +// missing, so the suite still runs on a bare machine. + +const assert = require('node:assert/strict'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { pathToFileURL } = require('node:url'); + +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { InMemoryTransport } = require('@modelcontextprotocol/sdk/inMemory.js'); +const { chromium } = require('playwright'); +const { SessionRegistry } = require('../src/registry'); +const { createServer } = require('../src/server'); +const { captureFrame } = require('../src/frame'); +const { changedSince } = require('../src/diff'); +const { compactState } = require('../src/state'); +const { imageBlock } = require('../src/image'); +const { runStep } = require('../../runwave/controller/src/step-runner'); + +const GAME_URL = pathToFileURL(path.join(__dirname, 'fixtures', 'game', 'index.html')).href; +const VIEWPORT = { width: 640, height: 360 }; + +async function chromiumUsable() { + try { + const browser = await chromium.launch({ headless: true }); + await browser.close(); + return true; + } catch { + return false; + } +} + +test('MCP session plays a real browser game', async (t) => { + if (!(await chromiumUsable())) { + t.skip('chromium cannot launch here; run "npx playwright install-deps chromium"'); + return; + } + const workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'runwave-mcp-it-')); + const registry = new SessionRegistry({ workspace }); + t.after(async () => { + await registry.closeAll(); + await fsp.rm(workspace, { recursive: true, force: true }); + }); + + const session = await registry.create({ + url: GAME_URL, + viewport: VIEWPORT, + stateExpression: '() => window.gameState', + }); + + await t.test('launch returns a usable downscaled frame', async () => { + const { file } = await captureFrame(session, { name: 'launch', grid: false }); + session.lastFrame = file; + const image = imageBlock(file); + assert.equal(image.sourceWidth, VIEWPORT.width, 'capture must match the viewport exactly'); + assert.equal(image.width, VIEWPORT.width / 2, 'frames are halved by default to save context'); + assert.ok(image.block.data.length > 100, 'image block must carry real base64 payload'); + assert.equal(image.block.mimeType, 'image/png'); + }); + + await t.test('state exposes the game canvas and custom state', async () => { + const state = compactState(await session.browser.state(session.config.stateExpression)); + assert.deepEqual(state.game_area, { x: 0, y: 0, width: VIEWPORT.width, height: VIEWPORT.height }); + assert.equal(state.custom.hits, 0); + assert.equal(state.webgl, undefined, 'renderer probe must be stripped from per-turn state'); + }); + + await t.test('a held key moves the player and the frame changes', async () => { + const before = await session.browser.state(session.config.stateExpression); + const previousFrame = session.lastFrame; + const stepIndex = session.nextStepIndex(); + const actionName = `act-${stepIndex}`; + const step = await runStep({ + input: { + action: 'step', + action_name: actionName, + actions: [{ type: 'key', start: 0, end: 600, key: 'ArrowRight' }], + autoCaptures: false, + }, + config: session.config, + browser: session.browser, + outputDir: session.actionDir(actionName), + nextStepIndex: stepIndex, + actionName, + beforeEndCapture: () => session.pauseForAgent('integration test'), + profiler: null, + }); + + assert.equal(step.captures.length, 1, 'one frame per turn unless more are requested'); + const after = step.endState; + assert.ok( + after.custom.x > before.custom.x + 20, + `holding right must move the player: ${before.custom.x} -> ${after.custom.x}` + ); + assert.equal(changedSince(previousFrame, step.captures[0].path), true); + assert.equal(await session.browser.isPaused(), true, 'the returned frame must be the pause boundary'); + session.lastFrame = step.captures[0].path; + }); + + await t.test('a grid cell click lands on the intended target', async () => { + // The target sits at x 480-560, y 140-220 in a 640x360 viewport. On a 16x16 + // grid that is columns 12-13 and rows 6-9, so cell (7, 12) must hit it. + const stepIndex = session.nextStepIndex(); + const actionName = `act-${stepIndex}`; + await session.prepareForGameplay('integration test'); + const step = await runStep({ + input: { + action: 'step', + action_name: actionName, + actions: [{ type: 'click', start: 50, overlay_row: 7, overlay_col: 12 }], + autoCaptures: false, + }, + config: session.config, + browser: session.browser, + outputDir: session.actionDir(actionName), + nextStepIndex: stepIndex, + actionName, + beforeEndCapture: () => session.pauseForAgent('integration test'), + profiler: null, + }); + + // Cell centre for (row 7, col 12) on a 16x16 grid over 640x360. + const expected = { + x: Math.round((12 + 0.5) * (VIEWPORT.width / 16)), + y: Math.round((7 + 0.5) * (VIEWPORT.height / 16)), + }; + const click = step.actions.find((item) => item.type === 'click'); + assert.deepEqual({ x: click.x, y: click.y }, expected, 'cell must resolve to its centre'); + assert.ok(click.x >= 480 && click.x <= 560 && click.y >= 140 && click.y <= 220, 'centre must fall inside the target'); + assert.equal(step.endState.custom.hits, 1, 'the click must register on the target'); + assert.equal(step.endState.custom.lit, true); + }); +}); + +test('harness pause freezes page time and blocks gameplay input', async (t) => { + if (!(await chromiumUsable())) { + t.skip('chromium cannot launch here; run "npx playwright install-deps chromium"'); + return; + } + const workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'runwave-mcp-pause-')); + const registry = new SessionRegistry({ workspace }); + const html = ``; + const session = await registry.create({ + url: `data:text/html,${encodeURIComponent(html)}`, + viewport: { width: 320, height: 200 }, + waitAfterLoad: 0, + }); + t.after(async () => { + await registry.closeAll(); + await fsp.rm(workspace, { recursive: true, force: true }); + }); + + await session.browser.page.waitForTimeout(120); + await session.pause({ mode: 'manual', reason: 'integration test' }); + const pausedAt = await session.browser.page.evaluate(() => ({ ticks, frames, now: performance.now() })); + await session.browser.page.waitForTimeout(250); + const frozen = await session.browser.page.evaluate(() => ({ + ticks, + frames, + now: performance.now(), + paused: window.__runwavePauseController.isPaused(), + })); + assert.equal(frozen.paused, true); + assert.equal(frozen.ticks, pausedAt.ticks, 'game timers must not advance while paused'); + assert.equal(frozen.frames, pausedAt.frames, 'animation frames must not advance while paused'); + assert.ok(frozen.now - pausedAt.now < 10, 'logical performance time must remain frozen'); + + await session.browser.keyDown('a'); + await session.browser.keyUp('a'); + assert.equal(await session.browser.page.evaluate(() => keys), 0, 'paused input must not reach the game'); + + await session.resume({ force: true, reason: 'integration test' }); + await session.browser.keyDown('a'); + await session.browser.keyUp('a'); + await session.browser.page.waitForTimeout(120); + const after = await session.browser.page.evaluate(() => ({ ticks, frames, keys, paused: window.__runwavePauseController.isPaused() })); + assert.equal(after.paused, false); + assert.ok(after.ticks > frozen.ticks, 'game timers must resume'); + assert.ok(after.frames > frozen.frames, 'animation frames must resume'); + assert.equal(after.keys, 1, 'resumed input must reach the game'); +}); + +test('pointer-locked view movement reaches games as one trusted delta', async (t) => { + if (!(await chromiumUsable())) { + t.skip('chromium cannot launch here; run "npx playwright install-deps chromium"'); + return; + } + const workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'runwave-mcp-pointer-lock-')); + const registry = new SessionRegistry({ workspace }); + const html = ``; + const session = await registry.create({ + url: `data:text/html,${encodeURIComponent(html)}`, + viewport: VIEWPORT, + waitAfterLoad: 0, + }); + t.after(async () => { + await registry.closeAll(); + await fsp.rm(workspace, { recursive: true, force: true }); + }); + + await session.browser.page.mouse.move(200, 180); + session.browser.mousePosition = { x: 200, y: 180 }; + await session.browser.page.evaluate(() => { window.moves = []; }); + const focus = await session.browser.focusGame({ button: 'middle' }); + assert.equal(focus.pointerLocked, true); + assert.deepEqual( + await session.browser.page.evaluate(() => window.moves), + [], + 'acquiring pointer lock must not rotate the game camera' + ); + + await session.browser.moveView({ dx: 24, dy: -9, steps: 1 }); + await session.browser.page.waitForTimeout(50); + assert.deepEqual(await session.browser.page.evaluate(() => window.moves), [ + { x: 24, y: -9, trusted: true }, + ]); +}); + +test('MCP tool loop pauses every returned frame and resumes only for act', async (t) => { + if (!(await chromiumUsable())) { + t.skip('chromium cannot launch here; run "npx playwright install-deps chromium"'); + return; + } + const workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'runwave-mcp-tools-pause-')); + const { server, registry } = createServer({ workspace }); + const client = new Client({ name: 'runwave-pause-integration', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + t.after(async () => { + await registry.closeAll(); + await client.close().catch(() => {}); + await server.close().catch(() => {}); + await fsp.rm(workspace, { recursive: true, force: true }); + }); + + const textOf = (result) => result.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n'); + const stateOf = (result) => { + const match = textOf(result).match(/state: (\{.*\})/); + assert.ok(match, `missing state in result: ${textOf(result)}`); + return JSON.parse(match[1]); + }; + const html = '
0
'; + const launch = await client.callTool({ + name: 'launch_game', + arguments: { + url: `data:text/html,${encodeURIComponent(html)}`, + viewport: { width: 320, height: 200 }, + state_expression: '() => ({ ticks: window.ticks })', + full_res: true, + }, + }); + const sessionId = textOf(launch).match(/session_id: (\S+)/)[1]; + const session = registry.get(sessionId); + const initialTicks = stateOf(launch).custom.ticks; + assert.equal(session.pauseMode, 'automatic'); + assert.equal(await session.browser.isPaused(), true); + + await new Promise((resolve) => setTimeout(resolve, 150)); + const still = await client.callTool({ name: 'observe', arguments: { session_id: sessionId, full_res: true } }); + assert.equal(stateOf(still).custom.ticks, initialTicks, 'the game must not advance during model reasoning'); + + const invalidWait = await client.callTool({ + name: 'act', + arguments: { session_id: sessionId, actions: [] }, + }); + assert.equal(invalidWait.isError, true); + assert.match(textOf(invalidWait), /positive duration_ms/); + + const acted = await client.callTool({ + name: 'act', + arguments: { + session_id: sessionId, + actions: [], + duration_ms: 160, + full_res: true, + }, + }); + const actedTicks = stateOf(acted).custom.ticks; + assert.ok(actedTicks > initialTicks, 'act must resume the game for its controlled duration'); + assert.equal(session.pauseMode, 'automatic'); + assert.equal(await session.browser.isPaused(), true); + + await new Promise((resolve) => setTimeout(resolve, 150)); + const after = await client.callTool({ name: 'observe', arguments: { session_id: sessionId, full_res: true } }); + assert.equal(stateOf(after).custom.ticks, actedTicks, 'the final act frame must be the pause boundary'); + + const persisted = JSON.parse(await fsp.readFile(session.paths.playthrough, 'utf8')); + assert.equal(persisted.steps.length, 1); + assert.equal(persisted.steps[0].duration_ms, 160); + assert.deepEqual(persisted.steps[0].actions, []); + + const beforeRenderTicks = await session.browser.page.evaluate(() => ticks); + const rendered = await client.callTool({ + name: 'render_playthrough', + arguments: { session_id: sessionId, tail_ms: 100 }, + }); + assert.notEqual(rendered.isError, true, textOf(rendered)); + const replay = JSON.parse(textOf(rendered)); + assert.equal(replay.recording_backend, 'playwright'); + assert.equal(replay.step_count, 1); + assert.ok((await fsp.stat(replay.video)).size > 0, 'replay must produce a non-empty WebM'); + assert.ok((await fsp.stat(replay.source_playthrough)).size > 0, 'replay must retain its exact timeline snapshot'); + assert.ok((await fsp.stat(replay.manifest)).size > 0, 'replay must produce a manifest'); + assert.equal(await session.browser.page.evaluate(() => ticks), beforeRenderTicks, 'rendering must not advance the live game'); + assert.equal(await session.browser.isPaused(), true, 'live game remains paused while the separate replay renders'); + + await client.callTool({ name: 'pause_game', arguments: { session_id: sessionId, reason: 'override test' } }); + const blocked = await client.callTool({ + name: 'act', + arguments: { session_id: sessionId, actions: [{ type: 'key', start: 0, end: 50, key: 'a' }] }, + }); + assert.equal(blocked.isError, true); + assert.match(textOf(blocked), /resume_game/); + await client.callTool({ name: 'resume_game', arguments: { session_id: sessionId } }); + await client.callTool({ name: 'end_game', arguments: { session_id: sessionId } }); +}); diff --git a/mcp/test/unit.test.js b/mcp/test/unit.test.js new file mode 100644 index 0000000..3a90665 --- /dev/null +++ b/mcp/test/unit.test.js @@ -0,0 +1,271 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { PNG } = require('pngjs'); + +const { buildSessionConfig, normalizeViewport } = require('../src/config'); +const { clampScale, crop, resize } = require('../src/image'); +const { compactState } = require('../src/state'); +const { action } = require('../src/schema'); +const { Session, canonicalReplayAction } = require('../src/session'); +const { replayConfigFor, replayStep } = require('../src/replay'); +const { normalizeActions } = require('../../runwave/controller/src/action-normalizer'); + +function solidPng(width, height, color = [10, 20, 30]) { + const png = new PNG({ width, height }); + for (let i = 0; i < width * height; i += 1) { + const idx = i << 2; + png.data[idx] = color[0]; + png.data[idx + 1] = color[1]; + png.data[idx + 2] = color[2]; + png.data[idx + 3] = 255; + } + return png; +} + +test('session config disables recording and grid overlay by default', () => { + const config = buildSessionConfig({ url: 'http://127.0.0.1:1/' }); + assert.equal(config.record, false, 'recording must be off so gstreamer is never required'); + assert.equal(config.headless, true); + assert.equal(config.gridScreenshots, false, 'overlay must be opt-in to keep image and input coordinates aligned'); + assert.equal(config.autoCaptures, false); + assert.equal(config.pauseController, true); + assert.equal(config.maxActionSpanMs.click, 2000); +}); + +test('MCP sessions allow a bounded held mouse button without changing normal Runwave clicks', () => { + const heldClick = [{ type: 'click', start: 0, end: 1500, x: 10, y: 20 }]; + assert.doesNotThrow(() => normalizeActions( + heldClick, + 1600, + { strict: true, config: buildSessionConfig(), aliases: {} } + )); + assert.throws( + () => normalizeActions(heldClick, 1600, { strict: true, config: {}, aliases: {} }), + /click action duration exceeds 100ms/ + ); +}); + +test('session config always carries a numeric viewport so grid cells resolve', () => { + // The daemon passes raw CLI input through, which is why cell actions fail + // there when no viewport was given. Building the config must close that gap. + const config = buildSessionConfig({ url: 'http://127.0.0.1:1/' }); + assert.equal(typeof config.viewport.width, 'number'); + assert.ok(config.viewport.width > 0 && config.viewport.height > 0); + const [click] = normalizeActions( + [{ type: 'click', start: 0, overlay_row: 2, overlay_col: 3 }], + 500, + { strict: true, config, aliases: {}, roundPoints: true } + ); + assert.equal(typeof click.x, 'number'); + assert.equal(typeof click.y, 'number'); +}); + +test('grid cell targets resolve to a stable point so traces replay identically', () => { + const config = buildSessionConfig({ viewport: { width: 1280, height: 720 } }); + const points = new Set(); + for (let i = 0; i < 50; i += 1) { + const [click] = normalizeActions( + [{ type: 'click', start: 0, overlay_row: 6, overlay_col: 7 }], + 500, + { strict: true, config, aliases: {}, roundPoints: true } + ); + points.add(`${click.x},${click.y}`); + } + assert.equal(points.size, 1, `expected one deterministic point, got ${[...points].join(' ')}`); +}); + +test('playtest scatter is preserved when sample mode is not set', () => { + const config = buildSessionConfig({ viewport: { width: 1280, height: 720 } }); + delete config.markGridSampleMode; + const points = new Set(); + for (let i = 0; i < 80; i += 1) { + const [click] = normalizeActions( + [{ type: 'click', start: 0, overlay_row: 6, overlay_col: 7 }], + 500, + { strict: true, config, aliases: {}, roundPoints: true } + ); + points.add(`${click.x},${click.y}`); + } + assert.ok(points.size > 5, 'default runwave behaviour must remain random'); +}); + +test('normalizeViewport falls back on invalid input', () => { + assert.deepEqual(normalizeViewport({ width: 0, height: -4 }), { width: 1280, height: 720 }); + assert.deepEqual(normalizeViewport({ width: 800, height: 600 }), { width: 800, height: 600 }); +}); + +test('resize halves dimensions and clamps scale above one', () => { + const png = solidPng(100, 50); + const half = resize(png, 0.5); + assert.equal(half.width, 50); + assert.equal(half.height, 25); + assert.equal(clampScale(4), 1); + assert.equal(resize(png, 1).width, 100); +}); + +test('resize preserves colour when downscaling a solid image', () => { + const png = solidPng(40, 40, [200, 100, 50]); + const small = resize(png, 0.25); + assert.deepEqual([small.data[0], small.data[1], small.data[2]], [200, 100, 50]); +}); + +test('crop clamps an out-of-bounds region instead of throwing', () => { + const png = solidPng(100, 100); + const { region } = crop(png, { x: 90, y: 90, width: 400, height: 400 }); + assert.deepEqual(region, { x: 90, y: 90, width: 10, height: 10 }); +}); + +test('compactState keeps the largest canvas as the game area and drops noise', () => { + const state = compactState({ + generic: { + title: 'Game', + url: 'http://x/', + activeElement: { tagName: 'BODY' }, + webgl: { renderer: 'SwiftShader', vendor: 'Google', supported: true }, + canvases: [ + { clientWidth: 10, clientHeight: 10, left: 0, top: 0 }, + { clientWidth: 640, clientHeight: 360, left: 20, top: 30 }, + ], + }, + }); + assert.deepEqual(state.game_area, { x: 20, y: 30, width: 640, height: 360 }); + assert.equal(state.pointer_locked, false); + assert.equal(state.canvas_count, 2); + assert.equal(state.webgl, undefined, 'renderer probe is per-turn noise for a player'); + assert.equal(state.focus, undefined, 'a BODY focus carries no signal'); +}); + +test('compactState surfaces custom state and errors from a stateExpression', () => { + assert.equal(compactState({ generic: {}, custom: { score: 7 } }).custom.score, 7); + assert.match(compactState({ generic: {}, customError: 'boom' }).custom_error, /boom/); +}); + +test('act schema accepts every action type the executor implements', () => { + const cases = [ + { type: 'key', start: 0, end: 900, key: 'ArrowRight' }, + { type: 'click', start: 100, x: 10, y: 20 }, + { type: 'click', start: 100, overlay_row: 6, overlay_col: 7 }, + { type: 'multi_click', start: 0, cells: [{ overlay_row: 1, overlay_col: 1 }], count: 5 }, + { type: 'drag', start: 0, end: 500, from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, mode: 'mouse' }, + { type: 'cursor_move', start: 0, x: 5, y: 5, steps: 8 }, + { type: 'view_move', start: 0, end: 400, dx: 120, dy: -20 }, + ]; + for (const item of cases) assert.doesNotThrow(() => action.parse(item), `failed: ${item.type}`); +}); + +test('act schema rejects unknown action types and negative offsets', () => { + assert.throws(() => action.parse({ type: 'scroll', start: 0 })); + assert.throws(() => action.parse({ type: 'key', start: -5, key: 'a' })); +}); + +test('playthrough persistence stores concrete replay-safe inputs atomically', async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-playthrough-unit-')); + const session = new Session({ + id: 'playthrough-test', + workspace, + options: { url: 'about:blank', idleTimeoutMs: 0 }, + }); + session.browser = { close: async () => {} }; + try { + session.appendPlaythroughStep({ + duration: 600, + note: 'move and shoot', + actions: [ + { type: 'key', start: 0, end: 600, key: 'right', resolvedKey: 'ArrowRight' }, + { type: 'click', start: 200, end: 250, x: 321, y: 123, button: 'left', clickCount: 1, cells: [{ row: 1, col: 2 }] }, + ], + }); + + const saved = JSON.parse(fs.readFileSync(session.paths.playthrough, 'utf8')); + assert.equal(saved.total_duration_ms, 600); + assert.equal(saved.steps[0].note, 'move and shoot'); + assert.deepEqual(saved.steps[0].actions, [ + { type: 'key', start: 0, end: 600, key: 'ArrowRight' }, + { type: 'click', start: 200, end: 250, x: 321, y: 123, button: 'left', clickCount: 1 }, + ]); + assert.equal(fs.readdirSync(session.paths.runDir).some((name) => name.endsWith('.tmp')), false); + + session.resetPlaythrough(); + assert.equal(session.playthrough.attempt, 2); + assert.equal(session.playthrough.steps.length, 0); + assert.equal(session.playthrough.total_duration_ms, 0); + } finally { + await session.close(); + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('replay normalization retains step timing without screenshot events', () => { + const session = { + browser: { launchUrl: 'https://example.test/game' }, + config: buildSessionConfig({ url: 'https://example.test/game', viewport: { width: 800, height: 450 } }), + }; + const config = replayConfigFor(session); + const step = replayStep({ + duration_ms: 500, + actions: [canonicalReplayAction({ + type: 'cursor_move', start: 50, end: 300, to: { x: 400, y: 200, cells: [{ row: 1, col: 1 }] }, steps: 8, + })], + }, config, 1); + + assert.equal(config.recordingBackend, 'playwright'); + assert.equal(config.pauseController, true); + assert.equal(config.maxActionSpanMs.click, 2000); + assert.equal(step.duration, 500); + assert.deepEqual(step.cursorMoves[0].to, { x: 400, y: 200 }); +}); + +test('replay accepts the MCP session held-click limit', () => { + const session = { + browser: { launchUrl: 'https://example.test/game' }, + config: buildSessionConfig({ url: 'https://example.test/game' }), + }; + const config = replayConfigFor(session); + assert.doesNotThrow(() => replayStep({ + duration_ms: 1900, + actions: [{ type: 'click', start: 200, end: 1700, x: 640, y: 360 }], + }, config, 1)); +}); + +test('manual pause bypasses queued gameplay and requires an explicit resume', async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-pause-unit-')); + const session = new Session({ + id: 'pause-test', + workspace, + options: { url: 'about:blank', idleTimeoutMs: 0 }, + }); + const calls = []; + session.browser = { + launchUrl: 'about:blank', + pause: async () => { calls.push('pause'); }, + resume: async () => { calls.push('resume'); }, + close: async () => {}, + }; + let release; + try { + const running = session.run(async () => { + calls.push('act-start'); + await new Promise((resolve) => { release = resolve; }); + calls.push('act-end'); + }); + await new Promise((resolve) => setImmediate(resolve)); + + await session.pause({ mode: 'manual', reason: 'interrupt test' }); + assert.deepEqual(calls, ['act-start', 'pause'], 'pause must not wait for the active session queue'); + await assert.rejects(session.prepareForGameplay(), /manually paused.*resume_game/); + + await session.resume({ force: true }); + assert.deepEqual(calls, ['act-start', 'pause', 'resume']); + release(); + await running; + } finally { + if (release) release(); + await session.close(); + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/package-lock.json b/package-lock.json index a78e6e4..79d0119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,508 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", "playwright": "1.61.1", - "pngjs": "7.0.0" + "pngjs": "7.0.0", + "zod": "3.25.76" }, "bin": { "runwave": "runwave/cli.js", - "runwave-controller": "runwave/controller.js" + "runwave-controller": "runwave/controller.js", + "runwave-mcp": "mcp/bin/runwave-mcp.js" }, "engines": { "node": ">=20" } }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -34,6 +525,343 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -72,6 +900,344 @@ "engines": { "node": ">=14.19.0" } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 1a1ed5e..ec7d693 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "main": "runwave/index.js", "bin": { "runwave": "runwave/cli.js", - "runwave-controller": "runwave/controller.js" + "runwave-controller": "runwave/controller.js", + "runwave-mcp": "mcp/bin/runwave-mcp.js" }, "scripts": { "cli": "node runwave/cli.js", "controller": "node runwave/controller.js", "smoke": "npm run test:smoke", - "test": "node --test runwave/protocol/test/*.test.js runwave/agent/test/*.test.js runwave/controller/test/*.test.js stress-test/test/*.test.js runwave/test/*.test.js", + "test": "node --test runwave/protocol/test/*.test.js runwave/agent/test/*.test.js runwave/controller/test/*.test.js stress-test/test/*.test.js runwave/test/*.test.js mcp/test/*.test.js", "test:agent": "node --test runwave/agent/test/*.test.js", "test:all": "npm test && npm run test:py", "test:controller": "node --test runwave/controller/test/*.test.js", @@ -21,6 +22,7 @@ "test:integration": "npm run test:runwave", "test:runwave": "node --test runwave/test/*.test.js", "test:stress-test": "node --test stress-test/test/*.test.js", + "test:mcp": "node --test mcp/test/*.test.js", "test:smoke": "node --test runwave/test/smoke.test.js", "test:py": "PYTHONWARNINGS=error python3 -m unittest discover -s runwavepy/tests -t runwavepy" }, @@ -33,7 +35,9 @@ }, "license": "UNLICENSED", "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", "playwright": "1.61.1", - "pngjs": "7.0.0" + "pngjs": "7.0.0", + "zod": "3.25.76" } } diff --git a/runwave/controller/README.md b/runwave/controller/README.md index 5277922..002b28b 100644 --- a/runwave/controller/README.md +++ b/runwave/controller/README.md @@ -235,10 +235,14 @@ List known sessions: runwave-controller '{"action":"sessions"}' ``` -When `record: true` is set, `stop` returns `video` and `audioVideo` pointing at -the same recorded audio/video WebM. All recording goes through gstreamer - see -the top-level [Requirements](../../README.md#requirements) section for the -mandatory environment (Linux, gstreamer, X server/Xvfb, PulseAudio). +When `record: true` is set, `stop` returns the recorded WebM in `video`. +Recording uses gstreamer by default; see the top-level +[Requirements](../../README.md#requirements) section for the mandatory Linux, +X server/Xvfb, and PulseAudio environment. Web sessions can instead set +`"recordingBackend":"playwright"` to capture the browser viewport directly. +That backend can run headlessly and does not require X11, but is video-only. +`recordAudio: true` continues to require the gstreamer backend and returns the +same audio/video WebM in both `video` and `audioVideo`. ## State diff --git a/runwave/controller/src/action-normalizer.js b/runwave/controller/src/action-normalizer.js index 388499b..b77e5e7 100644 --- a/runwave/controller/src/action-normalizer.js +++ b/runwave/controller/src/action-normalizer.js @@ -4,6 +4,7 @@ const { cellsFromObject, clickBurstTimes, gridSafeSampleRatio, + gridSampleMode, markGridFromConfig, randomPointInCells, viewportFromConfig, @@ -99,7 +100,10 @@ function normalizeTiming(action, type, duration, options) { return invalidTiming(`invalid ${type} interval`, action, options); } - const maxSpan = MAX_ACTION_SPAN_MS[type]; + const configuredMaxSpan = Number(options.config?.maxActionSpanMs?.[type]); + const maxSpan = Number.isFinite(configuredMaxSpan) + ? configuredMaxSpan + : MAX_ACTION_SPAN_MS[type]; if (Number.isFinite(maxSpan) && end - start > maxSpan) { return invalidTiming(`${type} action duration exceeds ${maxSpan}ms`, action, options); } @@ -167,7 +171,8 @@ function normalizePoint(point, label, options) { viewport, grid, Math.random, - gridSafeSampleRatio(options.config || {}) + gridSafeSampleRatio(options.config || {}), + gridSampleMode(options.config || {}) ); } catch (error) { if (options.strict) throw new Error(`${label} ${error.message}`); diff --git a/runwave/controller/src/browser-pause.js b/runwave/controller/src/browser-pause.js new file mode 100644 index 0000000..8beeb6d --- /dev/null +++ b/runwave/controller/src/browser-pause.js @@ -0,0 +1,270 @@ +'use strict'; + +// This script is installed before the game page's own scripts. It pauses the +// browser game rather than sending a game-specific key such as Escape, which +// makes the control independent of the game UI. The clock is virtualized so a +// long model turn does not become a large physics delta when the page resumes. +const BROWSER_PAUSE_INIT_SCRIPT = String.raw` +(() => { + const name = '__runwavePauseController'; + if (window[name]) return; + + const native = { + dateNow: Date.now.bind(Date), + performanceNow: window.performance.now.bind(window.performance), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + setInterval: window.setInterval.bind(window), + clearInterval: window.clearInterval.bind(window), + }; + const state = { + paused: false, + performanceOffset: 0, + dateOffset: 0, + pausePerformanceNow: 0, + pauseDateNow: 0, + nextId: 1, + frames: new Map(), + timers: new Map(), + intervals: new Map(), + pointerMove: null, + suppressPointerMovesUntil: 0, + }; + + const logicalPerformanceNow = () => state.paused + ? state.pausePerformanceNow + : native.performanceNow() - state.performanceOffset; + const logicalDateNow = () => state.paused + ? state.pauseDateNow + : native.dateNow() - state.dateOffset; + + function invoke(record) { + if (typeof record.callback === 'function') { + return record.callback.apply(window, record.args); + } + return (0, eval)(String(record.callback)); + } + + function scheduleFrame(record) { + if (state.paused || record.nativeId !== null) return; + record.nativeId = native.requestAnimationFrame(() => { + record.nativeId = null; + if (state.paused || !state.frames.has(record.id)) return; + state.frames.delete(record.id); + record.callback(logicalPerformanceNow()); + }); + } + + function scheduleTimer(record) { + if (state.paused || record.nativeId !== null) return; + const delay = Math.max(0, record.due - logicalPerformanceNow()); + record.nativeId = native.setTimeout(() => { + record.nativeId = null; + if (state.paused || !state.timers.has(record.id)) return; + state.timers.delete(record.id); + invoke(record); + }, delay); + } + + function scheduleInterval(record) { + if (state.paused || record.nativeId !== null) return; + const delay = Math.max(0, record.due - logicalPerformanceNow()); + record.nativeId = native.setTimeout(() => { + record.nativeId = null; + if (state.paused || !state.intervals.has(record.id)) return; + try { + invoke(record); + } finally { + if (state.intervals.has(record.id)) { + record.due = logicalPerformanceNow() + record.delay; + scheduleInterval(record); + } + } + }, delay); + } + + function cancelRecord(records, id, clear) { + const record = records.get(id); + if (!record) return false; + if (record.nativeId !== null) clear(record.nativeId); + records.delete(id); + return true; + } + + window.requestAnimationFrame = (callback) => { + const id = state.nextId++; + const record = { id, callback, nativeId: null }; + state.frames.set(id, record); + scheduleFrame(record); + return id; + }; + window.cancelAnimationFrame = (id) => { + cancelRecord(state.frames, id, native.cancelAnimationFrame); + }; + + window.setTimeout = (callback, delay, ...args) => { + const id = state.nextId++; + const record = { + id, + callback, + args, + due: logicalPerformanceNow() + Math.max(0, Number(delay) || 0), + nativeId: null, + }; + state.timers.set(id, record); + scheduleTimer(record); + return id; + }; + window.clearTimeout = (id) => { + cancelRecord(state.timers, id, native.clearTimeout); + cancelRecord(state.intervals, id, native.clearInterval); + }; + + window.setInterval = (callback, delay, ...args) => { + const id = state.nextId++; + const interval = Math.max(1, Number(delay) || 0); + const record = { + id, + callback, + args, + delay: interval, + due: logicalPerformanceNow() + interval, + nativeId: null, + }; + state.intervals.set(id, record); + scheduleInterval(record); + return id; + }; + window.clearInterval = (id) => { + cancelRecord(state.intervals, id, native.clearInterval); + cancelRecord(state.timers, id, native.clearTimeout); + }; + + try { + Object.defineProperty(window.performance, 'now', { + configurable: true, + value: logicalPerformanceNow, + }); + } catch {} + try { + Date.now = logicalDateNow; + } catch {} + + const blockWhilePaused = (event) => { + if (!state.paused) return; + // Let release events through so a pause cannot leave a key or button held. + if (event.type === 'keyup' || event.type === 'mouseup' || event.type === 'pointerup') return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + for (const type of [ + 'keydown', 'keypress', 'mousedown', 'mousemove', 'click', 'contextmenu', + 'pointerdown', 'pointermove', 'wheel', 'touchstart', 'touchmove', + ]) { + window.addEventListener(type, blockWhilePaused, true); + } + + // Chromium emits a trusted relative movement followed immediately by its + // inverse when CDP moves a pointer-locked mouse. Games see both and the + // camera movement cancels out. The controller arms one expected movement; + // this early capture listener lets the trusted forward event reach the game + // and suppresses only Chromium's matching recenter event. + window.addEventListener('mousemove', (event) => { + if (event.isTrusted && native.performanceNow() < state.suppressPointerMovesUntil) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + const pending = state.pointerMove; + if (!pending || !event.isTrusted) return; + if (native.performanceNow() > pending.deadline) { + state.pointerMove = null; + return; + } + const x = Number(event.movementX || 0); + const y = Number(event.movementY || 0); + if (!pending.forwardSeen && x === pending.dx && y === pending.dy) { + pending.forwardSeen = true; + return; + } + if (pending.forwardSeen && x === -pending.dx && y === -pending.dy) { + state.pointerMove = null; + event.preventDefault(); + event.stopImmediatePropagation(); + } + }, true); + + function pause() { + if (state.paused) return false; + state.pausePerformanceNow = logicalPerformanceNow(); + state.pauseDateNow = logicalDateNow(); + state.paused = true; + for (const record of state.frames.values()) { + if (record.nativeId !== null) native.cancelAnimationFrame(record.nativeId); + record.nativeId = null; + } + for (const record of state.timers.values()) { + if (record.nativeId !== null) native.clearTimeout(record.nativeId); + record.nativeId = null; + } + for (const record of state.intervals.values()) { + if (record.nativeId !== null) native.clearTimeout(record.nativeId); + record.nativeId = null; + } + return true; + } + + function resume() { + if (!state.paused) return false; + state.performanceOffset = native.performanceNow() - state.pausePerformanceNow; + state.dateOffset = native.dateNow() - state.pauseDateNow; + state.paused = false; + for (const record of state.frames.values()) scheduleFrame(record); + for (const record of state.timers.values()) scheduleTimer(record); + for (const record of state.intervals.values()) { + // Do not replay every interval that elapsed during the model's turn. + record.due = logicalPerformanceNow() + record.delay; + scheduleInterval(record); + } + return true; + } + + window[name] = { + pause, + resume, + isPaused: () => state.paused, + preparePointerMove: (dx, dy) => { + if (!document.pointerLockElement) return false; + state.suppressPointerMovesUntil = 0; + state.pointerMove = { + dx: Number(dx), + dy: Number(dy), + forwardSeen: false, + deadline: native.performanceNow() + 15000, + }; + return true; + }, + preparePointerLock: () => { + state.pointerMove = null; + // Chromium may defer the lock-acquisition recenter event until the page + // receives another rendered frame. On a paused software-rendered game, + // that can be minutes later, after the agent has inspected the returned + // screenshot. Keep acquisition noise blocked until the controller arms + // the first intentional view movement, which clears this sentinel. + state.suppressPointerMovesUntil = Number.POSITIVE_INFINITY; + return true; + }, + }; +})(); +`; + +function browserPauseInitScript() { + return BROWSER_PAUSE_INIT_SCRIPT; +} + +module.exports = { + BROWSER_PAUSE_INIT_SCRIPT, + browserPauseInitScript, +}; diff --git a/runwave/controller/src/browser-session.js b/runwave/controller/src/browser-session.js index cc94c8d..e816a0a 100644 --- a/runwave/controller/src/browser-session.js +++ b/runwave/controller/src/browser-session.js @@ -6,7 +6,8 @@ const { chromium } = require('playwright'); const { AudioVideoRecorder } = require('./audio-recorder'); const { ensureDir, safeName, sleep, timestamp } = require('./file-utils'); const { drawGridOnScreenshot } = require('./grid-overlay'); -const { parseArgList, targetUrl } = require('./protocol'); +const { browserPauseInitScript } = require('./browser-pause'); +const { parseArgList, recordingBackend, targetUrl } = require('./protocol'); const { readPageState } = require('./state-reader'); const DEFAULT_CHROMIUM_ARGS = [ @@ -42,9 +43,13 @@ function isRecording(config = {}) { return Boolean(config.record || config.recordAudio); } +function usesGstreamerRecording(config = {}) { + return recordingBackend(config) === 'gstreamer'; +} + function chromiumLaunchArgs(config = {}, env = process.env) { const args = chromiumArgs(config, env); - if (!isRecording(config)) return args; + if (!usesGstreamerRecording(config)) return args; const size = videoSize(config); return [ ...args, @@ -57,7 +62,7 @@ function chromiumLaunchArgs(config = {}, env = process.env) { } function launchHeadless(config = {}) { - return isRecording(config) ? false : config.headless !== false; + return usesGstreamerRecording(config) ? false : config.headless !== false; } function webLaunchConfig(config = {}) { @@ -201,7 +206,10 @@ class BrowserSession { this.videoDir = null; this.audioDir = undefined; this.audioRecorder = null; + this.playwrightRecorderActive = false; + this.playwrightVideoPath = null; this.mousePosition = { x: 0, y: 0 }; + this.paused = false; } timeSync(event, fields, fn) { @@ -218,8 +226,12 @@ class BrowserSession { async start() { this.timeSync('browser.start.ensure_run_dir', { dir: this.paths.runDir }, () => ensureDir(this.paths.runDir)); - await this.time('browser.start.game_process', () => this.startGameProcess()); const record = isRecording(this.config); + const backend = recordingBackend(this.config); + if (backend === 'playwright' && this.config.recordAudio) { + throw new Error(`the ${backend} recording backend is video-only; use gstreamer when recordAudio is enabled`); + } + await this.time('browser.start.game_process', () => this.startGameProcess()); if (record) { this.videoDir = this.timeSync('browser.start.ensure_video_dir', () => ensureDir(path.join(this.paths.runDir, 'video'))); } @@ -245,6 +257,11 @@ class BrowserSession { deviceScaleFactor: Number(this.config.deviceScaleFactor ?? 1), }) ); + if (this.config.pauseController) { + await this.time('browser.start.install_pause_controller', () => + this.context.addInitScript({ content: browserPauseInitScript() }) + ); + } if (record) { await this.time('browser.start.capture_viewport_stabilizer', () => this.context.addInitScript(browserViewportStabilizerScript) @@ -257,7 +274,7 @@ class BrowserSession { await this.time('browser.start.initial_navigate', { url: this.launchUrl }, () => this.navigate({ url: this.launchUrl, waitAfterLoad: this.config.waitAfterLoad }) ); - if (record) { + if (backend === 'gstreamer') { const videoSource = this.config.videoSource || await this.time('browser.start.page_viewport_video_source', () => pageViewportVideoSource(this.page) ); @@ -267,7 +284,30 @@ class BrowserSession { this.profiler ? this.profiler.child('audio-video-recorder') : null ); await this.time('browser.start.audio_video_recorder_start', () => this.audioRecorder.start()); + } else if (backend === 'playwright') { + await this.time('browser.start.playwright_recorder_start', () => this.startPlaywrightRecording()); + } + } + + async startPlaywrightRecording() { + if (!this.page || !this.page.screencast || typeof this.page.screencast.start !== 'function') { + throw new Error('the installed Playwright version does not support page.screencast'); } + const fileName = safeName(this.config.playwrightVideoFileName || '000-runwave-playwright'); + this.playwrightVideoPath = path.join(this.videoDir, `${fileName}.webm`); + await this.page.screencast.start({ + path: this.playwrightVideoPath, + size: videoSize(this.config), + }); + this.playwrightRecorderActive = true; + return this.playwrightVideoPath; + } + + async stopPlaywrightRecording() { + if (!this.playwrightRecorderActive) return null; + this.playwrightRecorderActive = false; + await this.page.screencast.stop(); + return fs.existsSync(this.playwrightVideoPath) ? this.playwrightVideoPath : null; } async startGameProcess() { @@ -335,14 +375,27 @@ class BrowserSession { async click(click) { const holdMs = Math.max(0, Number(click.end ?? click.start) - Number(click.start ?? 0)); const clickCount = Math.max(1, Math.round(Number(click.clickCount || 1))); + const pointerLocked = await this.time('browser.mouse.pointer_lock_state', () => + this.page.evaluate(() => Boolean(document.pointerLockElement)) + ); await this.time('browser.mouse.click', { x: click.x, y: click.y, button: click.button, clickCount, holdMs, + pointerLocked, }, async () => { - await this.page.mouse.move(click.x, click.y); + // In pointer-lock games the click coordinates are irrelevant. Moving to + // them first makes Chromium emit a relative delta and its inverse while + // recentering, which can violently disturb an FPS camera before firing. + if (!pointerLocked) { + await this.page.mouse.move(click.x, click.y); + await this.page.evaluate(() => { + const controller = window.__runwavePauseController; + if (controller && controller.preparePointerLock) controller.preparePointerLock(); + }); + } for (let index = 0; index < clickCount; index += 1) { const eventClickCount = index + 1; await this.page.mouse.down({ button: click.button, clickCount: eventClickCount }); @@ -350,7 +403,49 @@ class BrowserSession { await this.page.mouse.up({ button: click.button, clickCount: eventClickCount }); } }); - this.mousePosition = { x: click.x, y: click.y }; + if (!pointerLocked) this.mousePosition = { x: click.x, y: click.y }; + } + + async focusGame({ button = 'middle', x: requestedX, y: requestedY } = {}) { + const alreadyLocked = await this.page.evaluate(() => Boolean(document.pointerLockElement)); + if (alreadyLocked) { + return { pointerLocked: true, x: this.mousePosition.x, y: this.mousePosition.y, button }; + } + const surface = await this.page.evaluate(() => { + const canvases = Array.from(document.querySelectorAll('canvas')); + const canvas = canvases.sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return rightRect.width * rightRect.height - leftRect.width * leftRect.height; + })[0]; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + }); + if (!surface) throw new Error('focus_game requires a visible canvas'); + + let x = Number.isFinite(Number(requestedX)) ? Number(requestedX) : this.mousePosition.x; + let y = Number.isFinite(Number(requestedY)) ? Number(requestedY) : this.mousePosition.y; + if (x < surface.left || x >= surface.right || y < surface.top || y >= surface.bottom) { + x = Math.round((surface.left + surface.right) / 2); + y = Math.round((surface.top + surface.bottom) / 2); + } + if (x !== this.mousePosition.x || y !== this.mousePosition.y) { + await this.page.mouse.move(x, y); + this.mousePosition = { x, y }; + } + await this.page.evaluate(() => { + const controller = window.__runwavePauseController; + if (!controller || !controller.preparePointerLock) { + throw new Error('pointer lock controller is unavailable'); + } + controller.preparePointerLock(); + }); + await this.page.mouse.down({ button }); + await this.page.mouse.up({ button }); + await sleep(75); + const pointerLocked = await this.page.evaluate(() => Boolean(document.pointerLockElement)); + return { pointerLocked, x, y, button }; } async moveCursor(move) { @@ -415,54 +510,41 @@ class BrowserSession { async moveView(move) { const viewport = this.page.viewportSize() || this.config.viewport || { width: 1024, height: 620 }; - const x = Math.max(0, Math.min(viewport.width - 1, this.mousePosition.x + move.dx)); - const y = Math.max(0, Math.min(viewport.height - 1, this.mousePosition.y + move.dy)); - await this.time('browser.mouse.move', { x, y, dx: move.dx, dy: move.dy }, () => this.page.mouse.move(x, y)); - await this.time('browser.mouse.dispatch_view_move_events', { x, y, dx: move.dx, dy: move.dy }, () => - this.page.evaluate(({ dx, dy, x, y }) => { - const target = document.pointerLockElement || document.activeElement || document.querySelector('canvas') || document.body; - const targets = Array.from(new Set([target, document, window].filter(Boolean))); - const eventInit = { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - screenX: x, - screenY: y, - movementX: dx, - movementY: dy, - buttons: 0, - }; - const defineMovement = (event) => { - for (const [name, value] of [ - ['movementX', dx], - ['movementY', dy], - ]) { - if (event[name] !== value) { - Object.defineProperty(event, name, { value, configurable: true }); - } - } - return event; - }; - - for (const eventTarget of targets) { - eventTarget.dispatchEvent(defineMovement(new MouseEvent('mousemove', eventInit))); - - if (typeof PointerEvent === 'function') { - eventTarget.dispatchEvent( - defineMovement( - new PointerEvent('pointermove', { - ...eventInit, - pointerId: 1, - pointerType: 'mouse', - isPrimary: true, - }) - ) - ); - } - } - }, { dx: move.dx, dy: move.dy, x, y }) + const pointerLocked = await this.time('browser.mouse.pointer_lock_state', () => + this.page.evaluate(() => Boolean(document.pointerLockElement)) + ); + const rawX = this.mousePosition.x + move.dx; + const rawY = this.mousePosition.y + move.dy; + if (pointerLocked) { + // Chromium produces a trusted requested delta and then a trusted inverse + // recenter event. The page-side controller suppresses that inverse, so + // games which reject synthetic MouseEvents still receive real movement. + await this.time('browser.mouse.dispatch_locked_view_move', { + dx: move.dx, + dy: move.dy, + requestedSteps: move.steps, + }, async () => { + const movementX = Math.round(Number(move.dx)); + const movementY = Math.round(Number(move.dy)); + const armed = await this.page.evaluate(({ x, y }) => { + const controller = window.__runwavePauseController; + return Boolean(controller && controller.preparePointerMove(x, y)); + }, { x: movementX, y: movementY }); + if (!armed) throw new Error('trusted pointer movement controller is unavailable'); + await this.page.mouse.move(movementX, movementY); + // Unity and other frame-polled games consume mouse deltas during their + // next animation update. On a software renderer that frame can take + // much longer than the requested action duration, so wait for the + // actual frame instead of pausing on a wall-clock guess. + await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + }); + return; + } + + const x = Math.max(0, Math.min(viewport.width - 1, rawX)); + const y = Math.max(0, Math.min(viewport.height - 1, rawY)); + await this.time('browser.mouse.move', { x, y, dx: move.dx, dy: move.dy }, () => + this.page.mouse.move(x, y, { steps: move.steps }) ); this.mousePosition = { x, y }; } @@ -473,6 +555,38 @@ class BrowserSession { ); } + // The MCP agent receives a frame and then may spend seconds reasoning. The + // page-side controller stops the game clock and input at the renderer, while + // leaving screenshots and state reads available during the pause. + async pause() { + if (!this.page) return false; + const changed = await this.page.evaluate(() => { + const controller = window.__runwavePauseController; + if (!controller) throw new Error('runwave pause controller is unavailable'); + return controller.pause(); + }); + this.paused = true; + return changed; + } + + async resume() { + if (!this.page) return false; + const changed = await this.page.evaluate(() => { + const controller = window.__runwavePauseController; + if (!controller) throw new Error('runwave pause controller is unavailable'); + return controller.resume(); + }); + this.paused = false; + return changed; + } + + async isPaused() { + if (!this.page) return Boolean(this.paused); + return this.page.evaluate(() => Boolean( + window.__runwavePauseController && window.__runwavePauseController.isPaused() + )); + } + async stopGameProcess() { if (!this.process || processHasClosed(this.process)) return; await this.time('browser.close.terminate_process', async () => { @@ -485,11 +599,16 @@ class BrowserSession { } async close() { + let videoPath = null; let audioVideoPath = null; let closeError = null; try { + if (this.playwrightRecorderActive) { + videoPath = await this.time('browser.close.playwright_recorder_stop', () => this.stopPlaywrightRecording()); + } if (this.audioRecorder) { audioVideoPath = await this.time('browser.close.audio_video_stop', () => this.audioRecorder.stop()); + videoPath = audioVideoPath; } if (this.context) await this.time('browser.close.context_close', () => this.context.close()); if (this.browser) await this.time('browser.close.browser_close', () => this.browser.close()); @@ -501,9 +620,10 @@ class BrowserSession { } catch (error) { if (!closeError) closeError = error; } + this.paused = false; if (closeError) throw closeError; return { - video: audioVideoPath, + video: videoPath, audioVideo: audioVideoPath || undefined, }; } @@ -516,5 +636,8 @@ module.exports = { chromiumLaunchArgs, launchHeadless, pageViewportVideoSource, + recordingBackend, + usesGstreamerRecording, + videoSize, webLaunchConfig, }; diff --git a/runwave/controller/src/protocol.js b/runwave/controller/src/protocol.js index 74c3763..0b77dd2 100644 --- a/runwave/controller/src/protocol.js +++ b/runwave/controller/src/protocol.js @@ -141,6 +141,21 @@ function optionalPositiveInteger(value) { return Number.isInteger(number) && number > 0 ? number : null; } +function recordingBackend(input = {}, env = process.env) { + if (!input.record && !input.recordAudio) return null; + const raw = String( + input.recordingBackend + ?? input.recording_backend + ?? input.recordBackend + ?? input.videoBackend + ?? env.RUNWAVE_RECORDING_BACKEND + ?? 'gstreamer' + ).trim().toLowerCase(); + if (raw === 'gstreamer' || raw === 'x11') return 'gstreamer'; + if (raw === 'playwright' || raw === 'playwright-native' || raw === 'native') return 'playwright'; + throw new Error(`unsupported recording backend: ${raw}`); +} + function linuxStartConfig(input = {}) { const launch = input.launch && typeof input.launch === 'object' ? input.launch : {}; const launchEnv = input.env ?? launch.env; @@ -182,12 +197,17 @@ function startSessionConfig(input, options = {}) { const kind = targetKind(input); const viewport = normalizeSize(input.viewport, { width: 1024, height: 620 }); const record = Boolean(input.record || input.recordAudio); + const backend = recordingBackend(input); + if (kind === 'linux' && backend && backend !== 'gstreamer') { + throw new Error(`the ${backend} recording backend is only available for web sessions`); + } const common = { kind, context: { viewport, deviceScaleFactor: optionalNumber(input.deviceScaleFactor, 1), record, + recordingBackend: backend, videoSize: record ? normalizeSize(input.videoSize || input.viewport, viewport) : null, }, defaults: { @@ -216,7 +236,7 @@ function startSessionConfig(input, options = {}) { launchUrl: web.launchUrl, web, browser: { - headless: record ? false : input.headless !== false, + headless: backend === 'gstreamer' ? false : input.headless !== false, channel: optionalString(input.channel), executablePath: optionalString(input.executablePath), chromiumArgsMode: String(input.chromiumArgsMode || process.env.RUNWAVE_CHROMIUM_ARGS_MODE || 'append').toLowerCase(), @@ -261,6 +281,7 @@ module.exports = { linuxStartConfig, webStartConfig, parseArgList, + recordingBackend, startSessionConfig, diffStartSessionConfig, isListSessionsAction, diff --git a/runwave/controller/src/step-executor.js b/runwave/controller/src/step-executor.js index bc0cb45..7159def 100644 --- a/runwave/controller/src/step-executor.js +++ b/runwave/controller/src/step-executor.js @@ -35,7 +35,7 @@ async function releasePressedKeys(browser, pressed, profiler) { } } -async function executeTimeline({ browser, events, duration, outputDir, prefix, stateExpression, profiler }) { +async function executeTimeline({ browser, events, duration, outputDir, prefix, stateExpression, beforeEndCapture, profiler }) { const pressed = new Set(); const captures = []; const startedAt = Date.now(); @@ -97,6 +97,7 @@ async function executeTimeline({ browser, events, duration, outputDir, prefix, s await browser.moveView(event.viewMove); } } else if (event.type === 'capture') { + if (beforeEndCapture && event.at === duration) await beforeEndCapture(); captures.push(await (profiler ? profiler.time('timeline.event.capture', fields, () => captureAt({ browser, outputDir, prefix, stateExpression, at: event.at, profiler }) diff --git a/runwave/controller/src/step-runner.js b/runwave/controller/src/step-runner.js index 2330802..5997596 100644 --- a/runwave/controller/src/step-runner.js +++ b/runwave/controller/src/step-runner.js @@ -27,7 +27,7 @@ function summarizeActions(step) { ].sort((left, right) => (left.start ?? 0) - (right.start ?? 0)); } -async function runStep({ input, config, browser, outputDir, nextStepIndex, actionName, profiler }) { +async function runStep({ input, config, browser, outputDir, nextStepIndex, actionName, beforeEndCapture, profiler }) { const timeSync = (event, fields, fn) => (profiler ? profiler.timeSync(event, fields, fn) : fn()); const time = (event, fields, fn) => (profiler ? profiler.time(event, fields, fn) : fn()); @@ -58,6 +58,7 @@ async function runStep({ input, config, browser, outputDir, nextStepIndex, actio outputDir, prefix, stateExpression: input.stateExpression, + beforeEndCapture, profiler: profiler ? profiler.child('step-executor') : null, }) ); diff --git a/runwave/controller/test/browser-session.test.js b/runwave/controller/test/browser-session.test.js index 9739ade..517b8de 100644 --- a/runwave/controller/test/browser-session.test.js +++ b/runwave/controller/test/browser-session.test.js @@ -1,5 +1,7 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); const os = require('os'); +const path = require('node:path'); const test = require('node:test'); const { @@ -8,6 +10,8 @@ const { chromiumLaunchArgs, launchHeadless, pageViewportVideoSource, + recordingBackend, + usesGstreamerRecording, webLaunchConfig, } = require('../src/browser-session'); @@ -43,6 +47,71 @@ test('recording sessions force a visible headed browser', () => { assert.equal(launchHeadless({ record: false, headless: false }), false); }); +test('playwright recording stays headless and does not add x11 window arguments', () => { + const config = { + record: true, + recordingBackend: 'playwright', + viewport: { width: 1280, height: 720 }, + }; + + assert.equal(recordingBackend(config), 'playwright'); + assert.equal(usesGstreamerRecording(config), false); + assert.equal(launchHeadless(config), true); + assert.equal(chromiumLaunchArgs(config, {}).includes('--kiosk'), false); +}); + +test('recording backend rejects unknown values', () => { + assert.throws( + () => recordingBackend({ record: true, recordingBackend: 'unknown' }, {}), + /unsupported recording backend: unknown/ + ); +}); + +test('playwright recorder writes the configured viewport video and stops cleanly', async () => { + const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-playwright-recording-')); + const session = new BrowserSession( + { + url: 'about:blank', + record: true, + recordingBackend: 'playwright', + viewport: { width: 1280, height: 720 }, + }, + { runDir } + ); + const calls = []; + session.videoDir = path.join(runDir, 'video'); + fs.mkdirSync(session.videoDir); + session.page = { + screencast: { + start: async (options) => calls.push({ type: 'start', options }), + stop: async () => { + calls.push({ type: 'stop' }); + fs.writeFileSync(session.playwrightVideoPath, 'video'); + }, + }, + }; + + try { + const startedPath = await session.startPlaywrightRecording(); + const stoppedPath = await session.stopPlaywrightRecording(); + + assert.equal(startedPath, path.join(session.videoDir, '000-runwave-playwright.webm')); + assert.equal(stoppedPath, startedPath); + assert.deepEqual(calls, [ + { + type: 'start', + options: { + path: startedPath, + size: { width: 1280, height: 720 }, + }, + }, + { type: 'stop' }, + ]); + } finally { + fs.rmSync(runDir, { recursive: true, force: true }); + } +}); + test('browser launch config defaults game directories to start.sh', () => { assert.deepEqual(webLaunchConfig({ gameDir: '/tmp/web-game', port: 4123 }), { command: 'bash', @@ -82,6 +151,7 @@ test('browser clicks hold the mouse down for the normalized click interval', asy const session = new BrowserSession({ url: 'about:blank' }, { runDir: os.tmpdir() }); const calls = []; session.page = { + evaluate: async () => false, mouse: { move: async (x, y) => calls.push({ type: 'move', x, y }), down: async (options) => calls.push({ type: 'down', options, at: Date.now() }), @@ -99,3 +169,67 @@ test('browser clicks hold the mouse down for the normalized click interval', asy assert.ok(Date.now() - startedAt >= 45); assert.deepEqual(session.mousePosition, { x: 321, y: 222 }); }); + +test('pointer-locked clicks do not move the FPS camera before pressing the button', async () => { + const session = new BrowserSession({ url: 'about:blank' }, { runDir: os.tmpdir() }); + const calls = []; + session.mousePosition = { x: 400, y: 300 }; + session.page = { + evaluate: async () => true, + mouse: { + move: async (x, y) => calls.push({ type: 'move', x, y }), + down: async (options) => calls.push({ type: 'down', options }), + up: async (options) => calls.push({ type: 'up', options }), + }, + }; + + await session.click({ type: 'click', start: 0, end: 0, x: 640, y: 360, button: 'left', clickCount: 1 }); + + assert.deepEqual(calls.map((call) => call.type), ['down', 'up']); + assert.deepEqual(session.mousePosition, { x: 400, y: 300 }); +}); + +test('pointer-locked view movement dispatches trusted relative samples and suppresses recentering', async () => { + const session = new BrowserSession( + { url: 'about:blank', viewport: { width: 1280, height: 720 } }, + { runDir: os.tmpdir() } + ); + const moves = []; + const evaluations = []; + session.mousePosition = { x: 1270, y: 710 }; + session.page = { + viewportSize: () => ({ width: 1280, height: 720 }), + mouse: { move: async (x, y) => moves.push({ x, y }) }, + evaluate: async (fn, argument) => { + evaluations.push({ fn, argument }); + return true; + }, + }; + + await session.moveView({ dx: 100, dy: 50, steps: 8 }); + + assert.equal(moves.length, 1); + assert.equal(moves.reduce((sum, move) => sum + move.x, 0), 100); + assert.equal(moves.reduce((sum, move) => sum + move.y, 0), 50); + assert.deepEqual(evaluations[1].argument, { x: 100, y: 50 }); + assert.deepEqual(session.mousePosition, { x: 1270, y: 710 }); +}); + +test('unlocked view movement remains clamped to the viewport', async () => { + const session = new BrowserSession( + { url: 'about:blank', viewport: { width: 1280, height: 720 } }, + { runDir: os.tmpdir() } + ); + const moves = []; + session.mousePosition = { x: 1270, y: 710 }; + session.page = { + viewportSize: () => ({ width: 1280, height: 720 }), + mouse: { move: async (x, y) => moves.push({ x, y }) }, + evaluate: async () => false, + }; + + await session.moveView({ dx: 100, dy: 50 }); + + assert.deepEqual(moves, [{ x: 1279, y: 719 }]); + assert.deepEqual(session.mousePosition, { x: 1279, y: 719 }); +}); diff --git a/runwave/controller/test/session-cleanup.test.js b/runwave/controller/test/session-cleanup.test.js index 07586f7..ed821e5 100644 --- a/runwave/controller/test/session-cleanup.test.js +++ b/runwave/controller/test/session-cleanup.test.js @@ -215,6 +215,35 @@ test('web start configuration can launch a game directory through a local port', assert.equal(config.web.httpTimeoutMs, 60000); }); +test('playwright recording is represented as a headless web session', () => { + const config = startSessionConfig({ + action: 'start', + action_name: 'playwright-recording', + url: 'http://127.0.0.1:4123/', + viewport: { width: 1280, height: 720 }, + record: true, + recordingBackend: 'playwright', + }); + + assert.equal(config.context.record, true); + assert.equal(config.context.recordingBackend, 'playwright'); + assert.equal(config.browser.headless, true); +}); + +test('linux sessions reject the playwright-only recording backend', () => { + assert.throws( + () => startSessionConfig({ + action: 'start', + action_name: 'invalid-native-recording', + kind: 'linux', + command: './game', + record: true, + recordingBackend: 'playwright', + }), + /only available for web sessions/ + ); +}); + test('linux start rejects a live session with a different native launch command', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-linux-target-mismatch-')); const originalStart = { diff --git a/runwave/protocol/src/mark-grid.js b/runwave/protocol/src/mark-grid.js index 57640c4..f657aca 100644 --- a/runwave/protocol/src/mark-grid.js +++ b/runwave/protocol/src/mark-grid.js @@ -17,6 +17,12 @@ function viewportFromConfig(config = {}) { return config.viewport || config.videoSize || null; } +// Defaults to the historical scatter so existing playtest behaviour is unchanged. +function gridSampleMode(config = {}) { + const raw = String(config.markGridSampleMode ?? config.gridSampleMode ?? 'random').toLowerCase(); + return raw === 'center' || raw === 'centre' ? 'center' : 'random'; +} + function gridSafeSampleRatio(config = {}) { const raw = Number( config.markGridSafeSampleRatio @@ -91,7 +97,8 @@ function randomPointInCells( viewport, grid = DEFAULT_MARK_GRID, rng = Math.random, - safeSampleRatio = DEFAULT_GRID_SAFE_SAMPLE_RATIO + safeSampleRatio = DEFAULT_GRID_SAFE_SAMPLE_RATIO, + sampleMode = 'random' ) { const normalized = normalizeCellList(cells, grid, 4); if (!normalized.length) { @@ -99,6 +106,16 @@ function randomPointInCells( } const cell = normalized[Math.floor(rng() * normalized.length)]; const bounds = cellBounds(cell, viewport, grid); + // Scattering within a cell varies footage for playtest recordings. An agent + // aiming at a specific target instead needs the same cell to mean the same + // pixel every time, so a saved action trace replays identically. + if (sampleMode === 'center') { + return { + x: Math.max(0, Math.min(Math.round((bounds.left + bounds.right) / 2), Math.round(Number(viewport.width)) - 1)), + y: Math.max(0, Math.min(Math.round((bounds.top + bounds.bottom) / 2), Math.round(Number(viewport.height)) - 1)), + cells: normalized, + }; + } const ratio = Number.isFinite(Number(safeSampleRatio)) && Number(safeSampleRatio) > 0 && Number(safeSampleRatio) <= 1 @@ -131,6 +148,7 @@ function clickBurstTimes(at, duration, count = 10, intervalMs = 100) { module.exports = { DEFAULT_GRID_SAFE_SAMPLE_RATIO, DEFAULT_MARK_GRID, + gridSampleMode, gridSafeSampleRatio, markGridFromConfig, viewportFromConfig,