Skip to content

Commit 3e882a5

Browse files
Kevin SwintKevin Swint
authored andcommitted
feat: add play/stop controls for Studio simulation
Adds MCP tools to control Studio's playtest and simulation modes, enabling AI assistants to test games programmatically. New tools: - get_studio_state: Returns current mode (edit/playtest/simulation) - start_playtest: Starts playtest mode (F5) with player character - start_simulation: Starts simulation mode (F8) without player - stop_simulation: Stops and returns to edit mode - stop_playtest: Alias for stop_simulation Features: - Uses StudioTestService for reliable playtest start - Automatic retry logic for pending operation errors - State verification with configurable timeout - Clear error messages with recovery suggestions Plugin changes: - New GetStudioState.luau tool - New StartPlaytest.luau tool - New Simulation.luau tool (handles start/stop simulation) - Updated Types.luau with new argument types Note: stop_playtest only works for simulation mode (F8) due to Roblox Studio's DataModel isolation during playtest.
1 parent 93f21f5 commit 3e882a5

File tree

5 files changed

+412
-1
lines changed

5 files changed

+412
-1
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
local Main = script:FindFirstAncestor("MCPStudioPlugin")
2+
local Types = require(Main.Types)
3+
4+
local HttpService = game:GetService("HttpService")
5+
local RunService = game:GetService("RunService")
6+
7+
local function getStudioState(): string
8+
local isEdit = RunService:IsEdit()
9+
local isRunning = RunService:IsRunning()
10+
local isRunMode = RunService:IsRunMode()
11+
12+
local mode = "edit"
13+
if isRunning then
14+
if isRunMode then
15+
-- Run mode (F8) - physics simulation without player character
16+
mode = "simulation"
17+
else
18+
-- Play mode (F5) - playtest with player character
19+
mode = "playtest"
20+
end
21+
end
22+
23+
local state = {
24+
mode = mode,
25+
isEdit = isEdit,
26+
isRunning = isRunning,
27+
isRunMode = isRunMode,
28+
isPlaytest = isRunning and not isRunMode,
29+
canModify = isEdit and not isRunning,
30+
}
31+
32+
return HttpService:JSONEncode(state)
33+
end
34+
35+
local function handleGetStudioState(args: Types.ToolArgs): string?
36+
if not args["GetStudioState"] then
37+
return nil
38+
end
39+
40+
return getStudioState()
41+
end
42+
43+
return handleGetStudioState :: Types.ToolFunction

