@el4cteo/rbx-studio-mcp 0.6.8 → 0.7.1
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/README.md +2 -2
- package/dist/bridge/harness.js +39 -12
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/rpc.js +26 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +12 -4
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/tools/exec.js +5 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +9 -7
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/scripts.js +4 -2
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +11 -3
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Serialize.luau +10 -0
- package/plugin/src/TextEdit.luau +6 -6
- package/plugin/src/handlers/Discover.luau +690 -685
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Input.luau +43 -9
- package/plugin/src/handlers/Scripts.luau +677 -673
- package/plugin/src/handlers/Terrain.luau +371 -371
- package/plugin/src/init.server.luau +939 -883
- package/scripts/test-bridge.mjs +328 -267
- package/scripts/test-console.mjs +592 -560
- package/scripts/test-server.mjs +60 -0
|
@@ -1,371 +1,371 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Terrain: the one category of Roblox building this server could not touch.
|
|
4
|
-
|
|
5
|
-
Everything else here addresses instances by path, because everything else IS
|
|
6
|
-
an instance. Terrain is not: it is a voxel field owned by one object, with no
|
|
7
|
-
children to name and no properties to set. An agent asked for a hill had
|
|
8
|
-
exactly one route -- `execute_luau` with hand-written FillBall calls -- which
|
|
9
|
-
is the escape hatch, so it carried no validation, no undo grouping, and no
|
|
10
|
-
way to report what it had actually done.
|
|
11
|
-
|
|
12
|
-
The shapes are Roblox's own: block, ball, cylinder and wedge, plus a region
|
|
13
|
-
fill and a material swap. That is deliberately the whole surface. Voxel-level
|
|
14
|
-
writes (`WriteVoxels`, the `_beta` buffer APIs) would multiply the schema for
|
|
15
|
-
a capability an agent cannot use well -- reasoning about a raw occupancy grid
|
|
16
|
-
over a round trip is not something to encourage -- and the four solids
|
|
17
|
-
compose into everything people actually ask for.
|
|
18
|
-
|
|
19
|
-
Every write is one undo step. Terrain edits are large and destructive by
|
|
20
|
-
nature: filling a region the wrong size is not a typo you spot in a diff, it
|
|
21
|
-
is a landscape you have to get back, and Ctrl+Z is how.
|
|
22
|
-
]]
|
|
23
|
-
|
|
24
|
-
local Workspace = game:GetService("Workspace")
|
|
25
|
-
|
|
26
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
27
|
-
local Serialize = require(script.Parent.Parent.Serialize)
|
|
28
|
-
local Undo = require(script.Parent.Parent.Undo)
|
|
29
|
-
|
|
30
|
-
local Terrain = {}
|
|
31
|
-
|
|
32
|
-
--[[
|
|
33
|
-
Voxels are 4 studs, always.
|
|
34
|
-
|
|
35
|
-
`FillRegion` and `ReadVoxels` take a resolution argument, and 4 is the only
|
|
36
|
-
value the engine accepts -- anything else throws. It is a parameter for
|
|
37
|
-
forward compatibility rather than a choice, so it is not exposed: an agent
|
|
38
|
-
given the knob would spend calls discovering that.
|
|
39
|
-
]]
|
|
40
|
-
local RESOLUTION = 4
|
|
41
|
-
|
|
42
|
-
--[[
|
|
43
|
-
How many voxels one call may touch.
|
|
44
|
-
|
|
45
|
-
A region is a volume, so a request three times too large in each axis is
|
|
46
|
-
twenty-seven times the work, and Studio does not stream that -- it blocks.
|
|
47
|
-
Past this the call is refused with the number it would have written, which
|
|
48
|
-
is more useful than a Studio that stops responding for a minute.
|
|
49
|
-
]]
|
|
50
|
-
local MAX_VOXELS = 4_000_000
|
|
51
|
-
|
|
52
|
-
local function terrain(): Terrain
|
|
53
|
-
local found = Workspace:FindFirstChildOfClass("Terrain")
|
|
54
|
-
if found == nil then
|
|
55
|
-
Dispatch.fail(
|
|
56
|
-
"NO_TERRAIN",
|
|
57
|
-
"This place has no Terrain object.",
|
|
58
|
-
"Terrain is created with the place; a place saved without it cannot be filled."
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
|
-
return found :: Terrain
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
--[[
|
|
65
|
-
Reads a required value out of the request, coercing it the way every other
|
|
66
|
-
tool does.
|
|
67
|
-
|
|
68
|
-
`Serialize.parse` is the same path `create` and `modify` use, so "0, 50, 0",
|
|
69
|
-
"Enum.Material.Grass" and "Grass" all mean here exactly what they mean
|
|
70
|
-
there. A terrain tool that accepted a different spelling of Vector3 than the
|
|
71
|
-
rest of the server would be a small, permanent tax on everyone using it.
|
|
72
|
-
]]
|
|
73
|
-
local function need(params: { [string]: any }, field: string, valueType: string): any
|
|
74
|
-
local raw = params[field]
|
|
75
|
-
if raw == nil then
|
|
76
|
-
Dispatch.fail("BAD_PARAMS", string.format("`%s` is required.", field))
|
|
77
|
-
end
|
|
78
|
-
local ok, value, why = Serialize.parse(raw, valueType)
|
|
79
|
-
if not ok then
|
|
80
|
-
Dispatch.fail(
|
|
81
|
-
"BAD_PARAMS",
|
|
82
|
-
string.format("`%s` is not a %s: %s", field, valueType, tostring(why))
|
|
83
|
-
)
|
|
84
|
-
end
|
|
85
|
-
return value
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
local function optionalNumber(params: { [string]: any }, field: string, fallback: number): number
|
|
89
|
-
local raw = params[field]
|
|
90
|
-
if raw == nil then
|
|
91
|
-
return fallback
|
|
92
|
-
end
|
|
93
|
-
local value = tonumber(raw)
|
|
94
|
-
if value == nil then
|
|
95
|
-
Dispatch.fail("BAD_PARAMS", string.format("`%s` must be a number.", field))
|
|
96
|
-
end
|
|
97
|
-
return value :: number
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
--[[
|
|
101
|
-
A CFrame from a position and an optional orientation in degrees.
|
|
102
|
-
|
|
103
|
-
Terrain's fills are placed by CFrame, but an agent thinks in position plus
|
|
104
|
-
rotation the way the Properties panel presents a part -- and gets the
|
|
105
|
-
multiplication order wrong when asked to build one. Composing it here means
|
|
106
|
-
a rotated wedge is `orientation: "0, 45, 0"` rather than a CFrame expression
|
|
107
|
-
that has to be right first time.
|
|
108
|
-
]]
|
|
109
|
-
local function placement(params: { [string]: any }): CFrame
|
|
110
|
-
local position = need(params, "position", "Vector3") :: Vector3
|
|
111
|
-
local raw = params.orientation
|
|
112
|
-
if raw == nil then
|
|
113
|
-
return CFrame.new(position)
|
|
114
|
-
end
|
|
115
|
-
local ok, angles = Serialize.parse(raw, "Vector3")
|
|
116
|
-
if not ok then
|
|
117
|
-
Dispatch.fail("BAD_PARAMS", '`orientation` is three degrees, e.g. "0, 45, 0".')
|
|
118
|
-
end
|
|
119
|
-
local turn = angles :: Vector3
|
|
120
|
-
return CFrame.new(position)
|
|
121
|
-
* CFrame.Angles(math.rad(turn.X), math.rad(turn.Y), math.rad(turn.Z))
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
--[[
|
|
125
|
-
Refuses a fill big enough to hang Studio, and says how big it was.
|
|
126
|
-
|
|
127
|
-
The number matters more than the refusal. "Too large" invites a retry at a
|
|
128
|
-
guess; the voxel count against the cap tells the caller how much to shrink
|
|
129
|
-
it, and is usually the moment they notice they wrote studs where they meant
|
|
130
|
-
something smaller.
|
|
131
|
-
]]
|
|
132
|
-
local function guard(size: Vector3, what: string)
|
|
133
|
-
local voxels = math.ceil(size.X / RESOLUTION)
|
|
134
|
-
* math.ceil(size.Y / RESOLUTION)
|
|
135
|
-
* math.ceil(size.Z / RESOLUTION)
|
|
136
|
-
if voxels > MAX_VOXELS then
|
|
137
|
-
Dispatch.fail(
|
|
138
|
-
"REGION_TOO_LARGE",
|
|
139
|
-
string.format(
|
|
140
|
-
"%s covers about %d voxels; the limit is %d.",
|
|
141
|
-
what,
|
|
142
|
-
voxels,
|
|
143
|
-
MAX_VOXELS
|
|
144
|
-
),
|
|
145
|
-
"Fill it in smaller pieces, or use a coarser shape."
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
return voxels
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
local function material(params: { [string]: any }, field: string, fallback: Enum.Material?): Enum.Material
|
|
152
|
-
local raw = params[field]
|
|
153
|
-
if raw == nil then
|
|
154
|
-
if fallback ~= nil then
|
|
155
|
-
return fallback
|
|
156
|
-
end
|
|
157
|
-
Dispatch.fail("BAD_PARAMS", string.format("`%s` is required.", field))
|
|
158
|
-
end
|
|
159
|
-
local ok, value, why = Serialize.parse(raw, "Material")
|
|
160
|
-
if not ok then
|
|
161
|
-
Dispatch.fail(
|
|
162
|
-
"BAD_PARAMS",
|
|
163
|
-
string.format("`%s` is not a material: %s", field, tostring(why)),
|
|
164
|
-
"Try Grass, Rock, Sand, Water, Snow, Basalt, or Air to carve."
|
|
165
|
-
)
|
|
166
|
-
end
|
|
167
|
-
return value :: Enum.Material
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
--[[
|
|
171
|
-
Fills one or more solids, as a single undo step.
|
|
172
|
-
|
|
173
|
-
Batched for the same reason `create` is: terrain is built out of overlapping
|
|
174
|
-
primitives -- a hill is several balls, a road is a row of blocks -- and one
|
|
175
|
-
Ctrl+Z should take back the thing that was made rather than its last
|
|
176
|
-
component.
|
|
177
|
-
]]
|
|
178
|
-
function Terrain.fill(params: { [string]: any }): { [string]: any }
|
|
179
|
-
local shapes = params.shapes
|
|
180
|
-
if typeof(shapes) ~= "table" or #shapes == 0 then
|
|
181
|
-
Dispatch.fail("BAD_PARAMS", "`shapes` must be a non-empty array.")
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
local field = terrain()
|
|
185
|
-
local filled: { { [string]: any } } = {}
|
|
186
|
-
local total = 0
|
|
187
|
-
|
|
188
|
-
local _, recorded = Undo.record("StudioMCP.TerrainFill", "MCP terrain fill", function()
|
|
189
|
-
for index, shape in shapes :: { any } do
|
|
190
|
-
if typeof(shape) ~= "table" then
|
|
191
|
-
Dispatch.fail("BAD_PARAMS", string.format("shapes[%d] must be an object.", index))
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
local kind = string.lower(tostring(shape.shape or "block"))
|
|
195
|
-
-- Air is a material, not a special case: carving a cave is filling
|
|
196
|
-
-- with Air, and letting it through the ordinary path means `fill`
|
|
197
|
-
-- both builds and digs with one schema.
|
|
198
|
-
local chosen = material(shape, "material", Enum.Material.Grass)
|
|
199
|
-
local at = placement(shape)
|
|
200
|
-
local voxels
|
|
201
|
-
|
|
202
|
-
if kind == "block" then
|
|
203
|
-
local size = need(shape, "size", "Vector3") :: Vector3
|
|
204
|
-
voxels = guard(size, string.format("shapes[%d]", index))
|
|
205
|
-
field:FillBlock(at, size, chosen)
|
|
206
|
-
elseif kind == "ball" then
|
|
207
|
-
local radius = optionalNumber(shape, "radius", 0)
|
|
208
|
-
if radius <= 0 then
|
|
209
|
-
Dispatch.fail("BAD_PARAMS", string.format("shapes[%d] needs a `radius`.", index))
|
|
210
|
-
end
|
|
211
|
-
voxels = guard(Vector3.new(radius, radius, radius) * 2, string.format("shapes[%d]", index))
|
|
212
|
-
field:FillBall(at.Position, radius, chosen)
|
|
213
|
-
elseif kind == "cylinder" then
|
|
214
|
-
local radius = optionalNumber(shape, "radius", 0)
|
|
215
|
-
local height = optionalNumber(shape, "height", 0)
|
|
216
|
-
if radius <= 0 or height <= 0 then
|
|
217
|
-
Dispatch.fail(
|
|
218
|
-
"BAD_PARAMS",
|
|
219
|
-
string.format("shapes[%d] needs a `radius` and a `height`.", index)
|
|
220
|
-
)
|
|
221
|
-
end
|
|
222
|
-
voxels = guard(Vector3.new(radius * 2, height, radius * 2), string.format("shapes[%d]", index))
|
|
223
|
-
field:FillCylinder(at, height, radius, chosen)
|
|
224
|
-
elseif kind == "wedge" then
|
|
225
|
-
local size = need(shape, "size", "Vector3") :: Vector3
|
|
226
|
-
voxels = guard(size, string.format("shapes[%d]", index))
|
|
227
|
-
field:FillWedge(at, size, chosen)
|
|
228
|
-
else
|
|
229
|
-
Dispatch.fail(
|
|
230
|
-
"BAD_PARAMS",
|
|
231
|
-
string.format("shapes[%d]: unknown shape %q.", index, kind),
|
|
232
|
-
"Use block, ball, cylinder or wedge."
|
|
233
|
-
)
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
total += voxels
|
|
237
|
-
table.insert(filled, {
|
|
238
|
-
shape = kind,
|
|
239
|
-
material = chosen.Name,
|
|
240
|
-
position = Serialize.value(at.Position),
|
|
241
|
-
voxels = voxels,
|
|
242
|
-
})
|
|
243
|
-
end
|
|
244
|
-
end)
|
|
245
|
-
|
|
246
|
-
return { filled = filled, voxels = total, undoable = recorded }
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
--[[
|
|
250
|
-
Swaps one material for another inside a region, leaving the shape alone.
|
|
251
|
-
|
|
252
|
-
Distinct from `fill` because it is the common edit that a fill cannot
|
|
253
|
-
express: repainting the grass on an existing hill to snow without touching
|
|
254
|
-
the hill. Doing it with fills means reconstructing the terrain you already
|
|
255
|
-
have, which nobody gets exactly right.
|
|
256
|
-
]]
|
|
257
|
-
function Terrain.replace(params: { [string]: any }): { [string]: any }
|
|
258
|
-
local field = terrain()
|
|
259
|
-
local centre = need(params, "position", "Vector3") :: Vector3
|
|
260
|
-
local size = need(params, "size", "Vector3") :: Vector3
|
|
261
|
-
guard(size, "the region")
|
|
262
|
-
|
|
263
|
-
local from = material(params, "from", nil)
|
|
264
|
-
local to = material(params, "to", nil)
|
|
265
|
-
|
|
266
|
-
local region = Region3.new(centre - size / 2, centre + size / 2):ExpandToGrid(RESOLUTION)
|
|
267
|
-
|
|
268
|
-
local _, recorded = Undo.record("StudioMCP.TerrainReplace", "MCP terrain replace", function()
|
|
269
|
-
field:ReplaceMaterial(region, RESOLUTION, from, to)
|
|
270
|
-
end)
|
|
271
|
-
|
|
272
|
-
return {
|
|
273
|
-
from = from.Name,
|
|
274
|
-
to = to.Name,
|
|
275
|
-
position = Serialize.value(centre),
|
|
276
|
-
size = Serialize.value(size),
|
|
277
|
-
undoable = recorded,
|
|
278
|
-
}
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
--[[
|
|
282
|
-
Empties the terrain, or one region of it.
|
|
283
|
-
|
|
284
|
-
Whole-terrain clears are guarded behind an explicit `confirm`, because this
|
|
285
|
-
is the only call in the server that can destroy an afternoon's landscaping
|
|
286
|
-
with no path back except an undo the agent may not think to offer. Costing a
|
|
287
|
-
second round trip is the correct price.
|
|
288
|
-
]]
|
|
289
|
-
function Terrain.clear(params: { [string]: any }): { [string]: any }
|
|
290
|
-
local field = terrain()
|
|
291
|
-
|
|
292
|
-
if params.position ~= nil or params.size ~= nil then
|
|
293
|
-
local centre = need(params, "position", "Vector3") :: Vector3
|
|
294
|
-
local size = need(params, "size", "Vector3") :: Vector3
|
|
295
|
-
guard(size, "the region")
|
|
296
|
-
local region = Region3.new(centre - size / 2, centre + size / 2):ExpandToGrid(RESOLUTION)
|
|
297
|
-
local _, recorded = Undo.record("StudioMCP.TerrainClear", "MCP terrain clear", function()
|
|
298
|
-
field:FillRegion(region, RESOLUTION, Enum.Material.Air)
|
|
299
|
-
end)
|
|
300
|
-
return { cleared = "region", undoable = recorded }
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
if params.confirm ~= true then
|
|
304
|
-
Dispatch.fail(
|
|
305
|
-
"CONFIRM_REQUIRED",
|
|
306
|
-
"Clearing all terrain cannot be undone by anything but Ctrl+Z.",
|
|
307
|
-
'Pass confirm=true to do it, or give `position` and `size` to clear one region.'
|
|
308
|
-
)
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
local before = field:CountCells()
|
|
312
|
-
local _, recorded = Undo.record("StudioMCP.TerrainClear", "MCP terrain clear", function()
|
|
313
|
-
field:Clear()
|
|
314
|
-
end)
|
|
315
|
-
return { cleared = "all", cellsBefore = before, undoable = recorded }
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
--[[
|
|
319
|
-
What is there, without reading a voxel.
|
|
320
|
-
|
|
321
|
-
`CountCells` is the cheap answer to "does this place even use terrain",
|
|
322
|
-
which is the question worth asking before any of the above. The bounding box
|
|
323
|
-
tells an agent where to aim without guessing at the origin.
|
|
324
|
-
]]
|
|
325
|
-
function Terrain.stats(_params: { [string]: any }): { [string]: any }
|
|
326
|
-
local field = terrain()
|
|
327
|
-
|
|
328
|
-
--[[
|
|
329
|
-
Formatted here rather than through `Serialize.value`.
|
|
330
|
-
|
|
331
|
-
`MaxExtents` is a Region3int16 and its corners are Vector3int16, which is
|
|
332
|
-
a type `Serialize.value` has no case for -- it would have fallen through
|
|
333
|
-
to the generic tail and reported whatever `tostring` makes of it. The
|
|
334
|
-
extents are also in VOXELS, not studs, which is the sort of unit
|
|
335
|
-
confusion that produces a fill a thousand studs from where it was meant,
|
|
336
|
-
so they are converted and named for what they are.
|
|
337
|
-
]]
|
|
338
|
-
--[[
|
|
339
|
-
`MaxExtents` is deliberately not reported.
|
|
340
|
-
|
|
341
|
-
It reads like the bounding box of the terrain that exists, and it is
|
|
342
|
-
not: it is the fixed limit of where terrain is allowed to go, and it
|
|
343
|
-
answers -32000, -32000, -32000 to 32000, 32000, 32000 on an empty place
|
|
344
|
-
and on a full one alike. Reporting it as "where the terrain sits" was a
|
|
345
|
-
misreading of the name -- checked against a place with 2220 cells in a
|
|
346
|
-
160-stud patch, which returned exactly the same numbers as one with
|
|
347
|
-
none.
|
|
348
|
-
|
|
349
|
-
There is no engine call for the occupied box, and finding it means
|
|
350
|
-
reading every voxel in the world, which is far too expensive for a
|
|
351
|
-
question meant to be cheap. `cells` already answers the one that
|
|
352
|
-
matters: whether this place uses terrain at all.
|
|
353
|
-
]]
|
|
354
|
-
return {
|
|
355
|
-
cells = field:CountCells(),
|
|
356
|
-
waterColor = Serialize.value(field.WaterColor),
|
|
357
|
-
waterWaveSize = field.WaterWaveSize,
|
|
358
|
-
limitStuds = 32000,
|
|
359
|
-
}
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
function Terrain.register()
|
|
363
|
-
Dispatch.registerAll("terrain", {
|
|
364
|
-
fill = Terrain.fill,
|
|
365
|
-
replace = Terrain.replace,
|
|
366
|
-
clear = Terrain.clear,
|
|
367
|
-
stats = Terrain.stats,
|
|
368
|
-
})
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
return Terrain
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Terrain: the one category of Roblox building this server could not touch.
|
|
4
|
+
|
|
5
|
+
Everything else here addresses instances by path, because everything else IS
|
|
6
|
+
an instance. Terrain is not: it is a voxel field owned by one object, with no
|
|
7
|
+
children to name and no properties to set. An agent asked for a hill had
|
|
8
|
+
exactly one route -- `execute_luau` with hand-written FillBall calls -- which
|
|
9
|
+
is the escape hatch, so it carried no validation, no undo grouping, and no
|
|
10
|
+
way to report what it had actually done.
|
|
11
|
+
|
|
12
|
+
The shapes are Roblox's own: block, ball, cylinder and wedge, plus a region
|
|
13
|
+
fill and a material swap. That is deliberately the whole surface. Voxel-level
|
|
14
|
+
writes (`WriteVoxels`, the `_beta` buffer APIs) would multiply the schema for
|
|
15
|
+
a capability an agent cannot use well -- reasoning about a raw occupancy grid
|
|
16
|
+
over a round trip is not something to encourage -- and the four solids
|
|
17
|
+
compose into everything people actually ask for.
|
|
18
|
+
|
|
19
|
+
Every write is one undo step. Terrain edits are large and destructive by
|
|
20
|
+
nature: filling a region the wrong size is not a typo you spot in a diff, it
|
|
21
|
+
is a landscape you have to get back, and Ctrl+Z is how.
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local Workspace = game:GetService("Workspace")
|
|
25
|
+
|
|
26
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
27
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
28
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
29
|
+
|
|
30
|
+
local Terrain = {}
|
|
31
|
+
|
|
32
|
+
--[[
|
|
33
|
+
Voxels are 4 studs, always.
|
|
34
|
+
|
|
35
|
+
`FillRegion` and `ReadVoxels` take a resolution argument, and 4 is the only
|
|
36
|
+
value the engine accepts -- anything else throws. It is a parameter for
|
|
37
|
+
forward compatibility rather than a choice, so it is not exposed: an agent
|
|
38
|
+
given the knob would spend calls discovering that.
|
|
39
|
+
]]
|
|
40
|
+
local RESOLUTION = 4
|
|
41
|
+
|
|
42
|
+
--[[
|
|
43
|
+
How many voxels one call may touch.
|
|
44
|
+
|
|
45
|
+
A region is a volume, so a request three times too large in each axis is
|
|
46
|
+
twenty-seven times the work, and Studio does not stream that -- it blocks.
|
|
47
|
+
Past this the call is refused with the number it would have written, which
|
|
48
|
+
is more useful than a Studio that stops responding for a minute.
|
|
49
|
+
]]
|
|
50
|
+
local MAX_VOXELS = 4_000_000
|
|
51
|
+
|
|
52
|
+
local function terrain(): Terrain
|
|
53
|
+
local found = Workspace:FindFirstChildOfClass("Terrain")
|
|
54
|
+
if found == nil then
|
|
55
|
+
Dispatch.fail(
|
|
56
|
+
"NO_TERRAIN",
|
|
57
|
+
"This place has no Terrain object.",
|
|
58
|
+
"Terrain is created with the place; a place saved without it cannot be filled."
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
return found :: Terrain
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
--[[
|
|
65
|
+
Reads a required value out of the request, coercing it the way every other
|
|
66
|
+
tool does.
|
|
67
|
+
|
|
68
|
+
`Serialize.parse` is the same path `create` and `modify` use, so "0, 50, 0",
|
|
69
|
+
"Enum.Material.Grass" and "Grass" all mean here exactly what they mean
|
|
70
|
+
there. A terrain tool that accepted a different spelling of Vector3 than the
|
|
71
|
+
rest of the server would be a small, permanent tax on everyone using it.
|
|
72
|
+
]]
|
|
73
|
+
local function need(params: { [string]: any }, field: string, valueType: string): any
|
|
74
|
+
local raw = params[field]
|
|
75
|
+
if raw == nil then
|
|
76
|
+
Dispatch.fail("BAD_PARAMS", string.format("`%s` is required.", field))
|
|
77
|
+
end
|
|
78
|
+
local ok, value, why = Serialize.parse(raw, valueType)
|
|
79
|
+
if not ok then
|
|
80
|
+
Dispatch.fail(
|
|
81
|
+
"BAD_PARAMS",
|
|
82
|
+
string.format("`%s` is not a %s: %s", field, valueType, tostring(why))
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
return value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
local function optionalNumber(params: { [string]: any }, field: string, fallback: number): number
|
|
89
|
+
local raw = params[field]
|
|
90
|
+
if raw == nil then
|
|
91
|
+
return fallback
|
|
92
|
+
end
|
|
93
|
+
local value = tonumber(raw)
|
|
94
|
+
if value == nil then
|
|
95
|
+
Dispatch.fail("BAD_PARAMS", string.format("`%s` must be a number.", field))
|
|
96
|
+
end
|
|
97
|
+
return value :: number
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
--[[
|
|
101
|
+
A CFrame from a position and an optional orientation in degrees.
|
|
102
|
+
|
|
103
|
+
Terrain's fills are placed by CFrame, but an agent thinks in position plus
|
|
104
|
+
rotation the way the Properties panel presents a part -- and gets the
|
|
105
|
+
multiplication order wrong when asked to build one. Composing it here means
|
|
106
|
+
a rotated wedge is `orientation: "0, 45, 0"` rather than a CFrame expression
|
|
107
|
+
that has to be right first time.
|
|
108
|
+
]]
|
|
109
|
+
local function placement(params: { [string]: any }): CFrame
|
|
110
|
+
local position = need(params, "position", "Vector3") :: Vector3
|
|
111
|
+
local raw = params.orientation
|
|
112
|
+
if raw == nil then
|
|
113
|
+
return CFrame.new(position)
|
|
114
|
+
end
|
|
115
|
+
local ok, angles = Serialize.parse(raw, "Vector3")
|
|
116
|
+
if not ok then
|
|
117
|
+
Dispatch.fail("BAD_PARAMS", '`orientation` is three degrees, e.g. "0, 45, 0".')
|
|
118
|
+
end
|
|
119
|
+
local turn = angles :: Vector3
|
|
120
|
+
return CFrame.new(position)
|
|
121
|
+
* CFrame.Angles(math.rad(turn.X), math.rad(turn.Y), math.rad(turn.Z))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
--[[
|
|
125
|
+
Refuses a fill big enough to hang Studio, and says how big it was.
|
|
126
|
+
|
|
127
|
+
The number matters more than the refusal. "Too large" invites a retry at a
|
|
128
|
+
guess; the voxel count against the cap tells the caller how much to shrink
|
|
129
|
+
it, and is usually the moment they notice they wrote studs where they meant
|
|
130
|
+
something smaller.
|
|
131
|
+
]]
|
|
132
|
+
local function guard(size: Vector3, what: string)
|
|
133
|
+
local voxels = math.ceil(size.X / RESOLUTION)
|
|
134
|
+
* math.ceil(size.Y / RESOLUTION)
|
|
135
|
+
* math.ceil(size.Z / RESOLUTION)
|
|
136
|
+
if voxels > MAX_VOXELS then
|
|
137
|
+
Dispatch.fail(
|
|
138
|
+
"REGION_TOO_LARGE",
|
|
139
|
+
string.format(
|
|
140
|
+
"%s covers about %d voxels; the limit is %d.",
|
|
141
|
+
what,
|
|
142
|
+
voxels,
|
|
143
|
+
MAX_VOXELS
|
|
144
|
+
),
|
|
145
|
+
"Fill it in smaller pieces, or use a coarser shape."
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
return voxels
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
local function material(params: { [string]: any }, field: string, fallback: Enum.Material?): Enum.Material
|
|
152
|
+
local raw = params[field]
|
|
153
|
+
if raw == nil then
|
|
154
|
+
if fallback ~= nil then
|
|
155
|
+
return fallback
|
|
156
|
+
end
|
|
157
|
+
Dispatch.fail("BAD_PARAMS", string.format("`%s` is required.", field))
|
|
158
|
+
end
|
|
159
|
+
local ok, value, why = Serialize.parse(raw, "Material")
|
|
160
|
+
if not ok then
|
|
161
|
+
Dispatch.fail(
|
|
162
|
+
"BAD_PARAMS",
|
|
163
|
+
string.format("`%s` is not a material: %s", field, tostring(why)),
|
|
164
|
+
"Try Grass, Rock, Sand, Water, Snow, Basalt, or Air to carve."
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
return value :: Enum.Material
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
--[[
|
|
171
|
+
Fills one or more solids, as a single undo step.
|
|
172
|
+
|
|
173
|
+
Batched for the same reason `create` is: terrain is built out of overlapping
|
|
174
|
+
primitives -- a hill is several balls, a road is a row of blocks -- and one
|
|
175
|
+
Ctrl+Z should take back the thing that was made rather than its last
|
|
176
|
+
component.
|
|
177
|
+
]]
|
|
178
|
+
function Terrain.fill(params: { [string]: any }): { [string]: any }
|
|
179
|
+
local shapes = params.shapes
|
|
180
|
+
if typeof(shapes) ~= "table" or #shapes == 0 then
|
|
181
|
+
Dispatch.fail("BAD_PARAMS", "`shapes` must be a non-empty array.")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
local field = terrain()
|
|
185
|
+
local filled: { { [string]: any } } = {}
|
|
186
|
+
local total = 0
|
|
187
|
+
|
|
188
|
+
local _, recorded = Undo.record("StudioMCP.TerrainFill", "MCP terrain fill", function()
|
|
189
|
+
for index, shape in shapes :: { any } do
|
|
190
|
+
if typeof(shape) ~= "table" then
|
|
191
|
+
Dispatch.fail("BAD_PARAMS", string.format("shapes[%d] must be an object.", index - 1))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
local kind = string.lower(tostring(shape.shape or "block"))
|
|
195
|
+
-- Air is a material, not a special case: carving a cave is filling
|
|
196
|
+
-- with Air, and letting it through the ordinary path means `fill`
|
|
197
|
+
-- both builds and digs with one schema.
|
|
198
|
+
local chosen = material(shape, "material", Enum.Material.Grass)
|
|
199
|
+
local at = placement(shape)
|
|
200
|
+
local voxels
|
|
201
|
+
|
|
202
|
+
if kind == "block" then
|
|
203
|
+
local size = need(shape, "size", "Vector3") :: Vector3
|
|
204
|
+
voxels = guard(size, string.format("shapes[%d]", index - 1))
|
|
205
|
+
field:FillBlock(at, size, chosen)
|
|
206
|
+
elseif kind == "ball" then
|
|
207
|
+
local radius = optionalNumber(shape, "radius", 0)
|
|
208
|
+
if radius <= 0 then
|
|
209
|
+
Dispatch.fail("BAD_PARAMS", string.format("shapes[%d] needs a `radius`.", index - 1))
|
|
210
|
+
end
|
|
211
|
+
voxels = guard(Vector3.new(radius, radius, radius) * 2, string.format("shapes[%d]", index - 1))
|
|
212
|
+
field:FillBall(at.Position, radius, chosen)
|
|
213
|
+
elseif kind == "cylinder" then
|
|
214
|
+
local radius = optionalNumber(shape, "radius", 0)
|
|
215
|
+
local height = optionalNumber(shape, "height", 0)
|
|
216
|
+
if radius <= 0 or height <= 0 then
|
|
217
|
+
Dispatch.fail(
|
|
218
|
+
"BAD_PARAMS",
|
|
219
|
+
string.format("shapes[%d] needs a `radius` and a `height`.", index - 1)
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
voxels = guard(Vector3.new(radius * 2, height, radius * 2), string.format("shapes[%d]", index - 1))
|
|
223
|
+
field:FillCylinder(at, height, radius, chosen)
|
|
224
|
+
elseif kind == "wedge" then
|
|
225
|
+
local size = need(shape, "size", "Vector3") :: Vector3
|
|
226
|
+
voxels = guard(size, string.format("shapes[%d]", index - 1))
|
|
227
|
+
field:FillWedge(at, size, chosen)
|
|
228
|
+
else
|
|
229
|
+
Dispatch.fail(
|
|
230
|
+
"BAD_PARAMS",
|
|
231
|
+
string.format("shapes[%d]: unknown shape %q.", index - 1, kind),
|
|
232
|
+
"Use block, ball, cylinder or wedge."
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
total += voxels
|
|
237
|
+
table.insert(filled, {
|
|
238
|
+
shape = kind,
|
|
239
|
+
material = chosen.Name,
|
|
240
|
+
position = Serialize.value(at.Position),
|
|
241
|
+
voxels = voxels,
|
|
242
|
+
})
|
|
243
|
+
end
|
|
244
|
+
end)
|
|
245
|
+
|
|
246
|
+
return { filled = filled, voxels = total, undoable = recorded }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
--[[
|
|
250
|
+
Swaps one material for another inside a region, leaving the shape alone.
|
|
251
|
+
|
|
252
|
+
Distinct from `fill` because it is the common edit that a fill cannot
|
|
253
|
+
express: repainting the grass on an existing hill to snow without touching
|
|
254
|
+
the hill. Doing it with fills means reconstructing the terrain you already
|
|
255
|
+
have, which nobody gets exactly right.
|
|
256
|
+
]]
|
|
257
|
+
function Terrain.replace(params: { [string]: any }): { [string]: any }
|
|
258
|
+
local field = terrain()
|
|
259
|
+
local centre = need(params, "position", "Vector3") :: Vector3
|
|
260
|
+
local size = need(params, "size", "Vector3") :: Vector3
|
|
261
|
+
guard(size, "the region")
|
|
262
|
+
|
|
263
|
+
local from = material(params, "from", nil)
|
|
264
|
+
local to = material(params, "to", nil)
|
|
265
|
+
|
|
266
|
+
local region = Region3.new(centre - size / 2, centre + size / 2):ExpandToGrid(RESOLUTION)
|
|
267
|
+
|
|
268
|
+
local _, recorded = Undo.record("StudioMCP.TerrainReplace", "MCP terrain replace", function()
|
|
269
|
+
field:ReplaceMaterial(region, RESOLUTION, from, to)
|
|
270
|
+
end)
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
from = from.Name,
|
|
274
|
+
to = to.Name,
|
|
275
|
+
position = Serialize.value(centre),
|
|
276
|
+
size = Serialize.value(size),
|
|
277
|
+
undoable = recorded,
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
--[[
|
|
282
|
+
Empties the terrain, or one region of it.
|
|
283
|
+
|
|
284
|
+
Whole-terrain clears are guarded behind an explicit `confirm`, because this
|
|
285
|
+
is the only call in the server that can destroy an afternoon's landscaping
|
|
286
|
+
with no path back except an undo the agent may not think to offer. Costing a
|
|
287
|
+
second round trip is the correct price.
|
|
288
|
+
]]
|
|
289
|
+
function Terrain.clear(params: { [string]: any }): { [string]: any }
|
|
290
|
+
local field = terrain()
|
|
291
|
+
|
|
292
|
+
if params.position ~= nil or params.size ~= nil then
|
|
293
|
+
local centre = need(params, "position", "Vector3") :: Vector3
|
|
294
|
+
local size = need(params, "size", "Vector3") :: Vector3
|
|
295
|
+
guard(size, "the region")
|
|
296
|
+
local region = Region3.new(centre - size / 2, centre + size / 2):ExpandToGrid(RESOLUTION)
|
|
297
|
+
local _, recorded = Undo.record("StudioMCP.TerrainClear", "MCP terrain clear", function()
|
|
298
|
+
field:FillRegion(region, RESOLUTION, Enum.Material.Air)
|
|
299
|
+
end)
|
|
300
|
+
return { cleared = "region", undoable = recorded }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
if params.confirm ~= true then
|
|
304
|
+
Dispatch.fail(
|
|
305
|
+
"CONFIRM_REQUIRED",
|
|
306
|
+
"Clearing all terrain cannot be undone by anything but Ctrl+Z.",
|
|
307
|
+
'Pass confirm=true to do it, or give `position` and `size` to clear one region.'
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
local before = field:CountCells()
|
|
312
|
+
local _, recorded = Undo.record("StudioMCP.TerrainClear", "MCP terrain clear", function()
|
|
313
|
+
field:Clear()
|
|
314
|
+
end)
|
|
315
|
+
return { cleared = "all", cellsBefore = before, undoable = recorded }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
--[[
|
|
319
|
+
What is there, without reading a voxel.
|
|
320
|
+
|
|
321
|
+
`CountCells` is the cheap answer to "does this place even use terrain",
|
|
322
|
+
which is the question worth asking before any of the above. The bounding box
|
|
323
|
+
tells an agent where to aim without guessing at the origin.
|
|
324
|
+
]]
|
|
325
|
+
function Terrain.stats(_params: { [string]: any }): { [string]: any }
|
|
326
|
+
local field = terrain()
|
|
327
|
+
|
|
328
|
+
--[[
|
|
329
|
+
Formatted here rather than through `Serialize.value`.
|
|
330
|
+
|
|
331
|
+
`MaxExtents` is a Region3int16 and its corners are Vector3int16, which is
|
|
332
|
+
a type `Serialize.value` has no case for -- it would have fallen through
|
|
333
|
+
to the generic tail and reported whatever `tostring` makes of it. The
|
|
334
|
+
extents are also in VOXELS, not studs, which is the sort of unit
|
|
335
|
+
confusion that produces a fill a thousand studs from where it was meant,
|
|
336
|
+
so they are converted and named for what they are.
|
|
337
|
+
]]
|
|
338
|
+
--[[
|
|
339
|
+
`MaxExtents` is deliberately not reported.
|
|
340
|
+
|
|
341
|
+
It reads like the bounding box of the terrain that exists, and it is
|
|
342
|
+
not: it is the fixed limit of where terrain is allowed to go, and it
|
|
343
|
+
answers -32000, -32000, -32000 to 32000, 32000, 32000 on an empty place
|
|
344
|
+
and on a full one alike. Reporting it as "where the terrain sits" was a
|
|
345
|
+
misreading of the name -- checked against a place with 2220 cells in a
|
|
346
|
+
160-stud patch, which returned exactly the same numbers as one with
|
|
347
|
+
none.
|
|
348
|
+
|
|
349
|
+
There is no engine call for the occupied box, and finding it means
|
|
350
|
+
reading every voxel in the world, which is far too expensive for a
|
|
351
|
+
question meant to be cheap. `cells` already answers the one that
|
|
352
|
+
matters: whether this place uses terrain at all.
|
|
353
|
+
]]
|
|
354
|
+
return {
|
|
355
|
+
cells = field:CountCells(),
|
|
356
|
+
waterColor = Serialize.value(field.WaterColor),
|
|
357
|
+
waterWaveSize = field.WaterWaveSize,
|
|
358
|
+
limitStuds = 32000,
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
function Terrain.register()
|
|
363
|
+
Dispatch.registerAll("terrain", {
|
|
364
|
+
fill = Terrain.fill,
|
|
365
|
+
replace = Terrain.replace,
|
|
366
|
+
clear = Terrain.clear,
|
|
367
|
+
stats = Terrain.stats,
|
|
368
|
+
})
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
return Terrain
|