unity-open-mcp 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/capabilities/cost-hints.js +73 -0
  2. package/dist/capabilities/cost-hints.js.map +1 -1
  3. package/dist/capabilities/intent-groups.js +76 -4
  4. package/dist/capabilities/intent-groups.js.map +1 -1
  5. package/dist/capabilities/lifecycle.js +9 -0
  6. package/dist/capabilities/lifecycle.js.map +1 -1
  7. package/dist/capabilities/tool-groups.js +75 -17
  8. package/dist/capabilities/tool-groups.js.map +1 -1
  9. package/dist/constants.d.ts +2 -2
  10. package/dist/constants.js +2 -2
  11. package/dist/event-stream.d.ts +10 -0
  12. package/dist/event-stream.js +29 -0
  13. package/dist/event-stream.js.map +1 -1
  14. package/dist/game-view-settle.d.ts +18 -0
  15. package/dist/game-view-settle.js +81 -0
  16. package/dist/game-view-settle.js.map +1 -0
  17. package/dist/jobs/adapters.d.ts +59 -0
  18. package/dist/jobs/adapters.js +226 -0
  19. package/dist/jobs/adapters.js.map +1 -1
  20. package/dist/live-client.d.ts +10 -0
  21. package/dist/live-client.js +26 -3
  22. package/dist/live-client.js.map +1 -1
  23. package/dist/local-tools.js +3 -0
  24. package/dist/local-tools.js.map +1 -1
  25. package/dist/playmode-routes.d.ts +17 -0
  26. package/dist/playmode-routes.js +50 -0
  27. package/dist/playmode-routes.js.map +1 -0
  28. package/dist/playtest/png.d.ts +27 -0
  29. package/dist/playtest/png.js +248 -0
  30. package/dist/playtest/png.js.map +1 -0
  31. package/dist/playtest/recorder.d.ts +203 -0
  32. package/dist/playtest/recorder.js +945 -0
  33. package/dist/playtest/recorder.js.map +1 -0
  34. package/dist/playtest/redact.d.ts +16 -0
  35. package/dist/playtest/redact.js +124 -0
  36. package/dist/playtest/redact.js.map +1 -0
  37. package/dist/playtest/report.d.ts +73 -0
  38. package/dist/playtest/report.js +477 -0
  39. package/dist/playtest/report.js.map +1 -0
  40. package/dist/playtest/session-store.d.ts +78 -0
  41. package/dist/playtest/session-store.js +224 -0
  42. package/dist/playtest/session-store.js.map +1 -0
  43. package/dist/skill/SKILL.md +20 -3
  44. package/dist/skill/references/senses-and-tests.md +3 -0
  45. package/dist/tool-router.d.ts +44 -0
  46. package/dist/tool-router.js +235 -2
  47. package/dist/tool-router.js.map +1 -1
  48. package/dist/tools/batch-execute.js +5 -1
  49. package/dist/tools/batch-execute.js.map +1 -1
  50. package/dist/tools/capture-inline.js +30 -12
  51. package/dist/tools/capture-inline.js.map +1 -1
  52. package/dist/tools/capture-series.d.ts +46 -0
  53. package/dist/tools/capture-series.js +116 -0
  54. package/dist/tools/capture-series.js.map +1 -0
  55. package/dist/tools/game-view.d.ts +44 -0
  56. package/dist/tools/game-view.js +103 -0
  57. package/dist/tools/game-view.js.map +1 -0
  58. package/dist/tools/index.d.ts +1 -0
  59. package/dist/tools/index.js +34 -7
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/inputsim-gesture.d.ts +46 -0
  62. package/dist/tools/inputsim-gesture.js +123 -0
  63. package/dist/tools/inputsim-gesture.js.map +1 -0
  64. package/dist/tools/inputsim-pointer.js +35 -2
  65. package/dist/tools/inputsim-pointer.js.map +1 -1
  66. package/dist/tools/inputsim-touch.js +42 -22
  67. package/dist/tools/inputsim-touch.js.map +1 -1
  68. package/dist/tools/jobs.js +1 -1
  69. package/dist/tools/jobs.js.map +1 -1
  70. package/dist/tools/player-state.d.ts +42 -0
  71. package/dist/tools/player-state.js +142 -0
  72. package/dist/tools/player-state.js.map +1 -0
  73. package/dist/tools/playtest-conditions.d.ts +334 -0
  74. package/dist/tools/playtest-conditions.js +99 -0
  75. package/dist/tools/playtest-conditions.js.map +1 -0
  76. package/dist/tools/playtest-session.d.ts +45 -0
  77. package/dist/tools/playtest-session.js +105 -0
  78. package/dist/tools/playtest-session.js.map +1 -0
  79. package/dist/tools/playtest-wait.d.ts +46 -0
  80. package/dist/tools/playtest-wait.js +86 -0
  81. package/dist/tools/playtest-wait.js.map +1 -0
  82. package/dist/tools/screenshot.js +31 -8
  83. package/dist/tools/screenshot.js.map +1 -1
  84. package/dist/tools/text-target.d.ts +22 -0
  85. package/dist/tools/text-target.js +39 -0
  86. package/dist/tools/text-target.js.map +1 -0
  87. package/dist/tools/ui-snapshot.d.ts +41 -0
  88. package/dist/tools/ui-snapshot.js +87 -0
  89. package/dist/tools/ui-snapshot.js.map +1 -0
  90. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ // Playtest session recorder — on-disk layout.
