@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.
@@ -0,0 +1,187 @@
1
+ --!strict
2
+ --[[
3
+ The console log, carried across the boundaries that used to erase it.
4
+
5
+ Pressing Play does not pause this plugin, it loads a second and a third copy
6
+ of it into the playtest's DataModels, each with its own empty console. Studio
7
+ then shows you the play view's panel, so the session you were watching
8
+ appears to have been wiped; stopping the test shows the editor's again, and
9
+ everything that happened during the test is missing from it instead. Nothing
10
+ crashed and nothing was lost -- the log was simply written in a window that is
11
+ no longer the one on screen.
12
+
13
+ Two halves fix that, and they are deliberately different mechanisms because
14
+ the two directions have different problems.
15
+
16
+ Forwards (editor -> playtest) is a cold start: the playtest's copies begin
17
+ with nothing and need what was already on screen. That is persistence, and it
18
+ is what this file does -- the edit session writes its rows to plugin
19
+ settings, and every copy reads them once at load.
20
+
21
+ Backwards (playtest -> editor) is not persistence at all. The editor session
22
+ is still running the whole time; it just is not the one being asked to do the
23
+ work. So there is nothing to restore, only something to be told, and it is
24
+ told over the bridge -- see `announceToPeers` in the server's rpc.ts.
25
+
26
+ Only the EDIT session writes. It is the one that outlives every playtest and
27
+ receives the peer announcements, so its log is the superset; letting the
28
+ playtest copies write as well would store the same rows twice and replay both
29
+ on the next load.
30
+ ]]
31
+
32
+ local History = {}
33
+
34
+ local SETTING = "consoleHistory"
35
+
36
+ --[[
37
+ Rows kept, well under the console's own 300.
38
+
39
+ This is a settings file, rewritten in full on every save, and the value of an
40
+ old row falls off quickly -- what you want back is the screenful you were
41
+ looking at when the playtest started, not yesterday afternoon.
42
+ ]]
43
+ local MAX_ROWS = 150
44
+
45
+ --[[
46
+ Seconds between saves.
47
+
48
+ A burst of commands writes several rows a second and every one of them would
49
+ otherwise be a rewrite of the whole file. The loss window is the same two
50
+ seconds, and the only way to lose that window is to close Studio inside it --
51
+ which `flush` covers anyway.
52
+ ]]
53
+ local SAVE_EVERY = 2
54
+
55
+ export type Row = {
56
+ level: string,
57
+ message: string,
58
+ detail: string?,
59
+ stamp: string,
60
+ }
61
+
62
+ local store: Plugin? = nil
63
+ local read: (() -> { Row })? = nil
64
+ local dirty = false
65
+ local running = false
66
+
67
+ --[[
68
+ Which place this log belongs to.
69
+
70
+ One settings file is shared by every Studio window on the machine, so without
71
+ a key a second place's session would read back the first's history and show
72
+ it as its own. An unsaved place has no id, so its name stands in -- imperfect
73
+ between two unsaved places, and better than merging them.
74
+ ]]
75
+ local function placeKey(): string
76
+ if game.PlaceId ~= 0 then
77
+ return tostring(game.PlaceId)
78
+ end
79
+ return "local:" .. game.Name
80
+ end
81
+
82
+ --[[
83
+ Rows survive a round trip through JSON, so nothing that comes back is
84
+ trusted: a settings file is a file on disk, and a field that is missing or
85
+ the wrong type must skip the row rather than reach the renderer.
86
+ ]]
87
+ local function sane(value: any): Row?
88
+ if typeof(value) ~= "table" then
89
+ return nil
90
+ end
91
+ local row = value :: { [string]: any }
92
+ if typeof(row.level) ~= "string" or typeof(row.message) ~= "string" then
93
+ return nil
94
+ end
95
+ if typeof(row.stamp) ~= "string" then
96
+ return nil
97
+ end
98
+ return {
99
+ level = row.level,
100
+ message = row.message,
101
+ detail = if typeof(row.detail) == "string" then row.detail else nil,
102
+ stamp = row.stamp,
103
+ }
104
+ end
105
+
106
+ --[[
107
+ `snapshot` is a function rather than rows, because saving is throttled: what
108
+ must be written is whatever the console holds when the timer fires, not what
109
+ it held when the row that triggered it was logged.
110
+ ]]
111
+ function History.attach(pluginObject: Plugin, snapshot: () -> { Row })
112
+ store = pluginObject
113
+ read = snapshot
114
+ end
115
+
116
+ function History.restore(): { Row }
117
+ local pluginObject = store
118
+ if pluginObject == nil then
119
+ return {}
120
+ end
121
+ local saved = pluginObject:GetSetting(SETTING)
122
+ if typeof(saved) ~= "table" then
123
+ return {}
124
+ end
125
+ local record = saved :: { [string]: any }
126
+ if record.place ~= placeKey() or typeof(record.rows) ~= "table" then
127
+ return {}
128
+ end
129
+ local rows: { Row } = {}
130
+ for _, entry in record.rows :: { any } do
131
+ local row = sane(entry)
132
+ if row ~= nil then
133
+ table.insert(rows, row)
134
+ end
135
+ end
136
+ return rows
137
+ end
138
+
139
+ --[[
140
+ Writes now. Called from the save loop and from `plugin.Unloading`, where
141
+ there is no next tick to wait for.
142
+ ]]
143
+ function History.flush()
144
+ local pluginObject, snapshot = store, read
145
+ if pluginObject == nil or snapshot == nil then
146
+ return
147
+ end
148
+ dirty = false
149
+ local rows = snapshot()
150
+ while #rows > MAX_ROWS do
151
+ table.remove(rows, 1)
152
+ end
153
+ -- Guarded because a settings write is disk I/O: a full disk or a locked file
154
+ -- must cost the console its history, not its session.
155
+ pcall(function()
156
+ pluginObject:SetSetting(SETTING, { place = placeKey(), rows = rows })
157
+ end)
158
+ end
159
+
160
+ -- Marks the log as changed. Cheap on purpose: it runs on every logged row.
161
+ function History.touch()
162
+ dirty = true
163
+ end
164
+
165
+ --[[
166
+ Starts the save loop. Owned here rather than by the caller so the throttle
167
+ and the thing it throttles cannot drift apart.
168
+ ]]
169
+ function History.start()
170
+ running = true
171
+ task.spawn(function()
172
+ while running do
173
+ task.wait(SAVE_EVERY)
174
+ if running and dirty then
175
+ History.flush()
176
+ end
177
+ end
178
+ end)
179
+ end
180
+
181
+ -- Stops the loop on unload. A reloaded plugin is a fresh module, so without
182
+ -- this every reload would leave another timer running against a dead session.
183
+ function History.stop()
184
+ running = false
185
+ end
186
+
187
+ return History
@@ -0,0 +1,145 @@
1
+ --!strict
2
+ --[[
3
+ Carries console activity from a playtest's SERVER view to its CLIENT view.
4
+
5
+ Pressing Play gives this plugin three lives: the editor's, the playtest
6
+ server's, and the playtest client's. Studio shows whichever belongs to the
7
+ view you are looking at, and it defaults to the client -- which is the one
8
+ life that can do nothing. Roblox forbids HTTP from a client session, so that
9
+ panel has no way to see the bridge, and it sat on a standby notice while the
10
+ agent worked a few feet away in a view the user was not looking at.
11
+
12
+ The two DataModels share no memory, no globals and no services. The only
13
+ channel between them is the game's own replication, so that is what this
14
+ uses: the server plugin puts a RemoteEvent in ReplicatedStorage and fires
15
+ each console event across it; the client plugin listens and replays them
16
+ into its own console. The prism reacts, the log fills, and the view the user
17
+ actually has open stops lying about what is happening.
18
+
19
+ The cost is honest and worth stating: this plugin writes one instance into
20
+ the running game. It is `Archivable = false` so it can never be saved with
21
+ the place, it exists only while a playtest is running, and it goes when the
22
+ playtest's DataModel does. Nothing reads it but the client half of this
23
+ plugin, and nothing is sent up from the client -- the traffic is one-way, so
24
+ a game's own scripts cannot use it to reach the editor.
25
+ ]]
26
+
27
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
28
+ local RunService = game:GetService("RunService")
29
+
30
+ --[[
31
+ Named to be recognised in the Explorer rather than to be hidden.
32
+
33
+ Someone will see this appear in ReplicatedStorage mid-playtest and want to
34
+ know what put it there; a cryptic name turns that into a hunt. The prefix is
35
+ unlikely enough to collide with a real game's instances.
36
+ ]]
37
+ local CHANNEL = "__rbxStudioMcpMirror"
38
+
39
+ local Mirror = {}
40
+
41
+ local remote: RemoteEvent? = nil
42
+
43
+ --[[
44
+ Held shut until the server session has finished introducing itself.
45
+
46
+ Both halves of a playtest log their own banner, place and status, so a
47
+ channel that is open from the start delivers the server's copy of all of it
48
+ to a client that has already written its own -- the panel comes up saying
49
+ "rbx-studio v0.3.6" and "place: Game" twice, a line apart, which reads as a
50
+ glitch because it is one.
51
+
52
+ Opened on the server's "connected" row rather than after a fixed wait: that
53
+ is the exact point where its startup ends and the session's real activity
54
+ begins, and everything after it is news the client does not already have.
55
+ ]]
56
+ local open = false
57
+
58
+ function Mirror.open()
59
+ open = true
60
+ end
61
+
62
+ --[[
63
+ True only in the half of a playtest that can reach the bridge.
64
+
65
+ `IsRunning` separates a playtest from the editor, and `IsServer` separates
66
+ the two halves of it. The editor session must not do any of this: it has no
67
+ playtest to mirror and writing to its ReplicatedStorage would put the
68
+ instance in the user's actual place.
69
+ ]]
70
+ function Mirror.isPlaytestServer(): boolean
71
+ return RunService:IsRunning() and RunService:IsServer()
72
+ end
73
+
74
+ function Mirror.isPlaytestClient(): boolean
75
+ return RunService:IsRunning() and RunService:IsClient()
76
+ end
77
+
78
+ --[[
79
+ Opens the channel from the playtest server.
80
+
81
+ A leftover from an earlier test in the same DataModel is replaced rather
82
+ than reused: reusing one whose connections are gone looks like a working
83
+ channel that silently delivers nothing.
84
+ ]]
85
+ function Mirror.startServer()
86
+ if not Mirror.isPlaytestServer() then
87
+ return
88
+ end
89
+ local existing = ReplicatedStorage:FindFirstChild(CHANNEL)
90
+ if existing ~= nil then
91
+ existing:Destroy()
92
+ end
93
+ local event = Instance.new("RemoteEvent")
94
+ event.Name = CHANNEL
95
+ -- Never saved with the place, whatever the user does while the test runs.
96
+ event.Archivable = false
97
+ event.Parent = ReplicatedStorage
98
+ remote = event
99
+ end
100
+
101
+ --[[
102
+ Sends one console event, and never fails loudly.
103
+
104
+ This is decoration on top of the console. A replication error here must not
105
+ take down the call it was reporting on, so every send is guarded and a dead
106
+ channel simply stops mirroring.
107
+ ]]
108
+ function Mirror.send(kind: string, arguments: { any })
109
+ local event = remote
110
+ if event == nil or not open then
111
+ return
112
+ end
113
+ pcall(function()
114
+ event:FireAllClients(kind, arguments)
115
+ end)
116
+ end
117
+
118
+ --[[
119
+ Listens on the playtest client.
120
+
121
+ `WaitForChild` with a timeout rather than an indefinite wait: if the server
122
+ half never opens the channel the client should go quiet, not hang a thread
123
+ for the length of the playtest.
124
+ ]]
125
+ function Mirror.startClient(onMessage: (string, { any }) -> ())
126
+ if not Mirror.isPlaytestClient() then
127
+ return
128
+ end
129
+ task.spawn(function()
130
+ local event = ReplicatedStorage:WaitForChild(CHANNEL, 30)
131
+ if event == nil or not event:IsA("RemoteEvent") then
132
+ return
133
+ end
134
+ event.OnClientEvent:Connect(function(kind, arguments)
135
+ -- Shape-checked because anything on the wire can be forged by a
136
+ -- script in the place being tested. Nothing here is trusted enough
137
+ -- to index without looking.
138
+ if typeof(kind) == "string" and typeof(arguments) == "table" then
139
+ onMessage(kind, arguments :: { any })
140
+ end
141
+ end)
142
+ end)
143
+ end
144
+
145
+ return Mirror