From 607fb679473a93386f626daec9f5ba920764cb16 Mon Sep 17 00:00:00 2001 From: Vaibhav P <52103891+Martian-dev@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:17:59 +0000 Subject: [PATCH] Add MCP server so agent harnesses can play browser games Runwave's CLI runs its own VLM loop and produces a playtest video. This adds an MCP surface where the connected agent is the player instead: observe a frame, send a timed input sequence, observe the next frame. The server embeds the controller rather than driving the daemon. That is possible because BrowserSession and runStep already take their dependencies as arguments, and it avoids the daemon's session files, unauthenticated loopback port, process.exit on stop, and cwd captured at module load. The normalize/timeline/execute chain, session factory, state reader, and grid overlay are reused unchanged. Recording is out of scope for interactive play, so Chromium runs headless and the gstreamer, PulseAudio, and Xvfb requirements are gone. Adaptations for an agent player rather than a playtest bot: - Frames return as MCP image blocks, downscaled by default. A 1280x720 PNG is ~1200 tokens against ~300 at half scale, which dominates cost over a long navigation. zoom covers detail without a full frame. - act reports whether the frame changed. A byte-identical frame means the input did not reach the game, not that the game ignored it. - Grid overlay off by default: it enlarges the PNG with a label margin, desyncing image coordinates from input coordinates. The offset is reported when it is on. - Grid cells resolve to the cell centre via a new opt-in markGridSampleMode. Playtest scatter varies footage but misses precise targets and makes runs unreproducible; the default is unchanged. - Per-turn state drops the WebGL probe and keeps the largest canvas as the game area. - Calls serialize per session, since one page and one step counter mean concurrent calls would interleave keypresses. - Signal handlers and a 30 minute idle timeout close detached Chromium and game processes. Tests: 12 unit tests, plus an integration test that drives real headless Chromium against a fixture game and skips when Chromium cannot launch. The integration path is unverified here: this machine is missing libnss3/libnspr4, so it has only been exercised via unit tests so far. Co-Authored-By: Claude --- mcp/Dockerfile | 33 + mcp/README.md | 131 +++ mcp/bin/runwave-mcp.js | 41 + mcp/src/config.js | 56 + mcp/src/diff.js | 29 + mcp/src/frame.js | 54 + mcp/src/image.js | 108 ++ mcp/src/registry.js | 60 + mcp/src/result.js | 50 + mcp/src/schema.js | 112 ++ mcp/src/server.js | 50 + mcp/src/session.js | 109 ++ mcp/src/state.js | 46 + mcp/src/tools-aux.js | 140 +++ mcp/src/tools-play.js | 178 +++ mcp/test/fixtures/game/index.html | 62 + mcp/test/integration.test.js | 131 +++ mcp/test/unit.test.js | 143 +++ package-lock.json | 1170 ++++++++++++++++++- package.json | 10 +- runwave/controller/src/action-normalizer.js | 4 +- runwave/protocol/src/mark-grid.js | 20 +- 22 files changed, 2730 insertions(+), 7 deletions(-) create mode 100644 mcp/Dockerfile create mode 100644 mcp/README.md create mode 100644 mcp/bin/runwave-mcp.js create mode 100644 mcp/src/config.js create mode 100644 mcp/src/diff.js create mode 100644 mcp/src/frame.js create mode 100644 mcp/src/image.js create mode 100644 mcp/src/registry.js create mode 100644 mcp/src/result.js create mode 100644 mcp/src/schema.js create mode 100644 mcp/src/server.js create mode 100644 mcp/src/session.js create mode 100644 mcp/src/state.js create mode 100644 mcp/src/tools-aux.js create mode 100644 mcp/src/tools-play.js create mode 100644 mcp/test/fixtures/game/index.html create mode 100644 mcp/test/integration.test.js create mode 100644 mcp/test/unit.test.js 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..4d0e75f --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,131 @@ +# 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 and there is no +recording — which is also why 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) 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. | +| `act` | Send a timed input sequence, 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. | +| `reset_game` | Reload at the launch URL. | +| `journal` | Text log of what has been tried this session. | +| `list_sessions` | Running sessions. | +| `end_game` | Close the browser and stop the game process. | + +## 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" } + ] +} +``` + +This matters because an agent turn costs seconds. Committing to a move beats +sending one tap per turn. + +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. + +## 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..872aa3b --- /dev/null +++ b/mcp/src/config.js @@ -0,0 +1,56 @@ +'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, + 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/result.js b/mcp/src/result.js new file mode 100644 index 0000000..83faefd --- /dev/null +++ b/mcp/src/result.js @@ -0,0 +1,50 @@ +'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.`; +} + +module.exports = { + errorResult, + frameBlocks, + gridNote, + stateText, + textBlock, +}; diff --git a/mcp/src/schema.js b/mcp/src/schema.js new file mode 100644 index 0000000..e51803d --- /dev/null +++ b/mcp/src/schema.js @@ -0,0 +1,112 @@ +'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 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(`Hold duration.${span('click')}`), + ...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.'); + +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..5ac9ed7 --- /dev/null +++ b/mcp/src/server.js @@ -0,0 +1,50 @@ +'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.', + 'Frames come back downscaled to save context; use zoom to inspect detail and full_res only when you must.', + 'Prefer one act call that commits to a move (hold a key for several hundred ms) over many single taps.', + 'If a result says the frame did not change, the input did not land: change approach rather than repeating it.', + '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..933cd97 --- /dev/null +++ b/mcp/src/session.js @@ -0,0 +1,109 @@ +'use strict'; + +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'), + }; + 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.closed = false; + 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.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); + } + + async close() { + if (this.closed) return; + this.closed = true; + if (this.idleTimer) clearTimeout(this.idleTimer); + await this.browser.close(); + } + + summary() { + return { + session_id: this.id, + url: this.browser.launchUrl, + viewport: this.config.viewport, + turns: this.turn, + steps: this.stepIndex, + uptime_ms: Date.now() - this.createdAt, + run_dir: this.paths.runDir, + closed: this.closed, + }; + } +} + +function newSessionId() { + return `game-${timestamp()}`; +} + +module.exports = { + DEFAULT_IDLE_TIMEOUT_MS, + Session, + newSessionId, +}; diff --git a/mcp/src/state.js b/mcp/src/state.js new file mode 100644 index 0000000..0fa69fa --- /dev/null +++ b/mcp/src/state.js @@ -0,0 +1,46 @@ +'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(''); + } + if (generic.pointerLockElement && generic.pointerLockElement.tagName) { + state.pointer_locked = true; + } + + // 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.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..be1fcf8 --- /dev/null +++ b/mcp/src/tools-aux.js @@ -0,0 +1,140 @@ +'use strict'; + +const { z } = require('zod'); +const { region } = require('./schema'); +const { captureFrame, frameResult } = require('./frame'); +const { errorResult, 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 () => { + 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); + const result = 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})`, + }); + session.touch(); + return result; + }); + } 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 () => { + const { file } = await captureFrame(session, { name: `capture-${args.name}`, grid: false }); + session.note({ event: 'capture', name: args.name, path: file }); + session.touch(); + const summary = textBlock(`saved ${session.config.viewport.width}x${session.config.viewport.height} clean capture\npath: ${file}`); + 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] }; + }); + } catch (error) { + return errorResult(error); + } + }); +} + +function registerSessionTools(server, registry) { + 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 () => { + await session.browser.navigate({ url: session.browser.launchUrl }); + session.stepIndex = 0; + session.note({ event: 'reset' }); + const { file } = await captureFrame(session, { name: `reset-${session.turn}`, grid: false }); + session.lastFrame = file; + const state = await session.browser.state(session.config.stateExpression); + const result = frameResult({ file, margin: 0, state, label: 'after reset' }); + session.touch(); + return result; + }); + } 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); + } + }); +} + +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..511ce0c --- /dev/null +++ b/mcp/src/tools-play.js @@ -0,0 +1,178 @@ +'use strict'; + +const { z } = require('zod'); +const { runStep } = require('../../runwave/controller/src/step-runner'); +const { action } = require('./schema'); +const { captureFrame, frameResult } = require('./frame'); +const { errorResult } = 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 () => { + 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); + const result = 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}`], + }); + session.touch(); + return result; + }); + } 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, + ], + }); + 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 () => { + 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; + const result = frameResult({ file, margin, state, fullRes: args.full_res }); + session.touch(); + return result; + }); + } 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: prefer one sequence that commits to a move over many single-input calls.', + ].join(' '), + inputSchema: { + session_id: z.string(), + actions: z.array(action).min(1).describe('Inputs to run. Total sequence should stay under 8000ms.'), + captures: z.array(z.number().min(0)).max(MAX_FRAMES_PER_TURN).optional() + .describe('Offsets in ms to screenshot at. Defaults to the end of the sequence. Each extra frame costs context.'), + note: z.string().optional().describe('Short intent for the journal, e.g. "cross bridge east".'), + ...frameOptions, + }, + }, async (args) => { + try { + const session = registry.get(args.session_id); + return session.run(async () => { + const stepIndex = session.nextStepIndex(); + const actionName = `act-${String(stepIndex).padStart(3, '0')}`; + const previousFrame = session.lastFrame; + const step = await runStep({ + input: { + action: 'step', + action_name: actionName, + actions: args.actions, + ...(args.captures ? { captures: args.captures } : {}), + autoCaptures: false, + }, + config: session.config, + browser: session.browser, + outputDir: session.actionDir(actionName), + nextStepIndex: stepIndex, + actionName, + profiler: null, + }); + 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..b34bdf8 --- /dev/null +++ b/mcp/test/integration.test.js @@ -0,0 +1,131 @@ +'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 { chromium } = require('playwright'); +const { SessionRegistry } = require('../src/registry'); +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, + 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); + 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}`; + 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, + 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); + }); +}); diff --git a/mcp/test/unit.test.js b/mcp/test/unit.test.js new file mode 100644 index 0000000..721d4da --- /dev/null +++ b/mcp/test/unit.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('node:assert/strict'); +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 { 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); +}); + +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.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' })); +}); 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/src/action-normalizer.js b/runwave/controller/src/action-normalizer.js index 388499b..a9548f8 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, @@ -167,7 +168,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/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,