2
+ //
3
+ // A session is a deliberate on-disk record (not a cache): the review artifact
4
+ // a human reads after an agent's playtest. It lives in the same platform
5
+ // app-data root as the player-state snapshots, outside the project:
6
+ //
7
+ // <app data>/unity-open-mcp/playtest-sessions/<company>/<product>/<session>/
8
+ // session.json metadata (owner, device, snapshot, state, report path)
9
+ // log.jsonl one JSON line per event, appended as it happens
10
+ // report/
11
+ // frames/ full-size captures (linked or copied when the call ends)
12
+ // thumbs/ downscaled previews (written by `end`)
13
+ // report.md written by `end`
14
+ //
15
+ // `<app data>` mirrors the bridge's player-state root: ~/Library/Application
16
+ // Support on macOS, %LOCALAPPDATA% on Windows, $XDG_DATA_HOME or ~/.local/share
17
+ // elsewhere. Company / product are the project's Player Settings, made into one
18
+ // path segment each with the same rule as the bridge.
19
+ //
20
+ // Every write is synchronous and small: a log line is appended with one
21
+ // appendFileSync (open-write-close) the moment the event happens, so the log
22
+ // survives a domain reload, an Editor crash and an MCP server restart; the
23
+ // metadata file is replaced through a temp file + rename. A process that adopts
24
+ // a session first ends a torn last line, so its own entries stay parseable.
25
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ export const SESSION_FORMAT = 1;
29
+ export const SESSION_FILE = "session.json";
30
+ export const LOG_FILE = "log.jsonl";
31
+ export const REPORT_DIR = "report";
32
+ export const FRAMES_DIR = "frames";
33
+ export const THUMBS_DIR = "thumbs";
34
+ export const REPORT_FILE = "report.md";
35
+ /** The platform app-data folder (same rule as the bridge's player-state store). */
36
+ export function appDataRoot(platform = process.platform, env = process.env, home = env.HOME ?? homedir()) {
37
+ let appData;
38
+ if (platform === "darwin")
39
+ appData = join(home, "Library", "Application Support");
40
+ else if (platform === "win32")
41
+ appData = env.LOCALAPPDATA && env.LOCALAPPDATA.length ? env.LOCALAPPDATA : join(home, "AppData", "Local");
42
+ else {
43
+ const xdg = env.XDG_DATA_HOME;
44
+ appData = xdg && xdg.startsWith("/") ? xdg : join(home, ".local", "share");
45
+ }
46
+ return join(appData, "unity-open-mcp");
47
+ }
48
+ export function sessionsRoot(platform, env, home) {
49
+ return join(appDataRoot(platform, env, home), "playtest-sessions");
50
+ }
51
+ /** A company / product name as one path segment (the bridge's SafeSegment rule). */
52
+ export function safeSegment(value) {
53
+ if (!value)
54
+ return "_";
55
+ let out = "";
56
+ for (const c of value)
57
+ out += /[\\/:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c;
58
+ out = out.trim().replace(/\.+$/, "");
59
+ return out.length === 0 || out === "." || out === ".." ? "_" : out;
60
+ }
61
+ /** A session id: UTC stamp + a slug of the name + 4 hex, safe as a folder name. */
62
+ export function sessionId(name, now, random) {
63
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-");
64
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "playtest";
65
+ return `${stamp}-${slug}-${random}`;
66
+ }
67
+ export const SESSION_ID_PATTERN = /^[0-9]{8}-[0-9]{6}-[a-z0-9-]{1,40}$/;
68
+ /**
69
+ * Company / product from ProjectSettings/ProjectSettings.asset — the fallback
70
+ * when the Editor cannot be asked (a restart with the Editor closed). Plain
71
+ * and quoted YAML scalars only; anything else falls back to Unity's defaults.
72
+ */
73
+ export function readProjectIdentity(projectPath) {
74
+ let text;
75
+ try {
76
+ text = readFileSync(join(projectPath, "ProjectSettings", "ProjectSettings.asset"), "utf8");
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const scalar = (key) => {
82
+ const match = new RegExp(`^\\s*${key}:[ \\t]*(.*)$`, "m").exec(text);
83
+ if (!match)
84
+ return null;
85
+ const raw = match[1].trim();
86
+ if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2)
87
+ return raw.slice(1, -1).replace(/''/g, "'");
88
+ if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length >= 2) {
89
+ try {
90
+ return JSON.parse(raw);
91
+ }
92
+ catch {
93
+ return raw.slice(1, -1);
94
+ }
95
+ }
96
+ return raw;
97
+ };
98
+ const company = scalar("companyName");
99
+ const product = scalar("productName");
100
+ if (company === null || product === null)
101
+ return null;
102
+ return { company, product };
103
+ }
104
+ export class SessionStore {
105
+ root;
106
+ constructor(root) {
107
+ this.root = root;
108
+ }
109
+ productDir(company, product) {
110
+ return join(this.root, safeSegment(company), safeSegment(product));
111
+ }
112
+ dir(meta) {
113
+ return join(this.productDir(meta.company, meta.product), meta.id);
114
+ }
115
+ create(meta) {
116
+ const dir = this.dir(meta);
117
+ mkdirSync(join(dir, REPORT_DIR, FRAMES_DIR), { recursive: true });
118
+ this.writeMeta(meta);
119
+ writeFileSync(join(dir, LOG_FILE), "", { flag: "a" });
120
+ return dir;
121
+ }
122
+ writeMeta(meta) {
123
+ const dir = this.dir(meta);
124
+ const target = join(dir, SESSION_FILE);
125
+ const temp = `${target}.${process.pid}.tmp`;
126
+ writeFileSync(temp, JSON.stringify(meta, null, 2));
127
+ renameSync(temp, target);
128
+ }
129
+ readMeta(dir) {
130
+ try {
131
+ const meta = JSON.parse(readFileSync(join(dir, SESSION_FILE), "utf8"));
132
+ return meta && meta.format === SESSION_FORMAT && typeof meta.id === "string" ? meta : null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ append(meta, entry) {
139
+ appendFileSync(join(this.dir(meta), LOG_FILE), JSON.stringify(entry) + "\n");
140
+ }
141
+ /**
142
+ * Every parseable line of a session log; a torn line (a crash mid-write) is
143
+ * skipped. Read in chunks, so a log larger than the longest JS string still
144
+ * yields its entries.
145
+ */
146
+ readLog(dir) {
147
+ let fd;
148
+ try {
149
+ fd = openSync(join(dir, LOG_FILE), "r");
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ const out = [];
155
+ const parse = (line) => {
156
+ const text = line.toString("utf8");
157
+ if (!text.trim())
158
+ return;
159
+ try {
160
+ out.push(JSON.parse(text));
161
+ }
162
+ catch { /* torn line */ }
163
+ };
164
+ try {
165
+ const chunk = Buffer.alloc(1 << 20);
166
+ let rest = Buffer.alloc(0);
167
+ for (let read = readSync(fd, chunk, 0, chunk.length, null); read > 0; read = readSync(fd, chunk, 0, chunk.length, null)) {
168
+ const data = rest.length ? Buffer.concat([rest, chunk.subarray(0, read)]) : chunk.subarray(0, read);
169
+ let start = 0;
170
+ for (let newline = data.indexOf(10); newline >= 0; newline = data.indexOf(10, start)) {
171
+ parse(data.subarray(start, newline));
172
+ start = newline + 1;
173
+ }
174
+ // Copied: `chunk` is reused by the next read.
175
+ rest = Buffer.from(data.subarray(start));
176
+ }
177
+ if (rest.length)
178
+ parse(rest);
179
+ }
180
+ catch { /* an unreadable tail: keep the entries read so far */ }
181
+ finally {
182
+ closeSync(fd);
183
+ }
184
+ return out;
185
+ }
186
+ /** End a torn last line, so the next appended entry starts a line of its own. */
187
+ terminateLog(dir) {
188
+ const file = join(dir, LOG_FILE);
189
+ let torn = false;
190
+ try {
191
+ const fd = openSync(file, "r");
192
+ try {
193
+ const size = fstatSync(fd).size;
194
+ const last = Buffer.alloc(1);
195
+ torn = size > 0 && readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 10;
196
+ }
197
+ finally {
198
+ closeSync(fd);
199
+ }
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ if (torn)
205
+ appendFileSync(file, "\n");
206
+ }
207
+ /** Sessions of one product, newest first. */
208
+ list(company, product) {
209
+ const base = this.productDir(company, product);
210
+ if (!existsSync(base))
211
+ return [];
212
+ const out = [];
213
+ for (const name of readdirSync(base)) {
214
+ if (!SESSION_ID_PATTERN.test(name))
215
+ continue;
216
+ const dir = join(base, name);
217
+ const meta = this.readMeta(dir);
218
+ if (meta)
219
+ out.push({ dir, meta });
220
+ }
221
+ return out.sort((a, b) => b.meta.started_at.localeCompare(a.meta.started_at));
222
+ }
223
+ }
224
+ //# sourceMappingURL=session-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../../src/playtest/session-store.ts"],"names":[],"mappings":"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"}
@@ -32,6 +32,8 @@ Use exact schemas, scoped mutations, and gate evidence to work on the selected U
32
32
  - Before batching, interpreting a failed gate, or applying fixes: [Batch and gates](references/batch-and-gates.md).
33
33
  - Before file-level repair, YAML edits, or offline asset investigation: [YAML and offline work](references/yaml-and-offline-work.md).
34
34
  - Before screenshots, event capture, or test runs: [Senses and tests](references/senses-and-tests.md).
35
+ - Before driving gameplay input (taps, drags, multi-second gestures — legacy Input Manager or Input System): activate `input-simulation` and follow its domain skill; read the reported `backend` array and refusals instead of reflecting into game input code.
36
+ - To playtest the game (read the screen, tap by label, wait, capture, isolate the save, record a report for a human): follow [Playtest](#playtest) below.
35
37
 
36
38
  ### Project-owned commands
37
39
 
@@ -52,9 +54,12 @@ commands in `batch_execute`.
52
54
 
53
55
  Use the always-visible `unity_open_mcp_jobs` for explicitly supported asynchronous
54
56
  operations. `start` requires `tool_or_command`, adapter `args`, and an
55
- `idempotency_key`. Supported targets are `unity_senses_run_tests` (ordinary test
56
- filters, no caller `run_id`, cancellation unsupported) and available async project
57
- commands (`args: {args: {...}, gate, paths_hint}`). Read terminal project-job gate
57
+ `idempotency_key` (optional for read-only targets). Supported targets are
58
+ `unity_senses_run_tests` (ordinary test filters, no caller `run_id`, cancellation
59
+ unsupported), available async project commands (`args: {args: {...}, gate,
60
+ paths_hint}`), and play-mode operations (`unity_open_mcp_inputsim_gesture`,
61
+ `unity_open_mcp_playtest_wait`, `unity_senses_capture_series`; the tool's own args;
62
+ cancellable — cancel releases held input and restores a wait's time scale). Read terminal project-job gate
58
63
  results even on failure/cancellation. An unsupported target must not be retried as
59
64
  an arbitrary background tool.
60
65
 
@@ -68,3 +73,15 @@ before retrying. Records and keys expire 30 minutes after completion and are los
68
73
  on server restart. `job_busy` means a project job owns the Editor: mutations, Editor-state
69
74
  writes, test starts and every `execute_csharp` (even `read_only: true`) are refused.
70
75
  Wait on `error.job.jobId` via jobs (owner only), use typed reads meanwhile, retry once; never loop-retry. See [Routing and lifecycle](references/routing-and-lifecycle.md).
76
+
77
+ ## Playtest
78
+
79
+ Activate `playtest` with `input-simulation` (`manage_tools` `activate_for`, intent "playtest …").
80
+
81
+ 1. **Prepare.** Device first: `game_view get` (note what to restore), then `set_device` (Simulator device + orientation; read `screen.safe_area` / `insets`) or `set_resolution`; continue only on `settled: true`. Isolate the state: `project_commands(action="list", query="playtest")` and invoke the project's `playtest.begin` when it exists; otherwise start the session with `snapshot: true`. Then `playtest_session {"action":"start","name":"…","device":"…"}` — from now your input / wait / capture / project-command calls are recorded with their frames and console errors (one open session per agent: a second start is `session_already_open`).
82
+ 2. **Boot** with `editor_set_state play` (then `editor_status` until playing) and the project's `playtest.boot` command. Never reflect into launchers, singletons or save systems.
83
+ 3. **Loop.** `ui_snapshot` (compact, paged) → act like a player: `target_text` on `inputsim_pointer` / `inputsim_touch` (resolve `ambiguous_target` with `index` / `under`, never by guessing names) or `inputsim_gesture` → one `playtest_wait` on a condition (`log` regex, `ui` query — `matches:false` for "gone", `object`, the project's read-only `project_command`, `frames`, `ms`, `all`/`any`) with a `timeout_ms`, never a polling loop of snapshots, steps or `execute_csharp`; read `fired`, on `timed_out` read `condition.last` → look: `unity_senses_capture_series` for anything animated (`start_on`, `contact_sheet: true`, `metric: "diff"` in a `metric_region`), a composed screenshot (no width/height; quote its `capturedAt`) for a static screen → `playtest_session {"action":"mark","label":"…","verdict":"ok|bug|suspicious","note":"…"}`.
84
+ 4. **Findings.** Every console exception or error is a finding of the step that caused it: read `since_last_mark.errors` on each mark; `fail_on: ["exception"]` stops a wait at the first one with its stack.
85
+ 5. **Finish.** `playtest_session {"action":"end"}` → report path + summary. Stop play mode; invoke `playtest.end`, or `player_state restore` (dry run: read `prefs.removed` / `files.removed`; then `confirm: true` with its `plan`, check `verified`) and `delete` the session's snapshot; put the Game view back (`set_resolution` with the `size_label` from `get`). Hand the report path and the bug / suspicious steps to the human.
86
+
87
+ Traps: one frame lies about animation — use a series (a tap and what follows: `inputsim_gesture` `capture_at_ms`). Captures name their device: set it before capturing; a hidden Game view tab does not render. Never mutate project assets (scenes, prefabs, writing snippets) during a playtest. Restore overwrites PlayerPrefs and deletes `persistentDataPath` files not in the snapshot; never restore a snapshot you did not take for this session. Waits / series over 40 s return a `job_id` → `jobs wait`. After an MCP server restart, `playtest_session status` lists your session under `orphaned`: `end` (or `resume`) with `session: "<id>"`.
@@ -14,6 +14,9 @@
14
14
  Exit codes: `0` = all tests passed, `2` = test failures present (NOT a process error — do not treat 2 as a crash), `3` = test runner setup failure. Known **batchmode-only** red tests that pass in the GUI editor — don't try to "fix" these, validate them in the GUI editor instead: in-process HTTP-server lifecycle (socket-disposed), GPU/SceneView (no graphic device under `-nographics`), Unity-6's removed `LogEntries.GetEntries(List<LogEntry>)` internal API (console/capture), MSBuild/Roslyn `Microsoft.Build.Utilities.Core` host assembly load, and TimeManager SerializedProperty float access.
15
15
  - `**unity_senses_read_console`** — console entries via reflection. Filter `type: "error"` to confirm clean compile. `detail: "summary"` for messages only (saves tokens); `detail: "verbose"` includes Unity-internal frames.
16
16
  - `**unity_senses_screenshot**` — Scene / Game / **composed** / isolated 2×2 composite of one GameObject. `view: "game"` renders `Camera.main` **only** — Screen-Space Overlay UI and multi-camera compositions are NOT in the frame; use `view: "composed"` to verify UI edits (the full player frame: all cameras by depth + Overlay canvases).
17
+ - **Composed size.** Omit `width`/`height` to get the game's native resolution (the Game view size or the Device Simulator screen; no Editor chrome, no device bezel). A `width`/`height` of another aspect is padded (letterbox), never stretched — read `scaled_from` and `content_rect`. `overlay: "safe_area"` tints the unsafe band and outlines the safe area. Every game/composed response names what it was taken at in `capturedAt` (mode, device or size entry, orientation, screen) — quote it when reporting a frame.
18
+ - **Device first, then capture.** Put the game on the target resolution/device with `unity_open_mcp_game_view` (`playtest` group) and wait for `settled: true` before capturing; a frame taken while `not_settled` shows the old size.
19
+ - `**unity_senses_capture_series**` (`playtest` group, play mode) — the composed frame at scheduled frames inside the player loop: `at_ms` / `every_ms`+`duration_ms` / `at_frames` (≤ 120), relative to the call or a `start_on` condition (playtest_wait shapes). Each entry names the `frame` / `play_time` its image shows; `unchanged` = same pixels as the previous capture (static screen or hidden Game view). `region` crop, `scale`, `metric` (`luminance` / `diff` inside `metric_region`) and `contact_sheet: true` (one tiled PNG labelled `#i F<frame> +<ms>MS`). Use it for anything animated — two one-shot frames cannot tell a pulse from a change. Long series return a `job_id`.
17
20
  - `**unity_senses_screenshot_camera**` — render from an arbitrary world-space pose (position + rotation + fov) without moving the scene/game camera; transient camera, scene camera untouched.
18
21
  - `**unity_senses_capture_inline**` — same targets as `screenshot` but returns the PNG as an inline base64 image (no temp file) for agents that don't read the filesystem.
19
22
  - `**unity_senses_visual_compare**` — visual regression: capture a named reference snapshot (`action: save`) from scene / game / isolated view, then `action: compare` a later capture against it to check whether an edit visually broke anything. Returns `pixelDiffPercent`, `mismatchedPixels`, `perceptualDistance` (8×8 average-hash Hamming distance — tolerates minor anti-aliasing drift), and `match` (true when `pixelDiffPercent <= sensitivity * 100`; default `sensitivity: 0.01` = 1%). On a non-zero diff, returns an inline diff image (mismatched pixels highlighted red over the current frame). References persist under `~/.unity-open-mcp/screenshots/references/`; `action: list` / `delete` manage them. The `compare` resamples the current capture to the reference dimensions if they differ, so the diff is resolution-stable.
@@ -5,6 +5,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5
5
  import type { Router } from "./router.js";
6
6
  import { LiveClient } from "./live-client.js";
7
7
  import type { BridgeEventStream } from "./event-stream.js";
8
+ import { PlaytestRecorder } from "./playtest/recorder.js";
8
9
  export declare function resolveRouteLogEnabled(env?: NodeJS.ProcessEnv): boolean;
9
10
  export declare const ROUTE_LOG_ENABLED: boolean;
10
11
  /** Log a happy-path route trace iff the opt-in env flag is set. No-op by
@@ -74,6 +75,26 @@ export declare class ToolRouter implements Router {
74
75
  get jobs(): JobManager;
75
76
  closeJobs(): void;
76
77
  routeJobs(args: Record<string, unknown>, identity?: RequestIdentity): Promise<CallToolResult>;
78
+ /**
79
+ * Play-mode operations (`inputsim_gesture`, `playtest_wait`,
80
+ * `capture_series`): the bridge starts the operation on its shared play-mode
81
+ * scheduler and answers at once; this route polls POST /playmode-operations
82
+ * until the terminal result, inside the host-safe window. An operation whose
83
+ * worst case (`playmodeEstimateMs`: last keyframe / capture, the wait's
84
+ * timeout, a series' start_on wait + schedule + encoding) exceeds the inline
85
+ * budget is started as a `jobs` play-mode operation instead and the call
86
+ * returns that job id. One that outlives the inline poll anyway (gameplay
87
+ * time, a paused Editor) is cancelled — which releases held input and
88
+ * restores a wait's time scale — and the error points at the jobs route.
89
+ */
90
+ private routePlaymodeOperation;
91
+ /**
92
+ * `game_view`: set_resolution / set_device in play mode return once the game
93
+ * reports the new size — the bridge starts a settle watch on its play-mode
94
+ * scheduler and this route polls it (then re-reads `get`), or answers
95
+ * `not_settled`. get / list_devices and edit-mode changes pass through.
96
+ */
97
+ private routeGameView;
77
98
  private readonly modelCache;
78
99
  /**
79
100
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -120,7 +141,30 @@ export declare class ToolRouter implements Router {
120
141
  * request's agent id.
121
142
  */
122
143
  routeOverride(toolName: string, args: Record<string, unknown>, overrideLive: LiveClient, identity: RequestIdentity): Promise<CallToolResult>;
144
+ /**
145
+ * Every entry point funnels through here (direct, manage_tools invoke, port
146
+ * override). While the caller's agent owner has an open playtest session, a
147
+ * recorded call (input / wait / capture / project command / play-mode and
148
+ * Game view changes) is logged around its dispatch; everything else — and
149
+ * every call when no session is open — goes straight through. The recorder
150
+ * never changes a result and never fails a call.
151
+ */
123
152
  private routeCore;
153
+ /** The owner a request files sessions (and jobs) under. */
154
+ private ownerOf;
155
+ private playtestRecorder?;
156
+ /** The session recorder, created on first use and fed by the bridge event stream. */
157
+ get playtest(): PlaytestRecorder;
158
+ /** Install a recorder (tests use a temporary root). */
159
+ usePlaytestRecorder(recorder: PlaytestRecorder): void;
160
+ /**
161
+ * `unity_open_mcp_playtest_session` — local route. Bridge reads the session
162
+ * needs (company / product, the Game view state, a player-state snapshot or
163
+ * diff) go through the normal route as the same owner, and only when the
164
+ * bridge answers /ping; otherwise the recorder falls back to disk.
165
+ */
166
+ private routePlaytestSession;
167
+ private routeDispatch;
124
168
  private routeValidated;
125
169
  private routeUnchecked;
126
170
  private batchPingResult;
@@ -1,4 +1,7 @@
1
- import { testRunOperation, projectCommandOperation } from "./jobs/adapters.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { testRunOperation, projectCommandOperation, playmodeOperation, pollPlaymodeOperation, playmodeResult, toolOutput, toolResultBody, PLAYMODE_OPERATION_TOOLS, playmodeEstimateMs, playmodeFailureCode } from "./jobs/adapters.js";
3
+ import { PLAYMODE_ROUTES } from "./playmode-routes.js";
4
+ import { routeGameView as settleGameView } from "./game-view-settle.js";
2
5
  import { JobManager, JobManagerError } from "./jobs/job-manager.js";
3
6
  import { handleJobs } from "./jobs/handler.js";
4
7
  import { PROCESS_AGENT_ID, PROCESS_IDENTITY } from "./agent-identity.js";
@@ -29,8 +32,12 @@ import { generateSkill } from "./skill/generate-skill.js";
29
32
  import { getKnownClientKeys } from "./skill/client-paths.js";
30
33
  import { ALL_TOOLS } from "./tools/index.js";
31
34
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
32
- import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT } from "./constants.js";
35
+ import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS } from "./constants.js";
33
36
  import { findUnityForProject, readProcessCommandLine } from "./running-unity.js";
37
+ import { PlaytestRecorder, PlaytestError, PLAYTEST_SESSION_TOOL } from "./playtest/recorder.js";
38
+ import { secretEnvValues } from "./playtest/redact.js";
39
+ import { sessionsRoot } from "./playtest/session-store.js";
40
+ import { PLAYTEST_SESSION_ACTION_KEYS } from "./tools/playtest-session.js";
34
41
  // M31-optimizations Plan 1 / L14 — opt-in route-logging gate. Resolved once at
35
42
  // module load into a boolean; the per-dispatch log sites read the boolean
36
43
  // (not process.env on every call) so the hot path stays a single branch.
@@ -573,6 +580,11 @@ export class ToolRouter {
573
580
  if (!this.jobs.hasOperation(name))
574
581
  this.jobs.register(name, testRunOperation(clientFor));
575
582
  }
583
+ else if (PLAYMODE_OPERATION_TOOLS.has(name)) {
584
+ // One generic adapter for every tool on the bridge's shared play-mode scheduler.
585
+ if (!this.jobs.hasOperation(name))
586
+ this.jobs.register(name, playmodeOperation(name, PLAYMODE_OPERATION_TOOLS.get(name), clientFor));
587
+ }
576
588
  else if (name.startsWith("project.") && !(typeof key === "string" && this.jobs.hasKey(owner, key))) {
577
589
  // A retry whose key already names a job gets that job (or
578
590
  // idempotency_conflict) from start() without the catalog, which may
@@ -602,6 +614,80 @@ export class ToolRouter {
602
614
  return localError(error instanceof JobManagerError ? error.code : "invalid_arguments", error instanceof Error ? error.message : String(error));
603
615
  }
604
616
  }
617
+ /**
618
+ * Play-mode operations (`inputsim_gesture`, `playtest_wait`,
619
+ * `capture_series`): the bridge starts the operation on its shared play-mode
620
+ * scheduler and answers at once; this route polls POST /playmode-operations
621
+ * until the terminal result, inside the host-safe window. An operation whose
622
+ * worst case (`playmodeEstimateMs`: last keyframe / capture, the wait's
623
+ * timeout, a series' start_on wait + schedule + encoding) exceeds the inline
624
+ * budget is started as a `jobs` play-mode operation instead and the call
625
+ * returns that job id. One that outlives the inline poll anyway (gameplay
626
+ * time, a paused Editor) is cancelled — which releases held input and
627
+ * restores a wait's time scale — and the error points at the jobs route.
628
+ */
629
+ async routePlaymodeOperation(name, args, live, identity) {
630
+ const spec = PLAYMODE_ROUTES[name];
631
+ const estimateMs = playmodeEstimateMs(name, args);
632
+ if (estimateMs > spec.inlineLimitMs) {
633
+ // The job owns the bridge operation id; a caller's operation_id keeps
634
+ // its retry meaning as the job's idempotency key.
635
+ const { operation_id: retryKey, ...jobArgs } = args;
636
+ const started = await this.routeJobs({ action: "start", tool_or_command: name, args: jobArgs,
637
+ ...(typeof retryKey === "string" ? { idempotency_key: retryKey } : {}) }, identity);
638
+ const body = toolResultBody(started);
639
+ if (started.isError || typeof body.job_id !== "string")
640
+ return started;
641
+ return sourceResult({ ...body, note: `${spec.label} can take ${Math.round(estimateMs)} ms (> ${spec.inlineLimitMs} ms inline budget); it runs as a job.`,
642
+ agentNextSteps: [`Call unity_open_mcp_jobs {"action":"wait","job_id":"${body.job_id}","timeout_ms":30000} until the state is terminal; the result holds ${spec.resultHint}.`] }, "local");
643
+ }
644
+ const operationId = typeof args.operation_id === "string" ? args.operation_id : randomUUID();
645
+ const start = await live.route(name, { ...args, operation_id: operationId });
646
+ const started = toolOutput(start);
647
+ if (start.isError || started.error || !started.operation)
648
+ return injectRouteMeta(start, { route: "live" });
649
+ const outcome = await pollPlaymodeOperation(live, operationId, {
650
+ deadlineMs: Math.min(estimateMs + 15_000, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS - 5_000),
651
+ pollIntervalMs: estimateMs < 2_000 ? 50 : 100,
652
+ unreachableMs: 10_000,
653
+ });
654
+ const meta = spec.startMeta(started);
655
+ // The poll's own deadline cancel, acknowledged: the operation stalled (a
656
+ // paused Editor never ticks it), it did not end by itself.
657
+ const stalled = outcome.kind === "deadline" || (outcome.kind === "terminal" && outcome.cancelSent && outcome.snapshot.state === "cancelled");
658
+ if (stalled) {
659
+ return sourceResult({ error: { code: spec.stalledCode, message: `The ${spec.noun} did not finish inside the inline window (a paused Editor, gameplay time, or a slow frame rate); it was cancelled and its cleanup ${spec.cleanup}.` },
660
+ operationId, last: outcome.snapshot, agentNextSteps: [`Check that play mode is running (not paused) and the Game view renders, then retry — or run it as a job: unity_open_mcp_jobs {"action":"start","tool_or_command":"${name}","args":{…}}, then wait on the job id.`] }, "live", true);
661
+ }
662
+ switch (outcome.kind) {
663
+ case "lost":
664
+ return sourceResult({ error: { code: "operation_lost", message: `The bridge lost the ${spec.noun} (domain reload or Editor restart); its cleanup ${spec.cleanup}.` }, operationId }, "live", true);
665
+ case "unreachable":
666
+ return sourceResult({ error: { code: "job_status_unavailable", message: outcome.error }, operationId }, "live", true);
667
+ }
668
+ if (outcome.kind !== "terminal")
669
+ throw new Error("unreachable play-mode poll outcome");
670
+ const snapshot = outcome.snapshot;
671
+ const result = { ...meta, ...playmodeResult(snapshot) };
672
+ if (snapshot.state === "succeeded")
673
+ return sourceResult({ status: "ok", ...result }, "live");
674
+ const code = playmodeFailureCode(snapshot, spec.failedCode);
675
+ // The operation's own `error` (a message string) becomes error.message.
676
+ return sourceResult({ ...result, error: { code, message: String(snapshot.result?.error ?? `${spec.label} ended ${snapshot.reason ?? snapshot.state}; its cleanup ${spec.cleanup}.`) },
677
+ ...(spec.failureNextSteps(code) ? { agentNextSteps: spec.failureNextSteps(code) } : {}) }, "live", true);
678
+ }
679
+ /**
680
+ * `game_view`: set_resolution / set_device in play mode return once the game
681
+ * reports the new size — the bridge starts a settle watch on its play-mode
682
+ * scheduler and this route polls it (then re-reads `get`), or answers
683
+ * `not_settled`. get / list_devices and edit-mode changes pass through.
684
+ */
685
+ async routeGameView(args, live) {
686
+ const outcome = await settleGameView(live, args);
687
+ if (outcome.kind === "passthrough")
688
+ return injectRouteMeta(outcome.result, { route: "live" });
689
+ return sourceResult(outcome.body, "live", outcome.isError);
690
+ }
605
691
  modelCache = new AssetModelCache();
606
692
  /**
607
693
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -628,6 +714,9 @@ export class ToolRouter {
628
714
  ["unity_open_mcp_generate_skill", (_l, a) => this.routeGenerateSkill(a)],
629
715
  ["unity_open_mcp_manage_tools", (l, a, id) => this.routeManageTools(a, l, id)],
630
716
  ["unity_open_mcp_project_commands", (l, a) => this.routeProjectCommands(a, l)],
717
+ // Playtest session recorder: the log lives here (the server sees every
718
+ // call); bridge reads go through the normal route as the same owner.
719
+ [PLAYTEST_SESSION_TOOL, (l, a, id) => this.routePlaytestSession(a, l, id)],
631
720
  // Live tool whose release lookups (npm latest, GitHub tag) run HERE so
632
721
  // the Editor main thread never blocks on the network.
633
722
  ["unity_open_mcp_upgrade", (l, a) => this.routeUpgrade(a, l)],
@@ -636,6 +725,15 @@ export class ToolRouter {
636
725
  // the process agent id, whatever _meta.agentId the call carried. Direct,
637
726
  // manage_tools(invoke) and port-override calls therefore share one owner.
638
727
  ["unity_open_mcp_jobs", (_l, a, id) => this.routeJobs(a, id)],
728
+ // Gestures, waits and capture series run on the bridge's play-mode
729
+ // scheduler: start, then poll the operation to its terminal result here
730
+ // (long ones become a job).
731
+ ["unity_open_mcp_inputsim_gesture", (l, a, id) => this.routePlaymodeOperation("unity_open_mcp_inputsim_gesture", a, l, id)],
732
+ ["unity_open_mcp_playtest_wait", (l, a, id) => this.routePlaymodeOperation("unity_open_mcp_playtest_wait", a, l, id)],
733
+ ["unity_senses_capture_series", (l, a, id) => this.routePlaymodeOperation("unity_senses_capture_series", a, l, id)],
734
+ // Game view / Simulator changes settle over play-mode frames: poll the
735
+ // bridge's settle watch here so set_* returns once the game sees the size.
736
+ ["unity_open_mcp_game_view", (l, a) => this.routeGameView(a, l)],
639
737
  ["unity_open_mcp_bridge_status", (l, a) => this.routeBridgeStatus(a, l)],
640
738
  // M31 Plan 3 — Editor fd-exhaustion operator surfaces. restart_editor
641
739
  // is the reactive kill half (acts AFTER the Editor is hung);
@@ -756,7 +854,142 @@ export class ToolRouter {
756
854
  async routeOverride(toolName, args, overrideLive, identity) {
757
855
  return this.routeCore(overrideLive, toolName, args, identity);
758
856
  }
857
+ /**
858
+ * Every entry point funnels through here (direct, manage_tools invoke, port
859
+ * override). While the caller's agent owner has an open playtest session, a
860
+ * recorded call (input / wait / capture / project command / play-mode and
861
+ * Game view changes) is logged around its dispatch; everything else — and
862
+ * every call when no session is open — goes straight through. The recorder
863
+ * never changes a result and never fails a call.
864
+ */
759
865
  async routeCore(live, toolName, args, identity) {
866
+ const recorder = this.playtestRecorder;
867
+ if (!recorder || recorder.openCount === 0 || toolName === PLAYTEST_SESSION_TOOL)
868
+ return this.routeDispatch(live, toolName, args, identity);
869
+ const owner = this.ownerOf(identity);
870
+ if (!recorder.isRecording(owner))
871
+ return this.routeDispatch(live, toolName, args, identity);
872
+ if (toolName === "unity_open_mcp_jobs" && (args.action === "wait" || args.action === "status")) {
873
+ const observed = await this.routeDispatch(live, toolName, args, identity);
874
+ try {
875
+ recorder.observeJob(owner, args, observed);
876
+ }
877
+ catch (error) {
878
+ console.error("[unity-open-mcp] playtest recorder:", error);
879
+ }
880
+ return observed;
881
+ }
882
+ let handle = null;
883
+ try {
884
+ handle = recorder.begin(owner, toolName, args);
885
+ }
886
+ catch (error) {
887
+ console.error("[unity-open-mcp] playtest recorder:", error);
888
+ }
889
+ if (!handle)
890
+ return this.routeDispatch(live, toolName, args, identity);
891
+ this.eventStream.reconnectNow?.call(this.eventStream);
892
+ let result;
893
+ try {
894
+ result = await this.routeDispatch(live, toolName, args, identity);
895
+ }
896
+ catch (error) {
897
+ try {
898
+ recorder.finish(handle, args, null, error);
899
+ }
900
+ catch (inner) {
901
+ console.error("[unity-open-mcp] playtest recorder:", inner);
902
+ }
903
+ throw error;
904
+ }
905
+ try {
906
+ recorder.finish(handle, args, result);
907
+ }
908
+ catch (error) {
909
+ console.error("[unity-open-mcp] playtest recorder:", error);
910
+ }
911
+ return result;
912
+ }
913
+ /** The owner a request files sessions (and jobs) under. */
914
+ ownerOf(identity) {
915
+ return { project: this.projectPath || "default", agent: identity.agent, ...(identity.port === undefined ? {} : { port: identity.port }) };
916
+ }
917
+ playtestRecorder;
918
+ /** The session recorder, created on first use and fed by the bridge event stream. */
919
+ get playtest() {
920
+ if (!this.playtestRecorder) {
921
+ this.usePlaytestRecorder(new PlaytestRecorder({
922
+ root: sessionsRoot(),
923
+ projectPath: this.projectPath || "default",
924
+ knownSecrets: [this.projectPath ? resolveAuthToken(this.projectPath) : undefined, ...secretEnvValues()],
925
+ streamConnected: () => this.eventStream.isConnected === true,
926
+ }));
927
+ }
928
+ return this.playtestRecorder;
929
+ }
930
+ /** Install a recorder (tests use a temporary root). */
931
+ usePlaytestRecorder(recorder) {
932
+ this.playtestRecorder = recorder;
933
+ this.eventStream.addListener?.call(this.eventStream, event => recorder.onEvent(event));
934
+ }
935
+ /**
936
+ * `unity_open_mcp_playtest_session` — local route. Bridge reads the session
937
+ * needs (company / product, the Game view state, a player-state snapshot or
938
+ * diff) go through the normal route as the same owner, and only when the
939
+ * bridge answers /ping; otherwise the recorder falls back to disk.
940
+ */
941
+ async routePlaytestSession(args, live, identity) {
942
+ const action = String(args.action ?? "");
943
+ const allowed = PLAYTEST_SESSION_ACTION_KEYS[action];
944
+ if (!allowed)
945
+ return localError("invalid_arguments", `Unknown action '${action}'.`);
946
+ const extra = Object.keys(args).filter(key => key !== "action" && !allowed.includes(key));
947
+ if (extra.length)
948
+ return localError("invalid_arguments", `Keys not used by ${action}: ${extra.join(", ")}.`);
949
+ const recorder = this.playtest;
950
+ const owner = this.ownerOf(identity);
951
+ let available;
952
+ const call = async (tool, toolArgs) => {
953
+ available ??= await live.isLiveAvailable(1_500);
954
+ if (!available)
955
+ return localError("bridge_unavailable", "The Unity bridge is not reachable.");
956
+ const definition = ALL_TOOLS.find(t => t.name === tool);
957
+ return this.routeDispatch(live, tool, definition ? withSchemaDefaults(definition, toolArgs) : toolArgs, identity);
958
+ };
959
+ const sessionArg = typeof args.session === "string" ? args.session : undefined;
960
+ try {
961
+ if (action !== "status" || recorder.isRecording(owner) === false)
962
+ await recorder.prepare(call);
963
+ if (action === "start" || action === "resume" || action === "end")
964
+ this.eventStream.ensureSubscription?.call(this.eventStream);
965
+ let body;
966
+ switch (action) {
967
+ case "start":
968
+ body = await recorder.start(owner, args, call);
969
+ break;
970
+ case "mark":
971
+ body = recorder.mark(owner, args, sessionArg);
972
+ break;
973
+ case "status":
974
+ body = recorder.status(owner);
975
+ break;
976
+ case "resume":
977
+ body = recorder.resume(owner, sessionArg);
978
+ break;
979
+ case "end":
980
+ body = await recorder.end(owner, args, call);
981
+ break;
982
+ default: body = recorder.list(owner, typeof args.limit === "number" ? args.limit : 10);
983
+ }
984
+ return sourceResult(body, "local");
985
+ }
986
+ catch (error) {
987
+ if (error instanceof PlaytestError)
988
+ return sourceResult({ error: { code: error.code, message: error.message }, ...error.detail }, "local", true);
989
+ return localError("internal_error", error instanceof Error ? error.message : String(error));
990
+ }
991
+ }
992
+ async routeDispatch(live, toolName, args, identity) {
760
993
  // Every entry point funnels through here, so an explicit project_path is
761
994
  // retargeted before validation and wiring wherever the call came from.
762
995
  if ((toolName === "unity_open_mcp_capabilities" || toolName === "unity_open_mcp_bridge_status") && args.project_path !== undefined)