@el4cteo/rbx-studio-mcp 0.3.5 → 0.3.7

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.
@@ -13,7 +13,9 @@ local RunService = game:GetService("RunService")
13
13
  local Config = require(script.Config)
14
14
  local Console = require(script.Console)
15
15
  local Dispatch = require(script.Dispatch)
16
+ local History = require(script.History)
16
17
  local LogBuffer = require(script.LogBuffer)
18
+ local Mirror = require(script.Mirror)
17
19
  local Phrase = require(script.Phrase)
18
20
  local Themes = require(script.Themes)
19
21
  local Transport = require(script.Transport)
@@ -39,6 +41,8 @@ local SETTING_PORT = "port"
39
41
  local SETTING_AUTOCONNECT = "autoConnect"
40
42
  local SETTING_FORCE_POLL = "forcePoll"
41
43
  local SETTING_THEME = "theme"
44
+ local SETTING_WIDGET_OPEN = "widgetOpen"
45
+ local SETTING_WIDGET_SIZE = "widgetSize"
42
46
 
43
47
  --[[
44
48
  A fresh id per plugin load, which in practice means one per Studio window.
@@ -87,9 +91,10 @@ local function canConnect(): (boolean, string?)
87
91
  ]]
88
92
  return false,
89
93
  "client view of a playtest -- Studio forbids client sessions from making HTTP "
90
- .. "requests. The playtest's server session is connected and handling this "
91
- .. "place; switch Studio to the Server view (Test tab, Current: Server) to "
92
- .. "watch it work."
94
+ .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
95
+ .. "the playtest's server session instead, so the log and the strip below "
96
+ .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
97
+ .. "session that is actually connected."
93
98
  end
94
99
 
95
100
  -- First thing, before any handler or the transport can log: the buffer only
@@ -119,6 +124,22 @@ if typeof(storedTheme) == "string" then
119
124
  Themes.use(storedTheme)
120
125
  end
121
126
 
