@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.
- package/dist/bridge/rpc.js +73 -0
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/tools/input.js +38 -5
- package/dist/tools/input.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +1101 -998
- package/plugin/src/History.luau +187 -0
- package/plugin/src/Mirror.luau +145 -0
- package/plugin/src/ThemePicker.luau +458 -458
- package/plugin/src/Themes/Aurora.luau +161 -147
- package/plugin/src/Themes/Void.luau +266 -230
- package/plugin/src/Undo.luau +29 -0
- package/plugin/src/Visuals.luau +56 -6
- package/plugin/src/handlers/Device.luau +21 -1
- package/plugin/src/handlers/Input.luau +583 -493
- package/plugin/src/init.server.luau +260 -10
|
@@ -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
|