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.
- package/dist/capabilities/cost-hints.js +73 -0
- package/dist/capabilities/cost-hints.js.map +1 -1
- package/dist/capabilities/intent-groups.js +76 -4
- package/dist/capabilities/intent-groups.js.map +1 -1
- package/dist/capabilities/lifecycle.js +9 -0
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.js +75 -17
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +2 -2
- package/dist/event-stream.d.ts +10 -0
- package/dist/event-stream.js +29 -0
- package/dist/event-stream.js.map +1 -1
- package/dist/game-view-settle.d.ts +18 -0
- package/dist/game-view-settle.js +81 -0
- package/dist/game-view-settle.js.map +1 -0
- package/dist/jobs/adapters.d.ts +59 -0
- package/dist/jobs/adapters.js +226 -0
- package/dist/jobs/adapters.js.map +1 -1
- package/dist/live-client.d.ts +10 -0
- package/dist/live-client.js +26 -3
- package/dist/live-client.js.map +1 -1
- package/dist/local-tools.js +3 -0
- package/dist/local-tools.js.map +1 -1
- package/dist/playmode-routes.d.ts +17 -0
- package/dist/playmode-routes.js +50 -0
- package/dist/playmode-routes.js.map +1 -0
- package/dist/playtest/png.d.ts +27 -0
- package/dist/playtest/png.js +248 -0
- package/dist/playtest/png.js.map +1 -0
- package/dist/playtest/recorder.d.ts +203 -0
- package/dist/playtest/recorder.js +945 -0
- package/dist/playtest/recorder.js.map +1 -0
- package/dist/playtest/redact.d.ts +16 -0
- package/dist/playtest/redact.js +124 -0
- package/dist/playtest/redact.js.map +1 -0
- package/dist/playtest/report.d.ts +73 -0
- package/dist/playtest/report.js +477 -0
- package/dist/playtest/report.js.map +1 -0
- package/dist/playtest/session-store.d.ts +78 -0
- package/dist/playtest/session-store.js +224 -0
- package/dist/playtest/session-store.js.map +1 -0
- package/dist/skill/SKILL.md +20 -3
- package/dist/skill/references/senses-and-tests.md +3 -0
- package/dist/tool-router.d.ts +44 -0
- package/dist/tool-router.js +235 -2
- package/dist/tool-router.js.map +1 -1
- package/dist/tools/batch-execute.js +5 -1
- package/dist/tools/batch-execute.js.map +1 -1
- package/dist/tools/capture-inline.js +30 -12
- package/dist/tools/capture-inline.js.map +1 -1
- package/dist/tools/capture-series.d.ts +46 -0
- package/dist/tools/capture-series.js +116 -0
- package/dist/tools/capture-series.js.map +1 -0
- package/dist/tools/game-view.d.ts +44 -0
- package/dist/tools/game-view.js +103 -0
- package/dist/tools/game-view.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +34 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsim-gesture.d.ts +46 -0
- package/dist/tools/inputsim-gesture.js +123 -0
- package/dist/tools/inputsim-gesture.js.map +1 -0
- package/dist/tools/inputsim-pointer.js +35 -2
- package/dist/tools/inputsim-pointer.js.map +1 -1
- package/dist/tools/inputsim-touch.js +42 -22
- package/dist/tools/inputsim-touch.js.map +1 -1
- package/dist/tools/jobs.js +1 -1
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/player-state.d.ts +42 -0
- package/dist/tools/player-state.js +142 -0
- package/dist/tools/player-state.js.map +1 -0
- package/dist/tools/playtest-conditions.d.ts +334 -0
- package/dist/tools/playtest-conditions.js +99 -0
- package/dist/tools/playtest-conditions.js.map +1 -0
- package/dist/tools/playtest-session.d.ts +45 -0
- package/dist/tools/playtest-session.js +105 -0
- package/dist/tools/playtest-session.js.map +1 -0
- package/dist/tools/playtest-wait.d.ts +46 -0
- package/dist/tools/playtest-wait.js +86 -0
- package/dist/tools/playtest-wait.js.map +1 -0
- package/dist/tools/screenshot.js +31 -8
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/text-target.d.ts +22 -0
- package/dist/tools/text-target.js +39 -0
- package/dist/tools/text-target.js.map +1 -0
- package/dist/tools/ui-snapshot.d.ts +41 -0
- package/dist/tools/ui-snapshot.js +87 -0
- package/dist/tools/ui-snapshot.js.map +1 -0
- 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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"}
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -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
|
|
56
|
-
filters, no caller `run_id`, cancellation
|
|
57
|
-
commands (`args: {args: {...}, 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.
|
package/dist/tool-router.d.ts
CHANGED
|
@@ -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;
|
package/dist/tool-router.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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)
|