127
+ --[[
128
+ Whether the restore above still has to be pushed into the console.
129
+
130
+ `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
131
+ picks the change up for free -- which is why the prism cell came back on the
132
+ saved preset. `Console` does not read it live: it caches the palette in an
133
+ upvalue at module load, deliberately, so that forty read sites stay plain
134
+ field accesses. Module load happens at the `require` above, which is BEFORE
135
+ this line, so the cache held the default while the id said otherwise, and a
136
+ reload came back as the saved prism drawn in the default's colours.
137
+
138
+ It cannot simply be applied here either -- the console is not mounted yet.
139
+ So it is remembered and applied the moment it can be, right after mounting.
140
+ ]]
141
+ local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
142
+
122
143
  Session.register()
123
144
  Discover.register()
124
145
  Debug.register()
@@ -157,15 +178,111 @@ local button = toolbar:CreateButton(
157
178
  )
158
179
  button.ClickableWhenViewportHidden = true
159
180
 
160
- -- Enabled by default: the whole point is that opening a place shows you it
161
- -- connected without touching anything. Studio remembers the user's choice after
162
- -- the first time they close it.
181
+ --[[
182
+ The panel, opened and sized the way the user last left it -- in EVERY
183
+ DataModel, which is the whole point.
184
+
185
+ Pressing Play loads this plugin again into the playtest's DataModels, and a
186
+ dock widget belongs to the DataModel that created it: the editor's is hidden
187
+ along with the editor's view, and the playtest's is a brand new widget that
188
+ Studio brings up closed and at the default size. So the panel vanished on
189
+ every playtest and came back, when the user re-opened it from the toolbar,
190
+ as a 560x320 float with whatever size they had chosen thrown away.
191
+
192
+ Studio's own restore does not cross that boundary, so the preference is kept
193
+ here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
194
+ set so it is honoured rather than second-guessed. Written only from the
195
+ editor session -- see below -- so a playtest starting or ending can never
196
+ record a decision the user did not make.
197
+ ]]
198
+ local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
199
+ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
200
+
201
+ local floatWidth, floatHeight = 560, 320
202
+ local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
203
+ if typeof(storedSize) == "table" then
204
+ local saved = storedSize :: { [string]: any }
205
+ local x, y = tonumber(saved.x), tonumber(saved.y)
206
+ -- Guarded against nonsense: a zero or absurd size saved from a docked or
207
+ -- mid-teardown widget would otherwise be unrecoverable without clearing
208
+ -- settings by hand.
209
+ if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
210
+ floatWidth, floatHeight = math.floor(x), math.floor(y)
211
+ end
212
+ end
213
+
163
214
  local widget = plugin:CreateDockWidgetPluginGuiAsync(
164
215
  "StudioMCP_Console",
165
- DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
216
+ DockWidgetPluginGuiInfo.new(
217
+ Enum.InitialDockState.Float,
218
+ wantOpen,
219
+ -- Override Studio's own enabled-restore: ours is the one that survives
220
+ -- the hop into a playtest DataModel, and two restores disagreeing is
221
+ -- what produced a panel that was open in the editor and closed in play.
222
+ true,
223
+ floatWidth,
224
+ floatHeight,
225
+ 360,
226
+ 200
227
+ )
166
228
  )
167
229
  widget.Title = "rbx-studio"
168
230
 
231
+ --[[
232
+ Remember what the user does with the panel, from the editor only.
233
+
234
+ The editor session is the one whose Enabled and size changes are actually
235
+ the user's: a playtest DataModel's widget is created, shown and destroyed by
236
+ Studio around the test, and letting those transitions write would persist a
237
+ "closed" the user never asked for -- reintroducing the bug through the back
238
+ door.
239
+ ]]
240
+ if RunService:IsEdit() then
241
+ local unloading = false
242
+ plugin.Unloading:Connect(function()
243
+ unloading = true
244
+ end)
245
+
246
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
247
+ if not unloading then
248
+ plugin:SetSetting(SETTING_WIDGET_OPEN, widget.Enabled)
249
+ end
250
+ end)
251
+
252
+ -- Only a real, sane size, and only while the panel is up: a hidden or
253
+ -- collapsing widget reports sizes that are not a choice.
254
+ local function persistSize(): boolean
255
+ local size = widget.AbsoluteSize
256
+ if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
257
+ return false
258
+ end
259
+ plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
260
+ return true
261
+ end
262
+
263
+ widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
264
+
265
+ --[[
266
+ Seed from the size Studio has already restored, rather than waiting for
267
+ a resize that may never come.
268
+
269
+ Without this the setting stays empty until the user happens to drag the
270
+ panel's edge, so the first playtest inherits the 560x320 default and the
271
+ window visibly shrinks -- which is the same complaint as it vanishing,
272
+ one step later. Studio settles the geometry a few frames after the widget
273
+ is created and fires no change event for it, so it is polled briefly and
274
+ then left alone.
275
+ ]]
276
+ task.defer(function()
277
+ for _ = 1, 40 do
278
+ if persistSize() then
279
+ return
280
+ end
281
+ task.wait(0.1)
282
+ end
283
+ end)
284
+ end
285
+
169
286
  local currentStatus: Transport.Status = "disconnected"
170
287
 
171
288
  local function refreshMeta()
@@ -199,6 +316,94 @@ Console.mount(widget, {
199
316
  end,
200
317
  })
201
318
 
319
+ -- The saved preset, now that there is something to paint. Done immediately
320
+ -- after mounting and before the first line is logged, so nothing is ever drawn
321
+ -- in the wrong palette.
322
+ if themeNeedsApplying then
323
+ Console.applyTheme()
324
+ end
325
+
326
+ --[[
327
+ The log, picked up where the last copy of this plugin left it.
328
+
329
+ Restored before the banner below rather than after it, so the carried rows
330
+ read as what they are -- what was on screen a moment ago -- with this
331
+ session's first line underneath them instead of buried in the middle.
332
+
333
+ Every session reads; only the editor writes. See `History` for why.
334
+ ]]
335
+ History.attach(plugin, Console.snapshot)
336
+ local carried = History.restore()
337
+ if #carried > 0 then
338
+ Console.restore(carried)
339
+ Console.log(
340
+ "dim",
341
+ string.format("carried %d line%s over", #carried, if #carried == 1 then "" else "s"),
342
+ if RunService:IsEdit() then "from the last session" else "from the editor"
343
+ )
344
+ end
345
+
346
+ if RunService:IsEdit() then
347
+ History.start()
348
+ plugin.Unloading:Connect(function()
349
+ History.stop()
350
+ -- The loop cannot save what happens after it has stopped, and closing
351
+ -- Studio is exactly when the last few rows matter most.
352
+ History.flush()
353
+ end)
354
+ end
355
+
356
+ --[[
357
+ The playtest halves, joined.
358
+
359
+ The server half can reach the bridge and sees every command; the client half
360
+ is the one Studio actually shows during a playtest and can reach nothing. So
361
+ the server relays its console events and the client replays them, which is
362
+ the only way the panel in front of the user reacts to the work being done.
363
+
364
+ Note which side sets the observer: only the SERVER. The client replays
365
+ events through the same `Console` functions, and if it were also observing
366
+ it would echo each one straight back into the channel.
367
+ ]]
368
+ if RunService:IsEdit() then
369
+ -- The only observer the edit session needs: every logged row marks the
370
+ -- stored copy stale, and the save loop above decides when to write it.
371
+ Console.setObserver(function()
372
+ History.touch()
373
+ end)
374
+ elseif Mirror.isPlaytestServer() then
375
+ Mirror.startServer()
376
+ Console.setObserver(function(kind, arguments)
377
+ Mirror.send(kind, arguments)
378
+ end)
379
+ -- The channel itself stays shut until `onStatus` reports the connection;
380
+ -- see `Mirror.open`.
381
+ elseif Mirror.isPlaytestClient() then
382
+ Console.setMirroring(true)
383
+ Mirror.startClient(function(kind, arguments)
384
+ if kind == "log" then
385
+ local level, message, detail = arguments[1], arguments[2], arguments[3]
386
+ if typeof(level) == "string" and typeof(message) == "string" then
387
+ Console.log(
388
+ level :: any,
389
+ message,
390
+ if typeof(detail) == "string" then detail else nil
391
+ )
392
+ end
393
+ elseif kind == "beginCall" then
394
+ local title, callKind = arguments[1], arguments[2]
395
+ if typeof(title) == "string" and typeof(callKind) == "string" then
396
+ Console.beginCall(title, callKind)
397
+ end
398
+ elseif kind == "recordCall" then
399
+ local ok, milliseconds = arguments[1], arguments[2]
400
+ if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
401
+ Console.recordCall(ok, milliseconds)
402
+ end
403
+ end
404
+ end)
405
+ end
406
+
202
407
  Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
203
408
  Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
204
409
 
@@ -219,6 +424,9 @@ local function onStatus(status: Transport.Status, detail: string?)
219
424
  string.format("connected to 127.0.0.1:%d", Config.getPort()),
220
425
  if detail then "(" .. detail .. ")" else nil
221
426
  )
427
+ -- After the row, not before it: this session's own arrival is not news
428
+ -- to the client view, which announced its own a moment ago.
429
+ Mirror.open()
222
430
  elseif status == "connecting" then
223
431
  Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
224
432
  else
@@ -289,6 +497,21 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
289
497
  end)
290
498
  end
291
499
 
500
+ --[[
501
+ Where a peer's call ran, in the width the detail column has.
502
+
503
+ The latency already lives there and only about fourteen characters fit
504
+ beside a message, so this is two letters and a space rather than "playtest
505
+ server". Which half of a playtest it was does not matter to someone reading
506
+ the editor's log afterwards; that it was not the editor does.
507
+ ]]
508
+ local function peerTag(from: any): string
509
+ if typeof(from) ~= "string" then
510
+ return "peer"
511
+ end
512
+ return if from == "edit" then "edit" else "play"
513
+ end
514
+
292
515
  --[[
293
516
  Bridge news that is not a command.
294
517
 
@@ -301,6 +524,33 @@ local function onEvent(event: { [string]: any })
301
524
  Console.setClients(event.count)
302
525
  elseif event.event == "agent" and event.state == "finished" then
303
526
  Console.agentFinished()
527
+ elseif event.event == "peer" and typeof(event.op) == "string" then
528
+ --[[
529
+ A command another session on this place just finished.
530
+
531
+ Logged as if it had run here, because as far as the user is concerned
532
+ it did: they pressed Play, watched the agent work, pressed Stop, and
533
+ the panel that comes back is this one. Without this it comes back with
534
+ a hole in it exactly the length of the playtest.
535
+
536
+ The prism is bumped too -- `beginCall` then `recordCall`, back to
537
+ back -- so the trace carries a bar for the call and the session
538
+ counters count it. Nothing here reaches the transport, so a peer row
539
+ can never be announced onwards and two sessions cannot echo forever.
540
+ ]]
541
+ local params = if typeof(event.params) == "table" then event.params else nil
542
+ local title = Phrase.of(event.op, params)
543
+ local milliseconds = if typeof(event.ms) == "number" then event.ms else 0
544
+ local ok = event.ok == true
545
+ local detail = string.format("%s %.0fms", peerTag(event.from), milliseconds)
546
+
547
+ Console.beginCall(title, Phrase.kindOf(event.op))
548
+ Console.recordCall(ok, milliseconds)
549
+ if ok then
550
+ Console.log("reply", title, detail)
551
+ else
552
+ Console.log("error", string.format("%s failed", title), detail)
553
+ end
304
554
  end
305
555
  end
306
556
 
@@ -310,12 +560,12 @@ function connect()
310
560
  -- Reported as standby rather than an error: nothing failed, and this
311
561
  -- session was never going to connect.
312
562
  currentStatus = "disconnected"
313
- Console.log("dim", "standby", reason)
563
+ Console.log("dim", "mirroring", reason)
314
564
  Console.setStatus(
315
- "standby",
565
+ "mirroring",
316
566
  string.format("%s build %s", "client view", Config.BUILD_ID)
317
567
  )
318
- Console.setCaption("switch to the Server view to watch this playtest")
568
+ Console.setCaption("mirroring the playtest server")
319
569
  return
320
570
  end
321
571
  Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })