@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. package/plugin/src/init.server.luau +209 -11
@@ -53,13 +53,45 @@ local function applyProperty(target: Instance, name: string, spec: PropertySpec)
53
53
  return nil
54
54
  end
55
55
 
56
+ --[[
57
+ Sets attributes, honouring an explicit type where one is given.
58
+
59
+ A bare value is written as it arrives, so a string stays a string. That is
60
+ the safe default and it is also a trap: attributes hold Vector3, Color3,
61
+ UDim2 and the rest, and `"0, 5, 0"` -- the exact text that sets a Vector3
62
+ property -- was landing on an attribute as five characters. The write looked
63
+ like it worked and the game read a string. So a value may instead arrive as
64
+ { type = "Vector3", value = "0, 5, 0" }, which goes through the same parser
65
+ properties use.
66
+ ]]
56
67
  local function applyAttributes(target: Instance, attributes: { [string]: any }): { string }
57
68
  local failures: { string } = {}
58
69
  for name, value in attributes do
59
- -- A JSON null means "remove this attribute", which SetAttribute spells
60
- -- as nil. There is no other way to express removal in the payload.
70
+ local resolved: any = value
71
+ local typed = typeof(value) == "table" and (value :: any).type ~= nil
72
+ if typed then
73
+ local spec = value :: { type: string, value: any }
74
+ local parsedOk, parsed, reason = Serialize.parse(spec.value, spec.type)
75
+ if not parsedOk then
76
+ table.insert(
77
+ failures,
78
+ string.format(
79
+ "attribute %s: %s",
80
+ name,
81
+ reason or string.format("could not be read as a %s", tostring(spec.type))
82
+ )
83
+ )
84
+ continue
85
+ end
86
+ resolved = parsed
87
+ end
88
+
89
+ -- An empty string means "remove this attribute", which SetAttribute spells
90
+ -- as nil. There is no other way to express removal in the payload -- and
91
+ -- the typed form is how to set an attribute to a genuinely empty string,
92
+ -- since only the bare form is read as removal.
61
93
  local ok, err = pcall(function()
62
- target:SetAttribute(name, if value == "" then nil else value)
94
+ target:SetAttribute(name, if not typed and value == "" then nil else resolved)
63
95
  end)
64
96
  if not ok then
65
97
  table.insert(failures, string.format("attribute %s: %s", name, tostring(err)))
@@ -14,7 +14,9 @@ local Config = require(script.Config)
14
14
  local Console = require(script.Console)
15
15
  local Dispatch = require(script.Dispatch)
16
16
  local LogBuffer = require(script.LogBuffer)
17
+ local Mirror = require(script.Mirror)
17
18
  local Phrase = require(script.Phrase)
19
+ local Themes = require(script.Themes)
18
20
  local Transport = require(script.Transport)
19
21
  local Debug = require(script.handlers.Debug)
20
22
  local Assets = require(script.handlers.Assets)
@@ -37,6 +39,9 @@ local Session = require(script.handlers.Session)
37
39
  local SETTING_PORT = "port"
38
40
  local SETTING_AUTOCONNECT = "autoConnect"
39
41
  local SETTING_FORCE_POLL = "forcePoll"
42
+ local SETTING_THEME = "theme"
43
+ local SETTING_WIDGET_OPEN = "widgetOpen"
44
+ local SETTING_WIDGET_SIZE = "widgetSize"
40
45
 
41
46
  --[[
42
47
  A fresh id per plugin load, which in practice means one per Studio window.
@@ -85,9 +90,10 @@ local function canConnect(): (boolean, string?)
85
90
  ]]
86
91
  return false,
87
92
  "client view of a playtest -- Studio forbids client sessions from making HTTP "
88
- .. "requests. The playtest's server session is connected and handling this "
89
- .. "place; switch Studio to the Server view (Test tab, Current: Server) to "
90
- .. "watch it work."
93
+ .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
94
+ .. "the playtest's server session instead, so the log and the strip below "
95
+ .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
96
+ .. "session that is actually connected."
91
97
  end
92
98
 
93
99
  -- First thing, before any handler or the transport can log: the buffer only
@@ -103,6 +109,36 @@ end
103
109
 
104
110
  Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
105
111
 
