@chrrxs/robloxstudio-mcp-inspector 2.11.2 → 2.11.3
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/index.js
CHANGED
|
@@ -1217,7 +1217,28 @@ ${s}
|
|
|
1217
1217
|
}
|
|
1218
1218
|
function buildModuleScriptInvokeWrapper(opts) {
|
|
1219
1219
|
const wrapped = `return ((function()
|
|
1220
|
+
local __mcp_output = {}
|
|
1221
|
+
local __mcp_real_print = print
|
|
1222
|
+
local __mcp_real_warn = warn
|
|
1223
|
+
local print = function(...)
|
|
1224
|
+
__mcp_real_print(...)
|
|
1225
|
+
local args = {...}
|
|
1226
|
+
local parts = table.create(#args)
|
|
1227
|
+
for i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
1228
|
+
table.insert(__mcp_output, table.concat(parts, "\\t"))
|
|
1229
|
+
end
|
|
1230
|
+
local warn = function(...)
|
|
1231
|
+
__mcp_real_warn(...)
|
|
1232
|
+
local args = {...}
|
|
1233
|
+
local parts = table.create(#args)
|
|
1234
|
+
for i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
1235
|
+
table.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
|
|
1236
|
+
end
|
|
1237
|
+
local function __mcp_run()
|
|
1220
1238
|
${opts.userCode}
|
|
1239
|
+
end
|
|
1240
|
+
local ok, errOrValue = xpcall(__mcp_run, debug.traceback)
|
|
1241
|
+
return { ok = ok, value = errOrValue, output = __mcp_output }
|
|
1221
1242
|
end)())`;
|
|
1222
1243
|
return `
|
|
1223
1244
|
local HttpService = game:GetService("HttpService")
|
|
@@ -1234,15 +1255,29 @@ m.Name = "__MCPEvalPayload"
|
|
|
1234
1255
|
local okSet, setErr = pcall(function() m.Source = USER_CODE end)
|
|
1235
1256
|
if not okSet then
|
|
1236
1257
|
m:Destroy()
|
|
1237
|
-
return HttpService:JSONEncode({ bridge = "ok", ok = false,
|
|
1258
|
+
return HttpService:JSONEncode({ bridge = "ok", ok = false, error = "ModuleScript Source set failed: " .. tostring(setErr) })
|
|
1238
1259
|
end
|
|
1239
1260
|
m.Parent = workspace
|
|
1240
|
-
local
|
|
1261
|
+
local bridgeOk, inner = bf:Invoke(m)
|
|
1241
1262
|
m:Destroy()
|
|
1263
|
+
if not bridgeOk then
|
|
1264
|
+
return HttpService:JSONEncode({ bridge = "ok", ok = false, error = tostring(inner) })
|
|
1265
|
+
end
|
|
1266
|
+
-- inner is the {ok, value, output} table from our IIFE. Defensive: if it's
|
|
1267
|
+
-- somehow not a table (caller bypassed the wrapper), fall back to old shape.
|
|
1268
|
+
if typeof(inner) ~= "table" then
|
|
1269
|
+
return HttpService:JSONEncode({
|
|
1270
|
+
bridge = "ok",
|
|
1271
|
+
ok = true,
|
|
1272
|
+
result = if inner == nil then nil else tostring(inner),
|
|
1273
|
+
})
|
|
1274
|
+
end
|
|
1242
1275
|
return HttpService:JSONEncode({
|
|
1243
1276
|
bridge = "ok",
|
|
1244
|
-
ok = ok,
|
|
1245
|
-
result = if
|
|
1277
|
+
ok = inner.ok == true,
|
|
1278
|
+
result = if inner.ok and inner.value ~= nil then tostring(inner.value) else nil,
|
|
1279
|
+
error = if not inner.ok then tostring(inner.value) else nil,
|
|
1280
|
+
output = inner.output or {},
|
|
1246
1281
|
})
|
|
1247
1282
|
`;
|
|
1248
1283
|
}
|
|
@@ -3073,14 +3108,42 @@ var init_proxy_bridge_service = __esm({
|
|
|
3073
3108
|
"../core/dist/proxy-bridge-service.js"() {
|
|
3074
3109
|
"use strict";
|
|
3075
3110
|
init_bridge_service();
|
|
3076
|
-
ProxyBridgeService = class extends BridgeService {
|
|
3111
|
+
ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
3077
3112
|
primaryBaseUrl;
|
|
3078
3113
|
proxyInstanceId;
|
|
3079
3114
|
proxyRequestTimeout = 3e4;
|
|
3115
|
+
cachedInstances = [];
|
|
3116
|
+
refreshTimer;
|
|
3117
|
+
static REFRESH_INTERVAL_MS = 1e3;
|
|
3080
3118
|
constructor(primaryBaseUrl) {
|
|
3081
3119
|
super();
|
|
3082
3120
|
this.primaryBaseUrl = primaryBaseUrl;
|
|
3083
3121
|
this.proxyInstanceId = uuidv42();
|
|
3122
|
+
this.refreshInstances();
|
|
3123
|
+
this.refreshTimer = setInterval(() => this.refreshInstances(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
|
|
3124
|
+
}
|
|
3125
|
+
async refreshInstances() {
|
|
3126
|
+
try {
|
|
3127
|
+
const res = await fetch(`${this.primaryBaseUrl}/instances`);
|
|
3128
|
+
if (!res.ok)
|
|
3129
|
+
return;
|
|
3130
|
+
const body = await res.json();
|
|
3131
|
+
if (Array.isArray(body.instances)) {
|
|
3132
|
+
this.cachedInstances = body.instances;
|
|
3133
|
+
}
|
|
3134
|
+
} catch {
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
getInstances() {
|
|
3138
|
+
return this.cachedInstances;
|
|
3139
|
+
}
|
|
3140
|
+
/** Called when this proxy is being discarded (e.g. promotion to primary
|
|
3141
|
+
replaced it). Stops the background refresh so it doesn't leak. */
|
|
3142
|
+
stop() {
|
|
3143
|
+
if (this.refreshTimer) {
|
|
3144
|
+
clearInterval(this.refreshTimer);
|
|
3145
|
+
this.refreshTimer = void 0;
|
|
3146
|
+
}
|
|
3084
3147
|
}
|
|
3085
3148
|
async sendRequest(endpoint, data, target = "edit") {
|
|
3086
3149
|
const controller = new AbortController();
|
|
@@ -3208,8 +3271,12 @@ var init_server = __esm({
|
|
|
3208
3271
|
const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
|
|
3209
3272
|
try {
|
|
3210
3273
|
const result = await listenWithRetry(candidateApp, host, basePort, 1);
|
|
3274
|
+
const oldBridge = this.bridge;
|
|
3211
3275
|
this.bridge = candidateBridge;
|
|
3212
3276
|
this.tools = candidateTools;
|
|
3277
|
+
if (oldBridge instanceof ProxyBridgeService) {
|
|
3278
|
+
oldBridge.stop();
|
|
3279
|
+
}
|
|
3213
3280
|
httpHandle = result.server;
|
|
3214
3281
|
boundPort = result.port;
|
|
3215
3282
|
primaryApp = candidateApp;
|
|
@@ -3272,6 +3339,9 @@ var init_server = __esm({
|
|
|
3272
3339
|
clearInterval(cleanupInterval);
|
|
3273
3340
|
if (promotionInterval)
|
|
3274
3341
|
clearInterval(promotionInterval);
|
|
3342
|
+
if (this.bridge instanceof ProxyBridgeService) {
|
|
3343
|
+
this.bridge.stop();
|
|
3344
|
+
}
|
|
3275
3345
|
await this.server.close().catch(() => {
|
|
3276
3346
|
});
|
|
3277
3347
|
if (httpHandle)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrrxs/robloxstudio-mcp-inspector",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.3",
|
|
4
4
|
"description": "Read-only MCP Server for Roblox Studio (fork of boshyxd/robloxstudio-mcp-inspector with per-peer execute_luau fixes baked in)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -3255,56 +3255,52 @@ local function executeLuau(requestData)
|
|
|
3255
3255
|
error = "Code is required",
|
|
3256
3256
|
}
|
|
3257
3257
|
end
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3258
|
+
-- Both execution paths (loadstring + ModuleScript-require fallback) run
|
|
3259
|
+
-- the SAME wrapped source so they return a uniform { ok, value, output }
|
|
3260
|
+
-- shape. Two problems the wrapper solves at once:
|
|
3261
|
+
--
|
|
3262
|
+
-- 1. pcall(require, m) swallows the real error and returns Roblox's
|
|
3263
|
+
-- generic "Requested module experienced an error while loading"
|
|
3264
|
+
-- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
3265
|
+
-- ModuleScript itself returning successfully — the real error +
|
|
3266
|
+
-- traceback live in the returned table.
|
|
3267
|
+
--
|
|
3268
|
+
-- 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
3269
|
+
-- side getfenv print/warn override never reached user prints. A
|
|
3270
|
+
-- lexical local print/warn inside the IIFE captures user prints
|
|
3271
|
+
-- regardless of which path executes. We also call the real global
|
|
3272
|
+
-- print/warn so messages still flow to Studio's output and
|
|
3273
|
+
-- LogService.MessageOut (which powers get_runtime_logs).
|
|
3274
|
+
--
|
|
3275
|
+
-- Prints from required sub-modules don't reach this capture (they have
|
|
3276
|
+
-- their own env) — those go through the runtime log buffer.
|
|
3277
|
+
local wrapped = `return ((function()\
|
|
3278
|
+
\tlocal __mcp_output = \{\}\
|
|
3279
|
+
\tlocal __mcp_real_print = print\
|
|
3280
|
+
\tlocal __mcp_real_warn = warn\
|
|
3281
|
+
\tlocal print = function(...)\
|
|
3282
|
+
\t\t__mcp_real_print(...)\
|
|
3283
|
+
\t\tlocal args = \{...\}\
|
|
3284
|
+
\t\tlocal parts = table.create(#args)\
|
|
3285
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3286
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
3287
|
+
\tend\
|
|
3288
|
+
\tlocal warn = function(...)\
|
|
3289
|
+
\t\t__mcp_real_warn(...)\
|
|
3290
|
+
\t\tlocal args = \{...\}\
|
|
3291
|
+
\t\tlocal parts = table.create(#args)\
|
|
3292
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3293
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
3294
|
+
\tend\
|
|
3295
|
+
\tlocal function __mcp_run()\
|
|
3296
|
+
{code}\
|
|
3297
|
+
\tend\
|
|
3298
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
|
|
3299
|
+
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
3300
|
+
end)())`
|
|
3294
3301
|
local runViaModuleScript = function()
|
|
3295
3302
|
local m = Instance.new("ModuleScript")
|
|
3296
3303
|
m.Name = "__MCPExecLuauPayload"
|
|
3297
|
-
-- Wrap user code in an IIFE so require() always gets exactly one
|
|
3298
|
-
-- return value. Without this, code like `print("x")` errors with
|
|
3299
|
-
-- "Module code did not return exactly one value" because top-level
|
|
3300
|
-
-- ModuleScripts must return exactly one value.
|
|
3301
|
-
--
|
|
3302
|
-
-- The DOUBLE parens around the call are load-bearing: in Luau,
|
|
3303
|
-
-- `return f()` propagates whatever multi-value tuple f returns,
|
|
3304
|
-
-- including zero values. Outer parens adjust the call to exactly
|
|
3305
|
-
-- one value (the first, or nil). So `return ((f)())` always
|
|
3306
|
-
-- returns exactly one value, regardless of what f does.
|
|
3307
|
-
local wrapped = `return ((function()\n{code}\nend)())`
|
|
3308
3304
|
local okSet, setErr = pcall(function()
|
|
3309
3305
|
m.Source = wrapped
|
|
3310
3306
|
end)
|
|
@@ -3328,7 +3324,7 @@ local function executeLuau(requestData)
|
|
|
3328
3324
|
return matchStart ~= nil
|
|
3329
3325
|
end
|
|
3330
3326
|
local success, result = pcall(function()
|
|
3331
|
-
local fn, compileError = loadstring(
|
|
3327
|
+
local fn, compileError = loadstring(wrapped)
|
|
3332
3328
|
if not fn then
|
|
3333
3329
|
if isLoadstringUnavailable(compileError) then
|
|
3334
3330
|
return runViaModuleScript()
|
|
@@ -3338,27 +3334,39 @@ local function executeLuau(requestData)
|
|
|
3338
3334
|
return fn()
|
|
3339
3335
|
end)
|
|
3340
3336
|
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3341
|
-
-- LoadStringEnabled=false. Catch that
|
|
3337
|
+
-- LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
3342
3338
|
if not success and isLoadstringUnavailable(result) then
|
|
3343
3339
|
success, result = pcall(runViaModuleScript)
|
|
3344
3340
|
end
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
success = true,
|
|
3350
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
3351
|
-
output = output,
|
|
3352
|
-
message = "Code executed successfully",
|
|
3353
|
-
}
|
|
3354
|
-
else
|
|
3341
|
+
if not success then
|
|
3342
|
+
-- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
3343
|
+
-- error in the user code, or ModuleScript setup error). 'result' is the
|
|
3344
|
+
-- raw error string from pcall.
|
|
3355
3345
|
return {
|
|
3356
3346
|
success = false,
|
|
3357
3347
|
error = tostring(result),
|
|
3358
|
-
output =
|
|
3348
|
+
output = {},
|
|
3359
3349
|
message = "Code execution failed",
|
|
3360
3350
|
}
|
|
3361
3351
|
end
|
|
3352
|
+
-- Wrapper executed - unpack { ok, value, output }.
|
|
3353
|
+
local r = result
|
|
3354
|
+
local capturedOutput = r.output
|
|
3355
|
+
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
3356
|
+
if r.ok == true then
|
|
3357
|
+
return {
|
|
3358
|
+
success = true,
|
|
3359
|
+
returnValue = if r.value ~= nil then tostring(r.value) else nil,
|
|
3360
|
+
output = output,
|
|
3361
|
+
message = "Code executed successfully",
|
|
3362
|
+
}
|
|
3363
|
+
end
|
|
3364
|
+
return {
|
|
3365
|
+
success = false,
|
|
3366
|
+
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
3367
|
+
output = output,
|
|
3368
|
+
message = "Code execution failed",
|
|
3369
|
+
}
|
|
3362
3370
|
end
|
|
3363
3371
|
local function undo(_requestData)
|
|
3364
3372
|
local success, result = pcall(function()
|
|
@@ -5643,6 +5651,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5643
5651
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5644
5652
|
local HttpService = _services.HttpService
|
|
5645
5653
|
local LogService = _services.LogService
|
|
5654
|
+
local RunService = _services.RunService
|
|
5646
5655
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5647
5656
|
local installBridges = _EvalBridges.installBridges
|
|
5648
5657
|
local cleanupBridges = _EvalBridges.cleanupBridges
|
|
@@ -5759,6 +5768,19 @@ local function startPlaytest(requestData)
|
|
|
5759
5768
|
error = 'mode must be "play" or "run"',
|
|
5760
5769
|
}
|
|
5761
5770
|
end
|
|
5771
|
+
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5772
|
+
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5773
|
+
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
5774
|
+
-- Reset it so subsequent starts don't hit a false "already running".
|
|
5775
|
+
if testRunning and not RunService:IsRunning() then
|
|
5776
|
+
testRunning = false
|
|
5777
|
+
if logConnection then
|
|
5778
|
+
logConnection:Disconnect()
|
|
5779
|
+
logConnection = nil
|
|
5780
|
+
end
|
|
5781
|
+
cleanupStopListener()
|
|
5782
|
+
cleanupBridges()
|
|
5783
|
+
end
|
|
5762
5784
|
if testRunning then
|
|
5763
5785
|
return {
|
|
5764
5786
|
error = "A test is already running",
|
|
@@ -5856,17 +5878,35 @@ local function stopPlaytest(_requestData)
|
|
|
5856
5878
|
error = "Plugin not ready. Try again in a moment.",
|
|
5857
5879
|
}
|
|
5858
5880
|
end
|
|
5859
|
-
if StopPlayMonitor.waitForConsumption() then
|
|
5881
|
+
if not StopPlayMonitor.waitForConsumption() then
|
|
5882
|
+
-- Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
5883
|
+
-- EndTest on its own startup against a stale signal.
|
|
5884
|
+
StopPlayMonitor.clearPending()
|
|
5885
|
+
return {
|
|
5886
|
+
error = "No active playtest to stop.",
|
|
5887
|
+
}
|
|
5888
|
+
end
|
|
5889
|
+
-- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
5890
|
+
-- startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
5891
|
+
-- true until that yield completes and the post-block runs. Wait so
|
|
5892
|
+
-- back-to-back stop -> start sequences don't race against the prior
|
|
5893
|
+
-- teardown and get "A test is already running". 10s covers play-DM
|
|
5894
|
+
-- teardown on heavier places; if it still hasn't cleared we return
|
|
5895
|
+
-- anyway so users aren't stuck — but note that in the response so the
|
|
5896
|
+
-- caller knows a subsequent start may need a moment.
|
|
5897
|
+
local deadline = tick() + 10
|
|
5898
|
+
while testRunning and tick() < deadline do
|
|
5899
|
+
task.wait(0.1)
|
|
5900
|
+
end
|
|
5901
|
+
if testRunning then
|
|
5860
5902
|
return {
|
|
5861
5903
|
success = true,
|
|
5862
|
-
message = "Playtest
|
|
5904
|
+
message = "Playtest stop signal sent; teardown still in progress.",
|
|
5863
5905
|
}
|
|
5864
5906
|
end
|
|
5865
|
-
-- Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
5866
|
-
-- EndTest on its own startup against a stale signal.
|
|
5867
|
-
StopPlayMonitor.clearPending()
|
|
5868
5907
|
return {
|
|
5869
|
-
|
|
5908
|
+
success = true,
|
|
5909
|
+
message = "Playtest stopped.",
|
|
5870
5910
|
}
|
|
5871
5911
|
end
|
|
5872
5912
|
local function getPlaytestOutput(_requestData)
|
|
@@ -6175,7 +6215,7 @@ return {
|
|
|
6175
6215
|
<Properties>
|
|
6176
6216
|
<string name="Name">State</string>
|
|
6177
6217
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6178
|
-
local CURRENT_VERSION = "2.11.
|
|
6218
|
+
local CURRENT_VERSION = "2.11.3"
|
|
6179
6219
|
local MAX_CONNECTIONS = 5
|
|
6180
6220
|
local BASE_PORT = 58741
|
|
6181
6221
|
local activeTabIndex = 0
|
|
@@ -3255,56 +3255,52 @@ local function executeLuau(requestData)
|
|
|
3255
3255
|
error = "Code is required",
|
|
3256
3256
|
}
|
|
3257
3257
|
end
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3258
|
+
-- Both execution paths (loadstring + ModuleScript-require fallback) run
|
|
3259
|
+
-- the SAME wrapped source so they return a uniform { ok, value, output }
|
|
3260
|
+
-- shape. Two problems the wrapper solves at once:
|
|
3261
|
+
--
|
|
3262
|
+
-- 1. pcall(require, m) swallows the real error and returns Roblox's
|
|
3263
|
+
-- generic "Requested module experienced an error while loading"
|
|
3264
|
+
-- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
3265
|
+
-- ModuleScript itself returning successfully — the real error +
|
|
3266
|
+
-- traceback live in the returned table.
|
|
3267
|
+
--
|
|
3268
|
+
-- 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
3269
|
+
-- side getfenv print/warn override never reached user prints. A
|
|
3270
|
+
-- lexical local print/warn inside the IIFE captures user prints
|
|
3271
|
+
-- regardless of which path executes. We also call the real global
|
|
3272
|
+
-- print/warn so messages still flow to Studio's output and
|
|
3273
|
+
-- LogService.MessageOut (which powers get_runtime_logs).
|
|
3274
|
+
--
|
|
3275
|
+
-- Prints from required sub-modules don't reach this capture (they have
|
|
3276
|
+
-- their own env) — those go through the runtime log buffer.
|
|
3277
|
+
local wrapped = `return ((function()\
|
|
3278
|
+
\tlocal __mcp_output = \{\}\
|
|
3279
|
+
\tlocal __mcp_real_print = print\
|
|
3280
|
+
\tlocal __mcp_real_warn = warn\
|
|
3281
|
+
\tlocal print = function(...)\
|
|
3282
|
+
\t\t__mcp_real_print(...)\
|
|
3283
|
+
\t\tlocal args = \{...\}\
|
|
3284
|
+
\t\tlocal parts = table.create(#args)\
|
|
3285
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3286
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
3287
|
+
\tend\
|
|
3288
|
+
\tlocal warn = function(...)\
|
|
3289
|
+
\t\t__mcp_real_warn(...)\
|
|
3290
|
+
\t\tlocal args = \{...\}\
|
|
3291
|
+
\t\tlocal parts = table.create(#args)\
|
|
3292
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3293
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
3294
|
+
\tend\
|
|
3295
|
+
\tlocal function __mcp_run()\
|
|
3296
|
+
{code}\
|
|
3297
|
+
\tend\
|
|
3298
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
|
|
3299
|
+
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
3300
|
+
end)())`
|
|
3294
3301
|
local runViaModuleScript = function()
|
|
3295
3302
|
local m = Instance.new("ModuleScript")
|
|
3296
3303
|
m.Name = "__MCPExecLuauPayload"
|
|
3297
|
-
-- Wrap user code in an IIFE so require() always gets exactly one
|
|
3298
|
-
-- return value. Without this, code like `print("x")` errors with
|
|
3299
|
-
-- "Module code did not return exactly one value" because top-level
|
|
3300
|
-
-- ModuleScripts must return exactly one value.
|
|
3301
|
-
--
|
|
3302
|
-
-- The DOUBLE parens around the call are load-bearing: in Luau,
|
|
3303
|
-
-- `return f()` propagates whatever multi-value tuple f returns,
|
|
3304
|
-
-- including zero values. Outer parens adjust the call to exactly
|
|
3305
|
-
-- one value (the first, or nil). So `return ((f)())` always
|
|
3306
|
-
-- returns exactly one value, regardless of what f does.
|
|
3307
|
-
local wrapped = `return ((function()\n{code}\nend)())`
|
|
3308
3304
|
local okSet, setErr = pcall(function()
|
|
3309
3305
|
m.Source = wrapped
|
|
3310
3306
|
end)
|
|
@@ -3328,7 +3324,7 @@ local function executeLuau(requestData)
|
|
|
3328
3324
|
return matchStart ~= nil
|
|
3329
3325
|
end
|
|
3330
3326
|
local success, result = pcall(function()
|
|
3331
|
-
local fn, compileError = loadstring(
|
|
3327
|
+
local fn, compileError = loadstring(wrapped)
|
|
3332
3328
|
if not fn then
|
|
3333
3329
|
if isLoadstringUnavailable(compileError) then
|
|
3334
3330
|
return runViaModuleScript()
|
|
@@ -3338,27 +3334,39 @@ local function executeLuau(requestData)
|
|
|
3338
3334
|
return fn()
|
|
3339
3335
|
end)
|
|
3340
3336
|
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3341
|
-
-- LoadStringEnabled=false. Catch that
|
|
3337
|
+
-- LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
3342
3338
|
if not success and isLoadstringUnavailable(result) then
|
|
3343
3339
|
success, result = pcall(runViaModuleScript)
|
|
3344
3340
|
end
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
success = true,
|
|
3350
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
3351
|
-
output = output,
|
|
3352
|
-
message = "Code executed successfully",
|
|
3353
|
-
}
|
|
3354
|
-
else
|
|
3341
|
+
if not success then
|
|
3342
|
+
-- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
3343
|
+
-- error in the user code, or ModuleScript setup error). 'result' is the
|
|
3344
|
+
-- raw error string from pcall.
|
|
3355
3345
|
return {
|
|
3356
3346
|
success = false,
|
|
3357
3347
|
error = tostring(result),
|
|
3358
|
-
output =
|
|
3348
|
+
output = {},
|
|
3359
3349
|
message = "Code execution failed",
|
|
3360
3350
|
}
|
|
3361
3351
|
end
|
|
3352
|
+
-- Wrapper executed - unpack { ok, value, output }.
|
|
3353
|
+
local r = result
|
|
3354
|
+
local capturedOutput = r.output
|
|
3355
|
+
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
3356
|
+
if r.ok == true then
|
|
3357
|
+
return {
|
|
3358
|
+
success = true,
|
|
3359
|
+
returnValue = if r.value ~= nil then tostring(r.value) else nil,
|
|
3360
|
+
output = output,
|
|
3361
|
+
message = "Code executed successfully",
|
|
3362
|
+
}
|
|
3363
|
+
end
|
|
3364
|
+
return {
|
|
3365
|
+
success = false,
|
|
3366
|
+
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
3367
|
+
output = output,
|
|
3368
|
+
message = "Code execution failed",
|
|
3369
|
+
}
|
|
3362
3370
|
end
|
|
3363
3371
|
local function undo(_requestData)
|
|
3364
3372
|
local success, result = pcall(function()
|
|
@@ -5643,6 +5651,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5643
5651
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5644
5652
|
local HttpService = _services.HttpService
|
|
5645
5653
|
local LogService = _services.LogService
|
|
5654
|
+
local RunService = _services.RunService
|
|
5646
5655
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5647
5656
|
local installBridges = _EvalBridges.installBridges
|
|
5648
5657
|
local cleanupBridges = _EvalBridges.cleanupBridges
|
|
@@ -5759,6 +5768,19 @@ local function startPlaytest(requestData)
|
|
|
5759
5768
|
error = 'mode must be "play" or "run"',
|
|
5760
5769
|
}
|
|
5761
5770
|
end
|
|
5771
|
+
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5772
|
+
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5773
|
+
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
5774
|
+
-- Reset it so subsequent starts don't hit a false "already running".
|
|
5775
|
+
if testRunning and not RunService:IsRunning() then
|
|
5776
|
+
testRunning = false
|
|
5777
|
+
if logConnection then
|
|
5778
|
+
logConnection:Disconnect()
|
|
5779
|
+
logConnection = nil
|
|
5780
|
+
end
|
|
5781
|
+
cleanupStopListener()
|
|
5782
|
+
cleanupBridges()
|
|
5783
|
+
end
|
|
5762
5784
|
if testRunning then
|
|
5763
5785
|
return {
|
|
5764
5786
|
error = "A test is already running",
|
|
@@ -5856,17 +5878,35 @@ local function stopPlaytest(_requestData)
|
|
|
5856
5878
|
error = "Plugin not ready. Try again in a moment.",
|
|
5857
5879
|
}
|
|
5858
5880
|
end
|
|
5859
|
-
if StopPlayMonitor.waitForConsumption() then
|
|
5881
|
+
if not StopPlayMonitor.waitForConsumption() then
|
|
5882
|
+
-- Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
5883
|
+
-- EndTest on its own startup against a stale signal.
|
|
5884
|
+
StopPlayMonitor.clearPending()
|
|
5885
|
+
return {
|
|
5886
|
+
error = "No active playtest to stop.",
|
|
5887
|
+
}
|
|
5888
|
+
end
|
|
5889
|
+
-- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
5890
|
+
-- startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
5891
|
+
-- true until that yield completes and the post-block runs. Wait so
|
|
5892
|
+
-- back-to-back stop -> start sequences don't race against the prior
|
|
5893
|
+
-- teardown and get "A test is already running". 10s covers play-DM
|
|
5894
|
+
-- teardown on heavier places; if it still hasn't cleared we return
|
|
5895
|
+
-- anyway so users aren't stuck — but note that in the response so the
|
|
5896
|
+
-- caller knows a subsequent start may need a moment.
|
|
5897
|
+
local deadline = tick() + 10
|
|
5898
|
+
while testRunning and tick() < deadline do
|
|
5899
|
+
task.wait(0.1)
|
|
5900
|
+
end
|
|
5901
|
+
if testRunning then
|
|
5860
5902
|
return {
|
|
5861
5903
|
success = true,
|
|
5862
|
-
message = "Playtest
|
|
5904
|
+
message = "Playtest stop signal sent; teardown still in progress.",
|
|
5863
5905
|
}
|
|
5864
5906
|
end
|
|
5865
|
-
-- Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
5866
|
-
-- EndTest on its own startup against a stale signal.
|
|
5867
|
-
StopPlayMonitor.clearPending()
|
|
5868
5907
|
return {
|
|
5869
|
-
|
|
5908
|
+
success = true,
|
|
5909
|
+
message = "Playtest stopped.",
|
|
5870
5910
|
}
|
|
5871
5911
|
end
|
|
5872
5912
|
local function getPlaytestOutput(_requestData)
|
|
@@ -6175,7 +6215,7 @@ return {
|
|
|
6175
6215
|
<Properties>
|
|
6176
6216
|
<string name="Name">State</string>
|
|
6177
6217
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6178
|
-
local CURRENT_VERSION = "2.11.
|
|
6218
|
+
local CURRENT_VERSION = "2.11.3"
|
|
6179
6219
|
local MAX_CONNECTIONS = 5
|
|
6180
6220
|
local BASE_PORT = 58741
|
|
6181
6221
|
local activeTabIndex = 0
|
|
@@ -263,48 +263,59 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
263
263
|
const code = requestData.code as string;
|
|
264
264
|
if (!code || code === "") return { error: "Code is required" };
|
|
265
265
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
266
|
+
// Both execution paths (loadstring + ModuleScript-require fallback) run
|
|
267
|
+
// the SAME wrapped source so they return a uniform { ok, value, output }
|
|
268
|
+
// shape. Two problems the wrapper solves at once:
|
|
269
|
+
//
|
|
270
|
+
// 1. pcall(require, m) swallows the real error and returns Roblox's
|
|
271
|
+
// generic "Requested module experienced an error while loading"
|
|
272
|
+
// message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
273
|
+
// ModuleScript itself returning successfully — the real error +
|
|
274
|
+
// traceback live in the returned table.
|
|
275
|
+
//
|
|
276
|
+
// 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
277
|
+
// side getfenv print/warn override never reached user prints. A
|
|
278
|
+
// lexical local print/warn inside the IIFE captures user prints
|
|
279
|
+
// regardless of which path executes. We also call the real global
|
|
280
|
+
// print/warn so messages still flow to Studio's output and
|
|
281
|
+
// LogService.MessageOut (which powers get_runtime_logs).
|
|
282
|
+
//
|
|
283
|
+
// Prints from required sub-modules don't reach this capture (they have
|
|
284
|
+
// their own env) — those go through the runtime log buffer.
|
|
285
|
+
const wrapped = `return ((function()
|
|
286
|
+
\tlocal __mcp_output = {}
|
|
287
|
+
\tlocal __mcp_real_print = print
|
|
288
|
+
\tlocal __mcp_real_warn = warn
|
|
289
|
+
\tlocal print = function(...)
|
|
290
|
+
\t\t__mcp_real_print(...)
|
|
291
|
+
\t\tlocal args = {...}
|
|
292
|
+
\t\tlocal parts = table.create(#args)
|
|
293
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
294
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
|
|
295
|
+
\tend
|
|
296
|
+
\tlocal warn = function(...)
|
|
297
|
+
\t\t__mcp_real_warn(...)
|
|
298
|
+
\t\tlocal args = {...}
|
|
299
|
+
\t\tlocal parts = table.create(#args)
|
|
300
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
301
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
|
|
302
|
+
\tend
|
|
303
|
+
\tlocal function __mcp_run()
|
|
304
|
+
${code}
|
|
305
|
+
\tend
|
|
306
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)
|
|
307
|
+
\treturn { ok = ok, value = errOrValue, output = __mcp_output }
|
|
308
|
+
end)())`;
|
|
309
|
+
|
|
310
|
+
interface WrapperResult {
|
|
311
|
+
ok?: boolean;
|
|
312
|
+
value?: unknown;
|
|
313
|
+
output?: defined;
|
|
314
|
+
}
|
|
283
315
|
|
|
284
|
-
|
|
285
|
-
// ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
|
|
286
|
-
// peer where the engine respects that gate (notably the play-server DM
|
|
287
|
-
// in some Studio configurations), loadstring either returns nil with a
|
|
288
|
-
// "loadstring() is not available" message OR throws that same message
|
|
289
|
-
// directly. Both paths must trigger the ModuleScript + require
|
|
290
|
-
// fallback. The fallback can't intercept print/warn since the
|
|
291
|
-
// ModuleScript runs in its own environment, so the output array stays
|
|
292
|
-
// empty in that branch - the playtest log buffer already captures
|
|
293
|
-
// prints separately via LogService.MessageOut.
|
|
294
|
-
const runViaModuleScript = () => {
|
|
316
|
+
const runViaModuleScript = (): WrapperResult => {
|
|
295
317
|
const m = new Instance("ModuleScript");
|
|
296
318
|
m.Name = "__MCPExecLuauPayload";
|
|
297
|
-
// Wrap user code in an IIFE so require() always gets exactly one
|
|
298
|
-
// return value. Without this, code like `print("x")` errors with
|
|
299
|
-
// "Module code did not return exactly one value" because top-level
|
|
300
|
-
// ModuleScripts must return exactly one value.
|
|
301
|
-
//
|
|
302
|
-
// The DOUBLE parens around the call are load-bearing: in Luau,
|
|
303
|
-
// `return f()` propagates whatever multi-value tuple f returns,
|
|
304
|
-
// including zero values. Outer parens adjust the call to exactly
|
|
305
|
-
// one value (the first, or nil). So `return ((f)())` always
|
|
306
|
-
// returns exactly one value, regardless of what f does.
|
|
307
|
-
const wrapped = `return ((function()\n${code}\nend)())`;
|
|
308
319
|
const [okSet, setErr] = pcall(() => {
|
|
309
320
|
(m as unknown as { Source: string }).Source = wrapped;
|
|
310
321
|
});
|
|
@@ -316,7 +327,7 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
316
327
|
const [okReq, reqResult] = pcall(() => require(m));
|
|
317
328
|
m.Destroy();
|
|
318
329
|
if (!okReq) error(tostring(reqResult));
|
|
319
|
-
return reqResult;
|
|
330
|
+
return reqResult as unknown as WrapperResult;
|
|
320
331
|
};
|
|
321
332
|
|
|
322
333
|
const isLoadstringUnavailable = (err: unknown): boolean => {
|
|
@@ -326,40 +337,52 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
326
337
|
};
|
|
327
338
|
|
|
328
339
|
let [success, result] = pcall(() => {
|
|
329
|
-
const [fn, compileError] = loadstring(
|
|
340
|
+
const [fn, compileError] = loadstring(wrapped);
|
|
330
341
|
if (!fn) {
|
|
331
342
|
if (isLoadstringUnavailable(compileError)) {
|
|
332
343
|
return runViaModuleScript();
|
|
333
344
|
}
|
|
334
345
|
error(`Compile error: ${compileError}`);
|
|
335
346
|
}
|
|
336
|
-
return fn();
|
|
347
|
+
return fn() as unknown as WrapperResult;
|
|
337
348
|
});
|
|
338
349
|
|
|
339
350
|
// loadstring throws (not returns nil) in some plugin contexts when
|
|
340
|
-
// LoadStringEnabled=false. Catch that
|
|
351
|
+
// LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
341
352
|
if (!success && isLoadstringUnavailable(result)) {
|
|
342
353
|
[success, result] = pcall(runViaModuleScript);
|
|
343
354
|
}
|
|
344
355
|
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
if (!success) {
|
|
357
|
+
// Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
358
|
+
// error in the user code, or ModuleScript setup error). 'result' is the
|
|
359
|
+
// raw error string from pcall.
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: tostring(result),
|
|
363
|
+
output: [],
|
|
364
|
+
message: "Code execution failed",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
347
367
|
|
|
348
|
-
|
|
368
|
+
// Wrapper executed - unpack { ok, value, output }.
|
|
369
|
+
const r = result as unknown as WrapperResult;
|
|
370
|
+
const capturedOutput = r.output as unknown as string[] | undefined;
|
|
371
|
+
const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
|
|
372
|
+
if (r.ok === true) {
|
|
349
373
|
return {
|
|
350
374
|
success: true,
|
|
351
|
-
returnValue:
|
|
375
|
+
returnValue: r.value !== undefined ? tostring(r.value) : undefined,
|
|
352
376
|
output,
|
|
353
377
|
message: "Code executed successfully",
|
|
354
378
|
};
|
|
355
|
-
} else {
|
|
356
|
-
return {
|
|
357
|
-
success: false,
|
|
358
|
-
error: tostring(result),
|
|
359
|
-
output,
|
|
360
|
-
message: "Code execution failed",
|
|
361
|
-
};
|
|
362
379
|
}
|
|
380
|
+
return {
|
|
381
|
+
success: false,
|
|
382
|
+
error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
|
|
383
|
+
output,
|
|
384
|
+
message: "Code execution failed",
|
|
385
|
+
};
|
|
363
386
|
}
|
|
364
387
|
|
|
365
388
|
function undo(_requestData: Record<string, unknown>) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HttpService, LogService } from "@rbxts/services";
|
|
1
|
+
import { HttpService, LogService, RunService } from "@rbxts/services";
|
|
2
2
|
import { installBridges, cleanupBridges } from "../EvalBridges";
|
|
3
3
|
import StopPlayMonitor from "../StopPlayMonitor";
|
|
4
4
|
|
|
@@ -124,6 +124,20 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
124
124
|
return { error: 'mode must be "play" or "run"' };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Self-heal: if testRunning is stuck true but Studio reports no active
|
|
128
|
+
// playtest, the previous start_playtest's task.spawn was orphaned
|
|
129
|
+
// (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
130
|
+
// Reset it so subsequent starts don't hit a false "already running".
|
|
131
|
+
if (testRunning && !RunService.IsRunning()) {
|
|
132
|
+
testRunning = false;
|
|
133
|
+
if (logConnection) {
|
|
134
|
+
logConnection.Disconnect();
|
|
135
|
+
logConnection = undefined;
|
|
136
|
+
}
|
|
137
|
+
cleanupStopListener();
|
|
138
|
+
cleanupBridges();
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
if (testRunning) {
|
|
128
142
|
return { error: "A test is already running" };
|
|
129
143
|
}
|
|
@@ -220,13 +234,31 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
220
234
|
if (!StopPlayMonitor.requestStop()) {
|
|
221
235
|
return { error: "Plugin not ready. Try again in a moment." };
|
|
222
236
|
}
|
|
223
|
-
if (StopPlayMonitor.waitForConsumption()) {
|
|
224
|
-
|
|
237
|
+
if (!StopPlayMonitor.waitForConsumption()) {
|
|
238
|
+
// Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
239
|
+
// EndTest on its own startup against a stale signal.
|
|
240
|
+
StopPlayMonitor.clearPending();
|
|
241
|
+
return { error: "No active playtest to stop." };
|
|
242
|
+
}
|
|
243
|
+
// Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
244
|
+
// startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
245
|
+
// true until that yield completes and the post-block runs. Wait so
|
|
246
|
+
// back-to-back stop -> start sequences don't race against the prior
|
|
247
|
+
// teardown and get "A test is already running". 10s covers play-DM
|
|
248
|
+
// teardown on heavier places; if it still hasn't cleared we return
|
|
249
|
+
// anyway so users aren't stuck — but note that in the response so the
|
|
250
|
+
// caller knows a subsequent start may need a moment.
|
|
251
|
+
const deadline = tick() + 10;
|
|
252
|
+
while (testRunning && tick() < deadline) {
|
|
253
|
+
task.wait(0.1);
|
|
254
|
+
}
|
|
255
|
+
if (testRunning) {
|
|
256
|
+
return {
|
|
257
|
+
success: true,
|
|
258
|
+
message: "Playtest stop signal sent; teardown still in progress.",
|
|
259
|
+
};
|
|
225
260
|
}
|
|
226
|
-
|
|
227
|
-
// EndTest on its own startup against a stale signal.
|
|
228
|
-
StopPlayMonitor.clearPending();
|
|
229
|
-
return { error: "No active playtest to stop." };
|
|
261
|
+
return { success: true, message: "Playtest stopped." };
|
|
230
262
|
}
|
|
231
263
|
|
|
232
264
|
function getPlaytestOutput(_requestData: Record<string, unknown>) {
|