plugin/src/Tools/Simulation.luau

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
local Main = script:FindFirstAncestor("MCPStudioPlugin")
2+
local Types = require(Main.Types)
3+
4+
local HttpService = game:GetService("HttpService")
5+
local RunService = game:GetService("RunService")
6+
7+
-- Configuration
8+
local VERIFICATION_TIMEOUT = 10 -- seconds to wait for state change
9+
local POLL_INTERVAL = 0.1 -- seconds between state checks
10+
11+
-- Wait for RunService state to change with timeout
12+
local function waitForState(targetRunning: boolean, timeout: number): (boolean, string?)
13+
local startTime = os.clock()
14+
15+
while os.clock() - startTime < timeout do
16+
local isRunning = RunService:IsRunning()
17+
if isRunning == targetRunning then
18+
return true, nil
19+
end
20+
task.wait(POLL_INTERVAL)
21+
end
22+
23+
return false, string.format(
24+
"Timeout after %.1fs waiting for state change (expected IsRunning=%s, got %s)",
25+
timeout, tostring(targetRunning), tostring(RunService:IsRunning())
26+
)
27+
end
28+
29+
local function startSimulation(): string
30+
-- Check if already in playtest/simulation mode
31+
if RunService:IsRunning() then
32+
return HttpService:JSONEncode({
33+
success = false,
34+
error = "Studio is already running. Call stop_simulation first.",
35+
currentMode = RunService:IsRunMode() and "simulation" or "playtest",
36+
})
37+
end
38+
39+
local success, err = pcall(function()
40+
RunService:Run()
41+
end)
42+
43+
if not success then
44+
return HttpService:JSONEncode({
45+
success = false,
46+
error = "RunService:Run() failed: " .. tostring(err),
47+
})
48+
end
49+
50+
-- Verify simulation started
51+
local verified, verifyErr = waitForState(true, VERIFICATION_TIMEOUT)
52+
53+
if verified then
54+
return HttpService:JSONEncode({
55+
success = true,
56+
verified = true,
57+
mode = "simulation",
58+
note = "Started simulation mode (F8). Physics running without player.",
59+
})
60+
else
61+
return HttpService:JSONEncode({
62+
success = false,
63+
error = "Simulation call succeeded but verification failed: " .. tostring(verifyErr),
64+
})
65+
end
66+
end
67+
68+
local function stopSimulation(): string
69+
-- Note: We cannot reliably check IsRunning() because it returns false from plugin
70+
-- context during playtest due to DataModel isolation. We'll try to stop anyway.
71+
72+
local wasRunning = RunService:IsRunning()
73+
local previousMode = RunService:IsRunMode() and "simulation" or "playtest"
74+
75+
local success, err = pcall(function()
76+
RunService:Stop()
77+
end)
78+
79+
if not success then
80+
return HttpService:JSONEncode({
81+
success = false,
82+
error = "RunService:Stop() failed: " .. tostring(err),
83+
})
84+
end
85+
86+
-- For simulation mode, verification works. For playtest, IsRunning() was already false.
87+
if wasRunning then
88+
-- We were in simulation mode - verify it stopped
89+
local verified, verifyErr = waitForState(false, VERIFICATION_TIMEOUT)
90+
if verified then
91+
return HttpService:JSONEncode({
92+
success = true,
93+
verified = true,
94+
previousMode = previousMode,
95+
mode = "edit",
96+
note = "Stopped " .. previousMode .. ". Returned to edit mode.",
97+
})
98+
else
99+
return HttpService:JSONEncode({
100+
success = false,
101+
error = "Stop call succeeded but verification failed: " .. tostring(verifyErr),
102+
previousMode = previousMode,
103+
})
104+
end
105+
else
106+
-- IsRunning() was false - we're either in edit mode OR playtest mode
107+
-- (can't tell from plugin context due to DataModel isolation)
108+
-- RunService:Stop() only works for simulation mode, NOT playtest mode.
109+
-- For playtest, user must manually stop OR use run_server_code with EndTest()
110+
return HttpService:JSONEncode({
111+
success = true,
112+
mode = "edit",
113+
note = "RunService:Stop() called. Note: This only stops SIMULATION mode. For PLAYTEST mode, use run_server_code to call StudioTestService:EndTest(), or manually press Stop (F6).",
114+
})
115+
end
116+
end
117+
118+
local function handleSimulation(args: Types.ToolArgs): string?
119+
if args["StartSimulation"] then
120+
return startSimulation()
121+
elseif args["StopSimulation"] or args["StopPlaytest"] then
122+
return stopSimulation()
123+
end
124+
return nil
125+
end
126+
127+
return handleSimulation :: Types.ToolFunction
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
local Main = script:FindFirstAncestor("MCPStudioPlugin")
2+
local Types = require(Main.Types)
3+
4+
local HttpService = game:GetService("HttpService")
5+
local RunService = game:GetService("RunService")
6+
7+
-- Try to get StudioTestService (may not exist in older Studio versions)
8+
local StudioTestService = nil
9+
pcall(function()
10+
StudioTestService = game:GetService("StudioTestService")
11+
end)
12+
13+
-- Configuration
14+
local VERIFICATION_TIMEOUT = 10 -- seconds to wait for state change
15+
local POLL_INTERVAL = 0.1 -- seconds between state checks
16+
local MAX_RETRIES = 10 -- number of retries for pending operation error
17+
local RETRY_DELAY = 0.5 -- seconds between retries
18+
local ERROR_CHECK_DELAY = 0.3 -- seconds to wait for immediate errors
19+
20+
-- Wait for RunService state to change with timeout
21+
local function waitForState(targetRunning: boolean, timeout: number): (boolean, string?)
22+
local startTime = os.clock()
23+
24+
while os.clock() - startTime < timeout do
25+
local isRunning = RunService:IsRunning()
26+
if isRunning == targetRunning then
27+
return true, nil
28+
end
29+
task.wait(POLL_INTERVAL)
30+
end
31+
32+
return false, string.format(
33+
"Timeout after %.1fs waiting for state change (expected IsRunning=%s, got %s)",
34+
timeout, tostring(targetRunning), tostring(RunService:IsRunning())
35+
)
36+
end
37+
38+
local function startPlaytest(): string
39+
-- Note: We cannot reliably check if already running because RunService:IsRunning()
40+
-- returns false from plugin context during playtest due to DataModel isolation.
41+
-- StudioTestService will return an error if playtest is already running.
42+
43+
-- Try StudioTestService first (starts with player character)
44+
if StudioTestService then
45+
local lastError = nil
46+
47+
for attempt = 1, MAX_RETRIES do
48+
-- Use task.spawn because ExecutePlayModeAsync blocks until playtest starts.
49+
-- We need to return the HTTP response reasonably quickly.
50+
local startError = nil
51+
local completed = false
52+
53+
task.spawn(function()
54+
local success, err = pcall(function()
55+
StudioTestService:ExecutePlayModeAsync({})
56+
end)
57+
completed = true
58+
if not success then
59+
startError = tostring(err)
60+
end
61+
end)
62+
63+
-- Wait a short time for immediate errors (like "pending" or "already running")
64+
task.wait(ERROR_CHECK_DELAY)
65+
66+
if completed and startError then
67+
-- Got an immediate error
68+
lastError = startError
69+
70+
-- Check if it's the "pending operation" error - if so, retry after delay
71+
if string.find(startError, "Previous call to start play session has not been completed") then
72+
if attempt < MAX_RETRIES then
73+
warn(string.format("[MCP] Playtest pending, retry %d/%d in %.1fs...", attempt, MAX_RETRIES, RETRY_DELAY))
74+
task.wait(RETRY_DELAY)
75+
continue
76+
end
77+
else
78+
-- Different error, don't retry
79+
break
80+
end
81+
elseif not completed then
82+
-- No immediate error and still running = playtest is starting successfully
83+
return HttpService:JSONEncode({
84+
success = true,
85+
mode = "playtest",
86+
hasPlayer = true,
87+
note = "Started playtest mode (F5) via StudioTestService.",
88+
attempts = attempt,
89+
})
90+
else
91+
-- Completed with no error = success
92+
return HttpService:JSONEncode({
93+
success = true,
94+
mode = "playtest",
95+
hasPlayer = true,
96+
note = "Started playtest mode (F5) via StudioTestService.",
97+
attempts = attempt,
98+
})
99+
end
100+
end
101+
102+
return HttpService:JSONEncode({
103+
success = false,
104+
error = "ExecutePlayModeAsync failed: " .. (lastError or "unknown error"),
105+
suggestion = "Press F6 to manually stop any pending playtest, then try again.",
106+
})
107+
end
108+
109+
-- Fallback: StudioTestService not available, use RunService:Run() (simulation mode)
110+
local success, err = pcall(function()
111+
RunService:Run()
112+
end)
113+
114+
if not success then
115+
return HttpService:JSONEncode({
116+
success = false,
117+
error = "RunService:Run() failed: " .. tostring(err),
118+
})
119+
end
120+
121+
-- For simulation mode, RunService:IsRunning() DOES work from plugin context
122+
-- because simulation runs in the same DataModel
123+
local verified, verifyErr = waitForState(true, VERIFICATION_TIMEOUT)
124+
125+
if verified then
126+
return HttpService:JSONEncode({
127+
success = true,
128+
verified = true,
129+
mode = "simulation",
130+
note = "Started simulation mode (F8) - StudioTestService not available.",
131+
hasPlayer = false,
132+
})
133+
else
134+
return HttpService:JSONEncode({
135+
success = false,
136+
error = "Simulation started but verification failed: " .. tostring(verifyErr),
137+
})
138+
end
139+
end
140+
141+
local function handleStartPlaytest(args: Types.ToolArgs): string?
142+
if not args["StartPlaytest"] then
143+
return nil
144+
end
145+
146+
return startPlaytest()
147+
end
148+
149+
return handleStartPlaytest :: Types.ToolFunction

plugin/src/Types.luau

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@ export type RunCodeArgs = {
66
command: string,
77
}
88

9-
export type ToolArgs = { InsertModel: InsertModelArgs } | { RunCode: RunCodeArgs }
9+
export type GetStudioStateArgs = {}
10+
11+
export type StartPlaytestArgs = {}
12+
13+
export type StartSimulationArgs = {}
14+
15+
export type StopSimulationArgs = {}
16+
17+
export type StopPlaytestArgs = {}
18+
19+
export type ToolArgs =
20+
{ InsertModel: InsertModelArgs }
21+
| { RunCode: RunCodeArgs }
22+
| { GetStudioState: GetStudioStateArgs }
23+
| { StartPlaytest: StartPlaytestArgs }
24+
| { StartSimulation: StartSimulationArgs }
25+
| { StopSimulation: StopSimulationArgs }
26+
| { StopPlaytest: StopPlaytestArgs }
1027

1128
export type ToolFunction = (ToolArgs) -> string?
1229

0 commit comments

Comments
 (0)