112
+ --[[
113
+ The console's colour preset, restored before anything is drawn.
114
+
115
+ Applied here rather than after mounting so the panel is built in the right
116
+ palette from the start -- restoring it afterwards would flash the default
117
+ theme for a frame on every Studio launch. An unknown id (a preset renamed,
118
+ or a setting written by a newer build) falls back to the default rather than
119
+ failing, which is `Themes.get`'s job.
120
+ ]]
121
+ local storedTheme = plugin:GetSetting(SETTING_THEME)
122
+ if typeof(storedTheme) == "string" then
123
+ Themes.use(storedTheme)
124
+ end
125
+
126
+ --[[
127
+ Whether the restore above still has to be pushed into the console.
128
+
129
+ `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
130
+ picks the change up for free -- which is why the prism cell came back on the
131
+ saved preset. `Console` does not read it live: it caches the palette in an
132
+ upvalue at module load, deliberately, so that forty read sites stay plain
133
+ field accesses. Module load happens at the `require` above, which is BEFORE
134
+ this line, so the cache held the default while the id said otherwise, and a
135
+ reload came back as the saved prism drawn in the default's colours.
136
+
137
+ It cannot simply be applied here either -- the console is not mounted yet.
138
+ So it is remembered and applied the moment it can be, right after mounting.
139
+ ]]
140
+ local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
141
+
106
142
  Session.register()
107
143
  Discover.register()
108
144
  Debug.register()
@@ -121,23 +157,131 @@ Input.register()
121
157
  Device.register()
122
158
  Api.register()
123
159
 
160
+ --[[
161
+ The toolbar button.
162
+
163
+ The icon has to be an uploaded asset: `CreateButton` takes a content string,
164
+ and the only ones Studio resolves are `rbxassetid://` for uploaded images and
165
+ `rbxasset://` for files that ship inside Studio itself. A path to something in
166
+ this repository is not one of them, which is why the mark in `assets/` has to
167
+ go through an upload before it can appear here.
168
+
169
+ It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
170
+ the toolbar next to a plugin that has nothing to do with purchases.
171
+ ]]
124
172
  local toolbar = plugin:CreateToolbar("rbx-studio")
125
173
  local button = toolbar:CreateButton(
126
174
  "rbx-studio",
127
175
  "Show the rbx-studio console",
128
- "rbxasset://textures/ui/common/robux.png"
176
+ "rbxassetid://125390773465346"
129
177
  )
130
178
  button.ClickableWhenViewportHidden = true
131
179
 
132
- -- Enabled by default: the whole point is that opening a place shows you it
133
- -- connected without touching anything. Studio remembers the user's choice after
134
- -- the first time they close it.
180
+ --[[
181
+ The panel, opened and sized the way the user last left it -- in EVERY
182
+ DataModel, which is the whole point.
183
+
184
+ Pressing Play loads this plugin again into the playtest's DataModels, and a
185
+ dock widget belongs to the DataModel that created it: the editor's is hidden
186
+ along with the editor's view, and the playtest's is a brand new widget that
187
+ Studio brings up closed and at the default size. So the panel vanished on
188
+ every playtest and came back, when the user re-opened it from the toolbar,
189
+ as a 560x320 float with whatever size they had chosen thrown away.
190
+
191
+ Studio's own restore does not cross that boundary, so the preference is kept
192
+ here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
193
+ set so it is honoured rather than second-guessed. Written only from the
194
+ editor session -- see below -- so a playtest starting or ending can never
195
+ record a decision the user did not make.
196
+ ]]
197
+ local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
198
+ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
199
+
200
+ local floatWidth, floatHeight = 560, 320
201
+ local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
202
+ if typeof(storedSize) == "table" then
203
+ local saved = storedSize :: { [string]: any }
204
+ local x, y = tonumber(saved.x), tonumber(saved.y)
205
+ -- Guarded against nonsense: a zero or absurd size saved from a docked or
206
+ -- mid-teardown widget would otherwise be unrecoverable without clearing
207
+ -- settings by hand.
208
+ if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
209
+ floatWidth, floatHeight = math.floor(x), math.floor(y)
210
+ end
211
+ end
212
+
135
213
  local widget = plugin:CreateDockWidgetPluginGuiAsync(
136
214
  "StudioMCP_Console",
137
- DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
215
+ DockWidgetPluginGuiInfo.new(
216
+ Enum.InitialDockState.Float,
217
+ wantOpen,
218
+ -- Override Studio's own enabled-restore: ours is the one that survives
219
+ -- the hop into a playtest DataModel, and two restores disagreeing is
220
+ -- what produced a panel that was open in the editor and closed in play.
221
+ true,
222
+ floatWidth,
223
+ floatHeight,
224
+ 360,
225
+ 200
226
+ )
138
227
  )
139
228
  widget.Title = "rbx-studio"
140
229
 
