@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.
@@ -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