@el4cteo/rbx-studio-mcp 0.8.0 → 0.8.2

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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/apidump.js +24 -2
  3. package/dist/lib/apidump.js.map +1 -1
  4. package/dist/lib/livedata.js +24 -1
  5. package/dist/lib/livedata.js.map +1 -1
  6. package/dist/lib/liveops.js +79 -0
  7. package/dist/lib/liveops.js.map +1 -1
  8. package/dist/lib/monetization.js +119 -0
  9. package/dist/lib/monetization.js.map +1 -0
  10. package/dist/lib/opencloud.js +16 -6
  11. package/dist/lib/opencloud.js.map +1 -1
  12. package/dist/tools/audio.js +1 -1
  13. package/dist/tools/audio.js.map +1 -1
  14. package/dist/tools/device.js +8 -7
  15. package/dist/tools/device.js.map +1 -1
  16. package/dist/tools/discover.js +8 -1
  17. package/dist/tools/discover.js.map +1 -1
  18. package/dist/tools/perf.js +27 -5
  19. package/dist/tools/perf.js.map +1 -1
  20. package/dist/tools/playtest.js +35 -10
  21. package/dist/tools/playtest.js.map +1 -1
  22. package/dist/tools/terrain.js +2 -2
  23. package/dist/tools/terrain.js.map +1 -1
  24. package/dist/tools/universe.js +136 -11
  25. package/dist/tools/universe.js.map +1 -1
  26. package/dist/tools/world.js +30 -7
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/ClientRelay.luau +20 -2
  30. package/plugin/src/Config.luau +1 -1
  31. package/plugin/src/Emulation.luau +44 -16
  32. package/plugin/src/ExecRuntime.luau +168 -161
  33. package/plugin/src/LogBuffer.luau +58 -3
  34. package/plugin/src/Phrase.luau +2 -0
  35. package/plugin/src/handlers/Assets.luau +27 -18
  36. package/plugin/src/handlers/Audio.luau +36 -15
  37. package/plugin/src/handlers/Character.luau +38 -23
  38. package/plugin/src/handlers/Data.luau +12 -1
  39. package/plugin/src/handlers/Device.luau +38 -27
  40. package/plugin/src/handlers/Generate.luau +8 -5
  41. package/plugin/src/handlers/Geometry.luau +7 -5
  42. package/plugin/src/handlers/Perf.luau +1 -0
  43. package/plugin/src/handlers/Playtest.luau +40 -3
  44. package/plugin/src/handlers/Terrain.luau +9 -24
  45. package/plugin/src/handlers/Viewport.luau +4 -4
  46. package/plugin/src/handlers/World.luau +17 -1
  47. package/scripts/test-tools.mjs +178 -0
@@ -75,34 +75,62 @@ local memoryCap: number? = nil
75
75
  local NETWORK_FIELDS = {
76
76
  { key = "inLatency", property = "InboundNetworkMinDelayMs", scale = 1 },
77
77
  { key = "inJitter", property = "InboundNetworkJitterMs", scale = 1 },
78
- { key = "inLoss", property = "InboundNetworkLossPercent", scale = 100 },
78
+ { key = "inLoss", property = "InboundNetworkLossPercent", scale = 1 },
79
79
  { key = "outLatency", property = "OutboundNetworkMinDelayMs", scale = 1 },
80
80
  { key = "outJitter", property = "OutboundNetworkJitterMs", scale = 1 },
81
- { key = "outLoss", property = "OutboundNetworkLossPercent", scale = 100 },
81
+ { key = "outLoss", property = "OutboundNetworkLossPercent", scale = 1 },
82
82
  }
83
83
 
84
84
  --[[
85
85
  What the engine will actually accept, measured rather than assumed.
86
86
 
87
- None of this is documented and all three of them bite. Writes outside these
88
- ranges SUCCEED and are silently clamped, so a preset asking for 2% loss was
89
- reporting 0.5 back and the only way to notice was to read the value again --
90
- which is how this was found.
87
+ Writes outside these ranges SUCCEED and are silently clamped, so the only
88
+ way to notice is to read the value again.
91
89
 
92
- * Loss is a FRACTION, despite `LossPercent` in the name: it tops out at 0.5,
93
- and 0.5 is half the packets, not half a percent. Everything above this
94
- layer speaks in percent because that is what a person means by "2% loss",
95
- so the conversion happens at the edges -- `scale` above on the way out, and
96
- `LOSS_LIMIT` on the way in.
90
+ * Loss is a PERCENT, as the name says, and Roblox caps it at 0.5% "to
91
+ prevent interfering with standard congestion control" (the Network
92
+ Simulation announcement; its own "Poor" profile is 0.5%). This file once
93
+ read the 0.5 ceiling as a fraction -- half of all packets -- and divided
94
+ every request by 100, so "0.5% loss" applied 0.005% while the reply,
95
+ scaled back up, still said 0.5.
97
96
  * Delay and jitter both stop at 1000ms. A second of latency is already past
98
97
  anything a real connection does, so this costs nothing but honesty.
99
98
  ]]
100
- local LOSS_LIMIT = 0.5
99
+ local LOSS_LIMIT_PERCENT = 0.5
101
100
  local DELAY_LIMIT = 1000
102
101
 
103
- Emulation.LOSS_LIMIT_PERCENT = LOSS_LIMIT * 100
102
+ Emulation.LOSS_LIMIT_PERCENT = LOSS_LIMIT_PERCENT
104
103
  Emulation.DELAY_LIMIT = DELAY_LIMIT
105
104
 
105
+ --[[
106
+ The machine's real memory, read before this plugin could have capped it.
107
+
108
+ `EmulatedTotalMemoryInMB` defaults to the physical memory and 0 is a real
109
+ value, not "off": clearing a cap by writing 0 left Studio emulating a
110
+ device with no memory at all. Removing a cap therefore means writing this
111
+ back. Read once at load, which is before anything here writes it.
112
+ ]]
113
+ local machineMemory: number? = nil
114
+ do
115
+ local service = networkSettings()
116
+ if service ~= nil then
117
+ local ok, value = pcall(function()
118
+ return service.EmulatedTotalMemoryInMB
119
+ end)
120
+ if ok and tonumber(value) and (tonumber(value) :: number) > 0 then
121
+ machineMemory = tonumber(value)
122
+ end
123
+ end
124
+ end
125
+
126
+ function Emulation.machineMemory(): number?
127
+ return machineMemory
128
+ end
129
+
130
+ function Emulation.memoryCap(): number?
131
+ return memoryCap
132
+ end
133
+
106
134
  --[[
107
135
  What Studio is currently doing to network traffic.
108
136
 
@@ -125,9 +153,9 @@ function Emulation.network(): { [string]: any }
125
153
  local number = if ok then tonumber(value) or 0 else 0
126
154
  --[[
127
155
  Scaled into the caller's units, then rounded to two places. These are
128
- 32-bit floats: 2% loss is stored as 0.02 and reads back as
129
- 0.019999999552965164, and reporting that instead of 2 makes a
130
- perfectly applied setting look like a failed one.
156
+ 32-bit floats: 0.3% loss reads back as 0.30000001192092896, and
157
+ reporting that instead of 0.3 makes a perfectly applied setting look
158
+ like a failed one.
131
159
  ]]
132
160
  number *= field.scale
133
161
  state[field.key] = math.round(number * 100) / 100
@@ -1,161 +1,168 @@
1
- --!strict
2
- -- Shared bounded execution results in the VM that calls run.
3
- local LogService = game:GetService("LogService")
4
- local Serialize = require(script.Parent.Serialize)
5
- -- A chunk that prints in a loop would otherwise return a response no context
6
- -- window can hold.
7
- local MAX_OUTPUT_LINES = 200
8
- local MAX_RETURN_VALUES = 10
9
-
10
- -- Returned tables are walked rather than counted, but not without limit: a
11
- -- chunk that returns a whole config tree, or one with a cycle in it, must not be
12
- -- able to produce a response nothing can read.
13
- local MAX_TABLE_ENTRIES = 50
14
- local MAX_TABLE_DEPTH = 4
15
-
16
- local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
17
- local level = depth or 0
18
- local visited = seen or {}
19
- local kind = typeof(value)
20
-
21
- if kind == "function" or kind == "thread" then
22
- return string.format("<%s>", kind)
23
- end
24
- if kind ~= "table" then
25
- return Serialize.value(value)
26
- end
27
-
28
- local source = value :: { [any]: any }
29
- -- Named separately from the depth cap, because they mean opposite things to
30
- -- a reader. The depth cap says "there is more below this"; a cycle says
31
- -- "this is the table you are already inside". Reporting the second as the
32
- -- first printed `t.self.self.self` as three distinct nested tables, which
33
- -- reads as real structure that does not exist.
34
- if visited[source] then
35
- return "<circular reference>"
36
- end
37
- if level >= MAX_TABLE_DEPTH then
38
- return "<nested table>"
39
- end
40
- visited[source] = true
41
-
42
- local count = 0
43
- for _ in source do
44
- count += 1
45
- end
46
-
47
- -- Arrays keep their shape so they read as lists on the other side; anything
48
- -- with non-sequential keys becomes a map with stringified keys.
49
- if count == #source then
50
- local list: { any } = {}
51
- for index, item in ipairs(source) do
52
- if index > MAX_TABLE_ENTRIES then
53
- table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
54
- break
55
- end
56
- table.insert(list, describe(item, level + 1, visited))
57
- end
58
- -- Cleared on the way out so the same table appearing twice side by side
59
- -- still renders twice; only a table containing itself is a cycle.
60
- visited[source] = nil
61
- return list
62
- end
63
-
64
- local map: { [string]: any } = {}
65
- local shown = 0
66
- for key, item in source do
67
- shown += 1
68
- if shown > MAX_TABLE_ENTRIES then
69
- map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
70
- break
71
- end
72
- map[tostring(key)] = describe(item, level + 1, visited)
73
- end
74
- visited[source] = nil
75
- return map
76
- end
77
-
78
- local Runtime = {}
79
- Runtime.describe = describe
80
- function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
81
- -- Captured rather than inferred: a chunk's prints are usually the point, and
82
- -- reading them back from the log afterwards would also pick up whatever else
83
- -- the place logged in the meantime.
84
- local output: { { [string]: any } } = {}
85
- local connection = LogService.MessageOut:Connect(function(message, messageType)
86
- if #output < MAX_OUTPUT_LINES then
87
- table.insert(output, {
88
- message = message,
89
- level = if messageType == Enum.MessageType.MessageError
90
- then "error"
91
- elseif messageType == Enum.MessageType.MessageWarning then "warning"
92
- else "print",
93
- })
94
- end
95
- end)
96
-
97
- local started = os.clock()
98
- local results: any = nil
99
- if timeout then
100
- local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
101
- local deadline = started + timeout
102
- while results == nil and os.clock() < deadline do task.wait() end
103
- if results == nil then
104
- task.cancel(worker)
105
- results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
106
- end
107
- else
108
- results = table.pack(pcall(chunk :: () -> ...any))
109
- end
110
- local elapsed = os.clock() - started
111
-
112
- --[[
113
- MessageOut is deferred: `print` returns before the event fires, so
114
- disconnecting straight after the chunk finished captured nothing at all.
115
- A short settle picks the lines up.
116
-
117
- It is bounded and stops as soon as the flow stops, rather than waiting a
118
- fixed period, so a chunk that logged nothing costs almost no time and one
119
- that logged plenty is not truncated.
120
- ]]
121
- local settleDeadline = os.clock() + 0.5
122
- local seen = #output
123
- local quietFrames = 0
124
- while os.clock() < settleDeadline and quietFrames < 3 do
125
- task.wait()
126
- if #output == seen then
127
- quietFrames += 1
128
- else
129
- seen = #output
130
- quietFrames = 0
131
- end
132
- end
133
-
134
- connection:Disconnect()
135
-
136
- if not results[1] then
137
- return {
138
- ok = false,
139
- error = tostring(results[2]),
140
- output = output,
141
- milliseconds = math.floor(elapsed * 1000 + 0.5),
142
- }
143
- end
144
-
145
- local returned: { any } = {}
146
- for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
147
- -- A nil return is written out rather than dropped. `table.insert` of nil
148
- -- does nothing, so `return a, nil, b` came back as two values and every
149
- -- one after the nil shifted into the wrong position.
150
- local value = results[index]
151
- table.insert(returned, if value == nil then "nil" else describe(value))
152
- end
153
-
154
- return {
155
- ok = true,
156
- returned = returned,
157
- output = output,
158
- milliseconds = math.floor(elapsed * 1000 + 0.5),
159
- }
160
- end
161
- return Runtime
1
+ --!strict
2
+ -- Shared bounded execution results in the VM that calls run.
3
+ local LogService = game:GetService("LogService")
4
+ local LogBuffer = require(script.Parent.LogBuffer)
5
+ local Serialize = require(script.Parent.Serialize)
6
+ -- A chunk that prints in a loop would otherwise return a response no context
7
+ -- window can hold.
8
+ local MAX_OUTPUT_LINES = 200
9
+ local MAX_RETURN_VALUES = 10
10
+
11
+ -- Returned tables are walked rather than counted, but not without limit: a
12
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
+ -- able to produce a response nothing can read.
14
+ local MAX_TABLE_ENTRIES = 50
15
+ local MAX_TABLE_DEPTH = 4
16
+
17
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
18
+ local level = depth or 0
19
+ local visited = seen or {}
20
+ local kind = typeof(value)
21
+
22
+ if kind == "function" or kind == "thread" then
23
+ return string.format("<%s>", kind)
24
+ end
25
+ if kind ~= "table" then
26
+ return Serialize.value(value)
27
+ end
28
+
29
+ local source = value :: { [any]: any }
30
+ -- Named separately from the depth cap, because they mean opposite things to
31
+ -- a reader. The depth cap says "there is more below this"; a cycle says
32
+ -- "this is the table you are already inside". Reporting the second as the
33
+ -- first printed `t.self.self.self` as three distinct nested tables, which
34
+ -- reads as real structure that does not exist.
35
+ if visited[source] then
36
+ return "<circular reference>"
37
+ end
38
+ if level >= MAX_TABLE_DEPTH then
39
+ return "<nested table>"
40
+ end
41
+ visited[source] = true
42
+
43
+ local count = 0
44
+ for _ in source do
45
+ count += 1
46
+ end
47
+
48
+ -- Arrays keep their shape so they read as lists on the other side; anything
49
+ -- with non-sequential keys becomes a map with stringified keys.
50
+ if count == #source then
51
+ local list: { any } = {}
52
+ for index, item in ipairs(source) do
53
+ if index > MAX_TABLE_ENTRIES then
54
+ table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
55
+ break
56
+ end
57
+ table.insert(list, describe(item, level + 1, visited))
58
+ end
59
+ -- Cleared on the way out so the same table appearing twice side by side
60
+ -- still renders twice; only a table containing itself is a cycle.
61
+ visited[source] = nil
62
+ return list
63
+ end
64
+
65
+ local map: { [string]: any } = {}
66
+ local shown = 0
67
+ for key, item in source do
68
+ shown += 1
69
+ if shown > MAX_TABLE_ENTRIES then
70
+ map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
71
+ break
72
+ end
73
+ map[tostring(key)] = describe(item, level + 1, visited)
74
+ end
75
+ visited[source] = nil
76
+ return map
77
+ end
78
+
79
+ local Runtime = {}
80
+ Runtime.describe = describe
81
+ function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
82
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
83
+ -- reading them back from the log afterwards would also pick up whatever else
84
+ -- the place logged in the meantime.
85
+ local output: { { [string]: any } } = {}
86
+ local connection = LogService.MessageOut:Connect(function(message, messageType, context)
87
+ if #output < MAX_OUTPUT_LINES then
88
+ -- A structured log's context rides on the line, as it does in `console`.
89
+ local encoded = LogBuffer.encodeContext(context)
90
+ if encoded then
91
+ message ..= " " .. encoded
92
+ end
93
+ table.insert(output, {
94
+ message = message,
95
+ level = if messageType == Enum.MessageType.MessageError
96
+ then "error"
97
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
98
+ elseif messageType == Enum.MessageType.MessageInfo then "info"
99
+ else "print",
100
+ })
101
+ end
102
+ end)
103
+
104
+ local started = os.clock()
105
+ local results: any = nil
106
+ if timeout then
107
+ local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
108
+ local deadline = started + timeout
109
+ while results == nil and os.clock() < deadline do task.wait() end
110
+ if results == nil then
111
+ task.cancel(worker)
112
+ results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
113
+ end
114
+ else
115
+ results = table.pack(pcall(chunk :: () -> ...any))
116
+ end
117
+ local elapsed = os.clock() - started
118
+
119
+ --[[
120
+ MessageOut is deferred: `print` returns before the event fires, so
121
+ disconnecting straight after the chunk finished captured nothing at all.
122
+ A short settle picks the lines up.
123
+
124
+ It is bounded and stops as soon as the flow stops, rather than waiting a
125
+ fixed period, so a chunk that logged nothing costs almost no time and one
126
+ that logged plenty is not truncated.
127
+ ]]
128
+ local settleDeadline = os.clock() + 0.5
129
+ local seen = #output
130
+ local quietFrames = 0
131
+ while os.clock() < settleDeadline and quietFrames < 3 do
132
+ task.wait()
133
+ if #output == seen then
134
+ quietFrames += 1
135
+ else
136
+ seen = #output
137
+ quietFrames = 0
138
+ end
139
+ end
140
+
141
+ connection:Disconnect()
142
+
143
+ if not results[1] then
144
+ return {
145
+ ok = false,
146
+ error = tostring(results[2]),
147
+ output = output,
148
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
149
+ }
150
+ end
151
+
152
+ local returned: { any } = {}
153
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
154
+ -- A nil return is written out rather than dropped. `table.insert` of nil
155
+ -- does nothing, so `return a, nil, b` came back as two values and every
156
+ -- one after the nil shifted into the wrong position.
157
+ local value = results[index]
158
+ table.insert(returned, if value == nil then "nil" else describe(value))
159
+ end
160
+
161
+ return {
162
+ ok = true,
163
+ returned = returned,
164
+ output = output,
165
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
166
+ }
167
+ end
168
+ return Runtime
@@ -35,6 +35,10 @@ local SLACK = 500
35
35
  -- handful of entries is a mismatch rather than a late delivery.
36
36
  local CORRELATION_WINDOW = 20
37
37
 
38
+ -- A structured log's context, as JSON, is cut here: Roblox's own server logs
39
+ -- drop context past 1KiB, so nothing longer is worth carrying.
40
+ local MAX_CONTEXT = 1000
41
+
38
42
  export type Entry = {
39
43
  sequence: number?,
40
44
  level: string,
@@ -43,6 +47,8 @@ export type Entry = {
43
47
  -- Present on errors once the matching stack trace has been correlated.
44
48
  stack: string?,
45
49
  source: string?,
50
+ -- The table a structured log (`LogService:Info(msg, context)`) carried, as JSON.
51
+ context: string?,
46
52
  }
47
53
 
48
54
  local LEVELS: { [number]: string } = {
@@ -153,6 +159,7 @@ function LogBuffer.pushClient(player: Player, payload: any)
153
159
  timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
154
160
  stack = if typeof(payload.stack) == "string" then string.sub(payload.stack, 1, 4000) else nil,
155
161
  source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil,
162
+ context = if typeof(payload.context) == "string" then string.sub(payload.context, 1, MAX_CONTEXT) else nil,
156
163
  }
157
164
  local waiting = buffer.orphan[entry.message]
158
165
  if level == "error" and waiting then
@@ -183,12 +190,60 @@ function LogBuffer.stream(player: Player?): ({ Entry }, number, string, number,
183
190
  return table.move(entries, 1, #entries, 1, {}), dropped, studioToken, nextSequence, LogBuffer.recordingSeconds()
184
191
  end
185
192
 
186
- local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
193
+ --[[
194
+ The context table of a structured log, as compact JSON.
195
+
196
+ `LogService:Info("{player} leveled up", { player = "Bob" })` prints only the
197
+ filled-in message; the table is the part that says which player and what
198
+ state, and MessageOut hands it over as a third argument. Plain prints pass an
199
+ empty table, which is dropped. Capped at 1000 characters -- the ceiling
200
+ Roblox's own server logs apply -- and a table JSON cannot hold (a Vector3, an
201
+ Instance) falls back to text rather than being lost.
202
+ ]]
203
+ -- JSONEncode writes a Vector3 or an Instance as `null` without complaint, so
204
+ -- anything JSON has no shape for is turned into its text first.
205
+ local function plain(value: any, depth: number): any
206
+ local kind = typeof(value)
207
+ if kind == "string" or kind == "number" or kind == "boolean" then
208
+ return value
209
+ elseif kind == "table" then
210
+ if depth >= 4 then
211
+ return "<table>"
212
+ end
213
+ local copy = {}
214
+ for key, item in value do
215
+ copy[if typeof(key) == "number" then key else tostring(key)] = plain(item, depth + 1)
216
+ end
217
+ return copy
218
+ elseif kind == "Instance" then
219
+ local ok, full = pcall(function()
220
+ return value:GetFullName()
221
+ end)
222
+ return if ok then full else value.Name
223
+ end
224
+ return tostring(value)
225
+ end
226
+
227
+ function LogBuffer.encodeContext(context: any): string?
228
+ if typeof(context) ~= "table" or next(context) == nil then
229
+ return nil
230
+ end
231
+ local ok, encoded = pcall(function()
232
+ return HttpService:JSONEncode(plain(context, 0))
233
+ end)
234
+ if not ok then
235
+ return nil
236
+ end
237
+ return string.sub(encoded :: string, 1, MAX_CONTEXT)
238
+ end
239
+
240
+ local function push(message: string, messageType: Enum.MessageType, timestamp: number?, context: any?)
187
241
  local level = LEVELS[messageType.Value] or "print"
188
242
  local entry: Entry = {
189
243
  level = level,
190
244
  message = message,
191
245
  timestamp = timestamp or os.time(),
246
+ context = LogBuffer.encodeContext(context),
192
247
  }
193
248
 
194
249
  -- Claim a trace that beat its own error line here.
@@ -261,7 +316,7 @@ function LogBuffer.start()
261
316
  end
262
317
  end
263
318
 
264
- LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
319
+ LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType, context: any?)
265
320
  local buffered = collecting
266
321
 
267
322
  if buffered then
@@ -305,7 +360,7 @@ function LogBuffer.start()
305
360
  return
306
361
  end
307
362
 
308
- push(message, messageType)
363
+ push(message, messageType, nil, context)
309
364
  end)
310
365
 
311
366
  --[[
@@ -341,6 +341,8 @@ local DESCRIBERS: { [string]: Describer } = {
341
341
  return "Run the place"
342
342
  elseif op == "multiplayer" then
343
343
  return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
344
+ elseif op == "addPlayers" then
345
+ return string.format("Add %s to the test", plural(tonumber(params.players) or 1, "player"))
344
346
  elseif op == "stop" then
345
347
  return "Stop the playtest"
346
348
  elseif op == "endTest" then
@@ -17,6 +17,7 @@ local InsertService = game:GetService("InsertService")
17
17
 
18
18
  local Dispatch = require(script.Parent.Parent.Dispatch)
19
19
  local Paths = require(script.Parent.Parent.Paths)
20
+ local Serialize = require(script.Parent.Parent.Serialize)
20
21
  local Undo = require(script.Parent.Parent.Undo)
21
22
 
22
23
  local Assets = {}
@@ -53,6 +54,17 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
53
54
  then Paths.resolve(params.parent)
54
55
  else workspace
55
56
 
57
+ -- Parsed before anything loads. A position that did not parse used to be
58
+ -- skipped in silence, and the model landed wherever it was saved.
59
+ local position: Vector3? = nil
60
+ if typeof(params.position) == "string" and params.position ~= "" then
61
+ local ok, value, why = Serialize.parse(params.position, "Vector3")
62
+ if not ok then
63
+ Dispatch.fail("BAD_PARAMS", string.format("`position` is not a Vector3: %s", tostring(why)), 'Write it as "x, y, z".')
64
+ end
65
+ position = value :: Vector3
66
+ end
67
+
56
68
  --[[
57
69
  `game:GetObjects`, not `InsertService:LoadAsset`.
58
70
 
@@ -135,10 +147,8 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
135
147
  -- nothing silently is worse than saying so.
136
148
  local remaining: { Instance } = {}
137
149
  for _, child in children do
138
- if child.Parent ~= nil or child:IsDescendantOf(game) or child.Name ~= "" then
139
- if not child:IsA("LuaSourceContainer") then
140
- table.insert(remaining, child)
141
- end
150
+ if not child:IsA("LuaSourceContainer") then
151
+ table.insert(remaining, child)
142
152
  end
143
153
  end
144
154
  children = remaining
@@ -160,20 +170,19 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
160
170
  table.insert(inserted, Paths.of(child))
161
171
  end
162
172
 
163
- -- Position after parenting, so PrimaryPart and pivot are settled.
164
- if typeof(params.position) == "string" and params.position ~= "" then
165
- local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
166
- if x then
167
- local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
168
- for _, child in children do
169
- pcall(function()
170
- if child:IsA("Model") then
171
- (child :: Model):PivotTo(target)
172
- elseif child:IsA("BasePart") then
173
- (child :: BasePart).CFrame = target
174
- end
175
- end)
176
- end
173
+ -- Position after parenting, so PrimaryPart and pivot are settled. Only
174
+ -- the position moves: the asset keeps the rotation it was saved with.
175
+ if position then
176
+ for _, child in children do
177
+ pcall(function()
178
+ if child:IsA("Model") then
179
+ local model = child :: Model
180
+ model:PivotTo(model:GetPivot().Rotation + position)
181
+ elseif child:IsA("BasePart") then
182
+ local part = child :: BasePart
183
+ part.CFrame = part.CFrame.Rotation + position
184
+ end
185
+ end)
177
186
  end
178
187
  end
179
188
  end)