230
+ --[[
231
+ Remember what the user does with the panel, from the editor only.
232
+
233
+ The editor session is the one whose Enabled and size changes are actually
234
+ the user's: a playtest DataModel's widget is created, shown and destroyed by
235
+ Studio around the test, and letting those transitions write would persist a
236
+ "closed" the user never asked for -- reintroducing the bug through the back
237
+ door.
238
+ ]]
239
+ if RunService:IsEdit() then
240
+ local unloading = false
241
+ plugin.Unloading:Connect(function()
242
+ unloading = true
243
+ end)
244
+
245
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
246
+ if not unloading then
247
+ plugin:SetSetting(SETTING_WIDGET_OPEN, widget.Enabled)
248
+ end
249
+ end)
250
+
251
+ -- Only a real, sane size, and only while the panel is up: a hidden or
252
+ -- collapsing widget reports sizes that are not a choice.
253
+ local function persistSize(): boolean
254
+ local size = widget.AbsoluteSize
255
+ if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
256
+ return false
257
+ end
258
+ plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
259
+ return true
260
+ end
261
+
262
+ widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
263
+
264
+ --[[
265
+ Seed from the size Studio has already restored, rather than waiting for
266
+ a resize that may never come.
267
+
268
+ Without this the setting stays empty until the user happens to drag the
269
+ panel's edge, so the first playtest inherits the 560x320 default and the
270
+ window visibly shrinks -- which is the same complaint as it vanishing,
271
+ one step later. Studio settles the geometry a few frames after the widget
272
+ is created and fires no change event for it, so it is polled briefly and
273
+ then left alone.
274
+ ]]
275
+ task.defer(function()
276
+ for _ = 1, 40 do
277
+ if persistSize() then
278
+ return
279
+ end
280
+ task.wait(0.1)
281
+ end
282
+ end)
283
+ end
284
+
141
285
  local currentStatus: Transport.Status = "disconnected"
142
286
 
143
287
  local function refreshMeta()
@@ -165,8 +309,62 @@ Console.mount(widget, {
165
309
  onClear = function()
166
310
  Console.clear()
167
311
  end,
312
+ onTheme = function(id)
313
+ plugin:SetSetting(SETTING_THEME, id)
314
+ Console.log("dim", string.format("theme: %s", id))
315
+ end,
168
316
  })
169
317
 
318
+ -- The saved preset, now that there is something to paint. Done immediately
319
+ -- after mounting and before the first line is logged, so nothing is ever drawn
320
+ -- in the wrong palette.
321
+ if themeNeedsApplying then
322
+ Console.applyTheme()
323
+ end
324
+
325
+ --[[
326
+ The playtest halves, joined.
327
+
328
+ The server half can reach the bridge and sees every command; the client half
329
+ is the one Studio actually shows during a playtest and can reach nothing. So
330
+ the server relays its console events and the client replays them, which is
331
+ the only way the panel in front of the user reacts to the work being done.
332
+
333
+ Note which side sets the observer: only the SERVER. The client replays
334
+ events through the same `Console` functions, and if it were also observing
335
+ it would echo each one straight back into the channel.
336
+ ]]
337
+ if Mirror.isPlaytestServer() then
338
+ Mirror.startServer()
339
+ Console.setObserver(function(kind, arguments)
340
+ Mirror.send(kind, arguments)
341
+ end)
342
+ elseif Mirror.isPlaytestClient() then
343
+ Console.setMirroring(true)
344
+ Mirror.startClient(function(kind, arguments)
345
+ if kind == "log" then
346
+ local level, message, detail = arguments[1], arguments[2], arguments[3]
347
+ if typeof(level) == "string" and typeof(message) == "string" then
348
+ Console.log(
349
+ level :: any,
350
+ message,
351
+ if typeof(detail) == "string" then detail else nil
352
+ )
353
+ end
354
+ elseif kind == "beginCall" then
355
+ local title, callKind = arguments[1], arguments[2]
356
+ if typeof(title) == "string" and typeof(callKind) == "string" then
357
+ Console.beginCall(title, callKind)
358
+ end
359
+ elseif kind == "recordCall" then
360
+ local ok, milliseconds = arguments[1], arguments[2]
361
+ if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
362
+ Console.recordCall(ok, milliseconds)
363
+ end
364
+ end
365
+ end)
366
+ end
367
+
170
368
  Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
171
369
  Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
172
370
 
@@ -278,12 +476,12 @@ function connect()
278
476
  -- Reported as standby rather than an error: nothing failed, and this
279
477
  -- session was never going to connect.
280
478
  currentStatus = "disconnected"
281
- Console.log("dim", "standby", reason)
479
+ Console.log("dim", "mirroring", reason)
282
480
  Console.setStatus(
283
- "standby",
481
+ "mirroring",
284
482
  string.format("%s build %s", "client view", Config.BUILD_ID)
285
483
  )
286
- Console.setCaption("switch to the Server view to watch this playtest")
484
+ Console.setCaption("mirroring the playtest server")
287
485
  return
288
486
  end
289
487
  Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })