@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.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/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -1,361 +1,823 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Driving the player character during a playtest.
|
|
4
|
-
|
|
5
|
-
This exists because the obvious approach is closed. `VirtualInputManager`
|
|
6
|
-
wants the RobloxScript capability and `VirtualUser` wants LocalUser, and both
|
|
7
|
-
refuse a plugin outright -- measured, not assumed:
|
|
8
|
-
|
|
9
|
-
The current thread cannot call 'SendKeyEvent' (lacking capability RobloxScript)
|
|
10
|
-
The current thread cannot call 'SetKeyDown' (lacking capability LocalUser)
|
|
11
|
-
|
|
12
|
-
Roblox's own MCP server simulates input because it is first-party and runs
|
|
13
|
-
with privileges a plugin is never granted. So this drives the Humanoid
|
|
14
|
-
directly instead.
|
|
15
|
-
|
|
16
|
-
That turns out to be the better instrument anyway. Synthetic keystrokes test
|
|
17
|
-
the input stack; what anyone actually wants to know is whether the character
|
|
18
|
-
can reach the door, whether the trap fires, whether the checkpoint saves --
|
|
19
|
-
all of which are answered more precisely by asking the Humanoid to walk there
|
|
20
|
-
and reporting whether it arrived.
|
|
21
|
-
|
|
22
|
-
Pathfinding is what makes "go there" mean something. `Humanoid:MoveTo` walks
|
|
23
|
-
in a straight line and stops dead against the first wall; a computed path
|
|
24
|
-
routes around geometry, which is the difference between testing a route and
|
|
25
|
-
testing a collision.
|
|
26
|
-
|
|
27
|
-
Everything here needs a running playtest, and the character only exists in
|
|
28
|
-
the playtest's data model, so these must be addressed to that session.
|
|
29
|
-
]]
|
|
30
|
-
|
|
31
|
-
local PathfindingService = game:GetService("PathfindingService")
|
|
32
|
-
local
|
|
33
|
-
local
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
local
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
--
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
--
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
--[[
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
local
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
local
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Driving the player character during a playtest.
|
|
4
|
+
|
|
5
|
+
This exists because the obvious approach is closed. `VirtualInputManager`
|
|
6
|
+
wants the RobloxScript capability and `VirtualUser` wants LocalUser, and both
|
|
7
|
+
refuse a plugin outright -- measured, not assumed:
|
|
8
|
+
|
|
9
|
+
The current thread cannot call 'SendKeyEvent' (lacking capability RobloxScript)
|
|
10
|
+
The current thread cannot call 'SetKeyDown' (lacking capability LocalUser)
|
|
11
|
+
|
|
12
|
+
Roblox's own MCP server simulates input because it is first-party and runs
|
|
13
|
+
with privileges a plugin is never granted. So this drives the Humanoid
|
|
14
|
+
directly instead.
|
|
15
|
+
|
|
16
|
+
That turns out to be the better instrument anyway. Synthetic keystrokes test
|
|
17
|
+
the input stack; what anyone actually wants to know is whether the character
|
|
18
|
+
can reach the door, whether the trap fires, whether the checkpoint saves --
|
|
19
|
+
all of which are answered more precisely by asking the Humanoid to walk there
|
|
20
|
+
and reporting whether it arrived.
|
|
21
|
+
|
|
22
|
+
Pathfinding is what makes "go there" mean something. `Humanoid:MoveTo` walks
|
|
23
|
+
in a straight line and stops dead against the first wall; a computed path
|
|
24
|
+
routes around geometry, which is the difference between testing a route and
|
|
25
|
+
testing a collision.
|
|
26
|
+
|
|
27
|
+
Everything here needs a running playtest, and the character only exists in
|
|
28
|
+
the playtest's data model, so these must be addressed to that session.
|
|
29
|
+
]]
|
|
30
|
+
|
|
31
|
+
local PathfindingService = game:GetService("PathfindingService")
|
|
32
|
+
local Workspace = game:GetService("Workspace")
|
|
33
|
+
local Players = game:GetService("Players")
|
|
34
|
+
local RunService = game:GetService("RunService")
|
|
35
|
+
|
|
36
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
37
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
38
|
+
|
|
39
|
+
local Character = {}
|
|
40
|
+
|
|
41
|
+
-- How long to wait for a walk before calling it stuck. Long enough to cross a
|
|
42
|
+
-- large room, short enough that a failed route does not hold the request open.
|
|
43
|
+
local MOVE_TIMEOUT = 30
|
|
44
|
+
|
|
45
|
+
-- How close counts as arrived. `MoveToFinished` fires on its own timeout as
|
|
46
|
+
-- well as on arrival, so distance is what actually decides success.
|
|
47
|
+
local ARRIVAL_RADIUS = 6
|
|
48
|
+
|
|
49
|
+
--[[
|
|
50
|
+
How close counts as reaching one waypoint along the way.
|
|
51
|
+
|
|
52
|
+
Tighter than ARRIVAL_RADIUS, which judges the whole journey. Waypoints are
|
|
53
|
+
four studs apart by default, so a six-stud tolerance would count the next one
|
|
54
|
+
as already reached and the character would skip through the route without
|
|
55
|
+
walking it.
|
|
56
|
+
]]
|
|
57
|
+
local WAYPOINT_RADIUS = 3
|
|
58
|
+
|
|
59
|
+
--[[
|
|
60
|
+
Movement below this in a sample is not movement.
|
|
61
|
+
|
|
62
|
+
A standing character still drifts a little -- physics settling, a humanoid
|
|
63
|
+
shifting on a slope -- and treating that as progress would let a wedged
|
|
64
|
+
character look busy forever.
|
|
65
|
+
]]
|
|
66
|
+
local STUD_EPSILON = 0.35
|
|
67
|
+
|
|
68
|
+
--[[
|
|
69
|
+
Seconds of no progress before a walk is called stuck.
|
|
70
|
+
|
|
71
|
+
Well under `MoveToFinished`'s own eight, which is the number this replaces.
|
|
72
|
+
Two seconds is long enough to survive a pause at a corner and short enough
|
|
73
|
+
that a blocked route reports in seconds rather than costing eight per
|
|
74
|
+
waypoint for the rest of the path.
|
|
75
|
+
]]
|
|
76
|
+
local STUCK_SECONDS = 2
|
|
77
|
+
|
|
78
|
+
--[[
|
|
79
|
+
How many times a stalled walk may ask for a fresh route.
|
|
80
|
+
|
|
81
|
+
Three, because the cases this rescues -- a stale route, a door that shut, a
|
|
82
|
+
prop that moved -- clear in one or two attempts, while a character truly
|
|
83
|
+
wedged against geometry would loop on the spot until the deadline and report
|
|
84
|
+
nothing useful about why it failed.
|
|
85
|
+
]]
|
|
86
|
+
local MAX_REPATHS = 3
|
|
87
|
+
|
|
88
|
+
local function parseVector(value: any, what: string): Vector3
|
|
89
|
+
if typeof(value) ~= "string" then
|
|
90
|
+
Dispatch.fail("BAD_PARAMS", string.format("%s must be a string like \"10, 5, 0\".", what))
|
|
91
|
+
end
|
|
92
|
+
local x, y, z = string.match(value, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
|
|
93
|
+
if x == nil then
|
|
94
|
+
Dispatch.fail("BAD_PARAMS", string.format("%s must look like \"10, 5, 0\", got %q.", what, value))
|
|
95
|
+
end
|
|
96
|
+
return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
--[[
|
|
100
|
+
The character to drive, and the Humanoid inside it.
|
|
101
|
+
|
|
102
|
+
Named lookup comes first so a multiplayer test can address one player; with
|
|
103
|
+
one player the name is unnecessary and omitting it is the normal case.
|
|
104
|
+
]]
|
|
105
|
+
local function humanoidOf(params: { [string]: any }): (Model, Humanoid, Player)
|
|
106
|
+
if RunService:IsEdit() then
|
|
107
|
+
Dispatch.fail(
|
|
108
|
+
"NOT_RUNNING",
|
|
109
|
+
"There is no character in an edit session.",
|
|
110
|
+
"Start a playtest with `playtest op=play`, then address this to the playtest's studioId."
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
local player: Player? = nil
|
|
115
|
+
if typeof(params.player) == "string" and params.player ~= "" then
|
|
116
|
+
player = Players:FindFirstChild(params.player) :: Player?
|
|
117
|
+
if player == nil then
|
|
118
|
+
Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this test.", params.player))
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
player = Players:GetPlayers()[1]
|
|
122
|
+
if player == nil then
|
|
123
|
+
Dispatch.fail(
|
|
124
|
+
"NO_PLAYER",
|
|
125
|
+
"No players are in this test.",
|
|
126
|
+
"Run mode has no player at all -- use `playtest op=play` for a character."
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
local model = (player :: Player).Character
|
|
132
|
+
if model == nil then
|
|
133
|
+
Dispatch.fail("NO_CHARACTER", "The player has no character right now (probably respawning).")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
local humanoid = (model :: Model):FindFirstChildOfClass("Humanoid")
|
|
137
|
+
if humanoid == nil then
|
|
138
|
+
Dispatch.fail("NO_HUMANOID", "The character has no Humanoid.")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return model :: Model, humanoid :: Humanoid, player :: Player
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
local function positionOf(model: Model): Vector3
|
|
145
|
+
local root = model:FindFirstChild("HumanoidRootPart")
|
|
146
|
+
if root and root:IsA("BasePart") then
|
|
147
|
+
return root.Position
|
|
148
|
+
end
|
|
149
|
+
return model:GetPivot().Position
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Takes physics authority over the character so the server can actually move it.
|
|
154
|
+
|
|
155
|
+
Without this the character does not move at all, and says nothing about why.
|
|
156
|
+
A player's character is network-owned by their client, so a `MoveTo` issued
|
|
157
|
+
from the server is overridden by the client's own controller on the next
|
|
158
|
+
frame -- the humanoid accepts the request, `MoveToFinished` fires, waypoints
|
|
159
|
+
are reported reached, and the character has not travelled a single stud.
|
|
160
|
+
Measured: identical start and end positions across a three-waypoint path that
|
|
161
|
+
reported Success.
|
|
162
|
+
|
|
163
|
+
`SetNetworkOwner(nil)` hands authority to the server, after which the same
|
|
164
|
+
call moved it 7.11 studs. This is what Studio's own "server-side character
|
|
165
|
+
control" does, and it is the difference between driving the character and
|
|
166
|
+
politely asking the client to.
|
|
167
|
+
]]
|
|
168
|
+
local function takeControl(model: Model): boolean
|
|
169
|
+
local root = model:FindFirstChild("HumanoidRootPart")
|
|
170
|
+
if root == nil or not root:IsA("BasePart") then
|
|
171
|
+
return false
|
|
172
|
+
end
|
|
173
|
+
-- Guarded: an anchored root, or a session where the part is not
|
|
174
|
+
-- network-owned at all, throws rather than returning false.
|
|
175
|
+
local ok = pcall(function()
|
|
176
|
+
(root :: BasePart):SetNetworkOwner(nil)
|
|
177
|
+
end)
|
|
178
|
+
return ok
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
--[[
|
|
182
|
+
Walks to a point, following a computed path around obstacles.
|
|
183
|
+
|
|
184
|
+
Reports where it ended up rather than only whether the call returned, because
|
|
185
|
+
`MoveTo` succeeds at being asked and says nothing about arriving. A route
|
|
186
|
+
blocked by a wall the agent did not know about looks identical to a
|
|
187
|
+
successful walk unless the final distance is measured.
|
|
188
|
+
]]
|
|
189
|
+
--[[
|
|
190
|
+
Agent settings, shared by the route check and the walk.
|
|
191
|
+
|
|
192
|
+
Written once because a route that `path` calls walkable and `moveTo` then
|
|
193
|
+
fails to walk would be worse than having neither: the whole value of testing
|
|
194
|
+
a route before committing to it is that the test used the same agent.
|
|
195
|
+
|
|
196
|
+
The defaults describe a standard Roblox character -- 2 studs across, 5 tall,
|
|
197
|
+
able to jump. Overriding them is the point for anything else: an NPC with a
|
|
198
|
+
wide model genuinely cannot fit through a gap a player can, and that is a
|
|
199
|
+
level-design fact nobody can see by looking.
|
|
200
|
+
]]
|
|
201
|
+
local function agentOf(params: { [string]: any }): { [string]: any }
|
|
202
|
+
return {
|
|
203
|
+
AgentRadius = math.clamp(tonumber(params.agentRadius) or 2, 0.1, 50),
|
|
204
|
+
AgentHeight = math.clamp(tonumber(params.agentHeight) or 5, 0.1, 100),
|
|
205
|
+
AgentCanJump = params.canJump ~= false,
|
|
206
|
+
AgentCanClimb = params.canClimb == true,
|
|
207
|
+
--[[
|
|
208
|
+
Waypoint spacing is exposed because it decides what the answer even
|
|
209
|
+
means. Wide spacing gives a coarse route that says "roughly this
|
|
210
|
+
way"; tight spacing follows the geometry and is what you want when
|
|
211
|
+
the question is whether something fits through a doorway.
|
|
212
|
+
]]
|
|
213
|
+
WaypointSpacing = math.clamp(tonumber(params.spacing) or 4, 0.1, 100),
|
|
214
|
+
Costs = if typeof(params.costs) == "table" then params.costs else nil,
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
--[[
|
|
219
|
+
Turns a start and a goal into two world points.
|
|
220
|
+
|
|
221
|
+
Both ends accept either a position or the path of something in the place,
|
|
222
|
+
because both are how the question actually arrives: "from the spawn to the
|
|
223
|
+
cell door" names two instances, while "from here to there" names two
|
|
224
|
+
vectors.
|
|
225
|
+
]]
|
|
226
|
+
local function endpointOf(params: { [string]: any }, pathKey: string, vectorKey: string, label: string): Vector3
|
|
227
|
+
local named = params[pathKey]
|
|
228
|
+
if typeof(named) == "string" and named ~= "" then
|
|
229
|
+
local target = Paths.resolve(named)
|
|
230
|
+
if target:IsA("BasePart") then
|
|
231
|
+
return (target :: BasePart).Position
|
|
232
|
+
elseif target:IsA("Model") then
|
|
233
|
+
return (target :: Model):GetPivot().Position
|
|
234
|
+
end
|
|
235
|
+
Dispatch.fail("NOT_POSITIONED", string.format("%s has no position.", named))
|
|
236
|
+
end
|
|
237
|
+
return parseVector(params[vectorKey], label)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
--[[
|
|
241
|
+
Answers "can anything get from here to there" without moving anything.
|
|
242
|
+
|
|
243
|
+
This is the half of pathfinding that was missing, and it is the more useful
|
|
244
|
+
half. `moveTo` needs a character, needs a playtest, and changes the place
|
|
245
|
+
while it runs. The navmesh does not: measured, `ComputeAsync` returns
|
|
246
|
+
`Success` with real waypoints in an ordinary edit session, with nothing
|
|
247
|
+
playing and nobody spawned.
|
|
248
|
+
|
|
249
|
+
So a level can be checked before it is ever played. Is the spawn connected
|
|
250
|
+
to the objective? Did that new wall seal off a corridor? Can a 4-stud-wide
|
|
251
|
+
NPC use the door the 2-stud player walks through? Each of those is a route
|
|
252
|
+
query, and every one of them previously required pressing Play and driving a
|
|
253
|
+
character by hand.
|
|
254
|
+
|
|
255
|
+
Reports the route rather than judging it. `NoPath` is an answer, not an
|
|
256
|
+
error: it means the two points are not connected for an agent of this shape,
|
|
257
|
+
which is usually the finding rather than the failure.
|
|
258
|
+
]]
|
|
259
|
+
function Character.path(params: { [string]: any }): { [string]: any }
|
|
260
|
+
local start = endpointOf(params, "fromPath", "from", "`from`")
|
|
261
|
+
local goal = endpointOf(params, "toPath", "to", "`to`")
|
|
262
|
+
|
|
263
|
+
local path = PathfindingService:CreatePath(agentOf(params))
|
|
264
|
+
local ok, err = pcall(function()
|
|
265
|
+
path:ComputeAsync(start, goal)
|
|
266
|
+
end)
|
|
267
|
+
if not ok then
|
|
268
|
+
Dispatch.fail(
|
|
269
|
+
"PATH_FAILED",
|
|
270
|
+
string.format("Could not compute a path: %s", tostring(err)),
|
|
271
|
+
"Costs keys must be material or PathfindingModifier label names."
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
local status = tostring(path.Status):gsub("Enum%.PathStatus%.", "")
|
|
276
|
+
local straightLine = (goal - start).Magnitude
|
|
277
|
+
|
|
278
|
+
if path.Status ~= Enum.PathStatus.Success then
|
|
279
|
+
return {
|
|
280
|
+
reachable = false,
|
|
281
|
+
status = status,
|
|
282
|
+
from = tostring(start),
|
|
283
|
+
to = tostring(goal),
|
|
284
|
+
straightLineDistance = math.round(straightLine * 10) / 10,
|
|
285
|
+
hint = "Nothing walkable connects these points for an agent this size. "
|
|
286
|
+
.. "A wall, a gap wider than a jump, or a drop too far. Try a smaller "
|
|
287
|
+
.. "`agentRadius`, or `canJump`/`canClimb`.",
|
|
288
|
+
}
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
local waypoints = path:GetWaypoints()
|
|
292
|
+
local travelled = 0
|
|
293
|
+
local jumps = 0
|
|
294
|
+
local rows: { { [string]: any } } = {}
|
|
295
|
+
|
|
296
|
+
for index, waypoint in waypoints do
|
|
297
|
+
if index > 1 then
|
|
298
|
+
travelled += (waypoint.Position - waypoints[index - 1].Position).Magnitude
|
|
299
|
+
end
|
|
300
|
+
local action = tostring(waypoint.Action):gsub("Enum%.PathWaypointAction%.", "")
|
|
301
|
+
if action == "Jump" then
|
|
302
|
+
jumps += 1
|
|
303
|
+
end
|
|
304
|
+
--[[
|
|
305
|
+
Only jumps and the ends are listed individually. A hundred Walk
|
|
306
|
+
waypoints down a corridor say nothing a total distance does not, and
|
|
307
|
+
the jumps are the part of a route that breaks: a jump is where an NPC
|
|
308
|
+
gets stuck and where a player without a jump cannot follow.
|
|
309
|
+
]]
|
|
310
|
+
if action ~= "Walk" or index == 1 or index == #waypoints then
|
|
311
|
+
table.insert(rows, {
|
|
312
|
+
index = index,
|
|
313
|
+
action = action,
|
|
314
|
+
position = tostring(waypoint.Position),
|
|
315
|
+
label = if waypoint.Label ~= "" then waypoint.Label else nil,
|
|
316
|
+
})
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
reachable = true,
|
|
322
|
+
status = status,
|
|
323
|
+
from = tostring(start),
|
|
324
|
+
to = tostring(goal),
|
|
325
|
+
waypointCount = #waypoints,
|
|
326
|
+
pathDistance = math.round(travelled * 10) / 10,
|
|
327
|
+
straightLineDistance = math.round(straightLine * 10) / 10,
|
|
328
|
+
--[[
|
|
329
|
+
How far the route is beyond a straight line. A ratio near 1 is a
|
|
330
|
+
clear run; a large one means the agent is going a long way around,
|
|
331
|
+
which is the measurable form of "this level makes you backtrack".
|
|
332
|
+
]]
|
|
333
|
+
detour = if straightLine > 0 then math.round((travelled / straightLine) * 100) / 100 else 1,
|
|
334
|
+
jumps = jumps,
|
|
335
|
+
waypoints = rows,
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
function Character.moveTo(params: { [string]: any }): { [string]: any }
|
|
340
|
+
local model, humanoid = humanoidOf(params)
|
|
341
|
+
|
|
342
|
+
local goal: Vector3
|
|
343
|
+
if typeof(params.path) == "string" and params.path ~= "" then
|
|
344
|
+
local target = Paths.resolve(params.path)
|
|
345
|
+
if target:IsA("BasePart") then
|
|
346
|
+
goal = (target :: BasePart).Position
|
|
347
|
+
elseif target:IsA("Model") then
|
|
348
|
+
goal = (target :: Model):GetPivot().Position
|
|
349
|
+
else
|
|
350
|
+
Dispatch.fail("NOT_POSITIONED", string.format("%s has no position.", params.path))
|
|
351
|
+
end
|
|
352
|
+
else
|
|
353
|
+
goal = parseVector(params.to, "`to`")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
local start = positionOf(model)
|
|
357
|
+
local controlled = takeControl(model)
|
|
358
|
+
|
|
359
|
+
--[[
|
|
360
|
+
Straight-line movement is available on request, because pathfinding
|
|
361
|
+
deliberately refuses routes it considers unreachable -- and "walk at it
|
|
362
|
+
anyway and tell me what happens" is a legitimate thing to test.
|
|
363
|
+
]]
|
|
364
|
+
--[[
|
|
365
|
+
Waypoints carry their action, not just their position.
|
|
366
|
+
|
|
367
|
+
The action is the half of a waypoint that says HOW to get there, and
|
|
368
|
+
dropping it on the way in is what made the walk guess at jumps.
|
|
369
|
+
]]
|
|
370
|
+
type Step = { position: Vector3, action: Enum.PathWaypointAction }
|
|
371
|
+
local waypoints: { Step } = {}
|
|
372
|
+
local pathStatus = "direct"
|
|
373
|
+
|
|
374
|
+
--[[
|
|
375
|
+
A route from wherever the character is now.
|
|
376
|
+
|
|
377
|
+
Written as a function because the walk needs it more than once. A path is
|
|
378
|
+
computed against the world as it stood at the moment of asking, and a
|
|
379
|
+
playtest is the one place where that stops being true while you are
|
|
380
|
+
walking through it: a door closes, an NPC steps into the corridor, a
|
|
381
|
+
platform moves. The first route is a plan, not a promise.
|
|
382
|
+
]]
|
|
383
|
+
local function routeFrom(origin: Vector3): (string, { Step })
|
|
384
|
+
local path = PathfindingService:CreatePath(agentOf(params))
|
|
385
|
+
local ok, err = pcall(function()
|
|
386
|
+
path:ComputeAsync(origin, goal)
|
|
387
|
+
end)
|
|
388
|
+
if not ok then
|
|
389
|
+
Dispatch.fail("PATH_FAILED", string.format("Could not compute a path: %s", tostring(err)))
|
|
390
|
+
end
|
|
391
|
+
local steps: { Step } = {}
|
|
392
|
+
if path.Status == Enum.PathStatus.Success then
|
|
393
|
+
for _, waypoint in path:GetWaypoints() do
|
|
394
|
+
table.insert(steps, { position = waypoint.Position, action = waypoint.Action })
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
return tostring(path.Status), steps
|
|
398
|
+
end
|
|
399
|
+
if params.direct ~= true then
|
|
400
|
+
-- Same settings the `path` op uses, so a route reported walkable there is
|
|
401
|
+
-- the route walked here.
|
|
402
|
+
pathStatus, waypoints = routeFrom(start)
|
|
403
|
+
if #waypoints == 0 then
|
|
404
|
+
return {
|
|
405
|
+
arrived = false,
|
|
406
|
+
pathStatus = pathStatus,
|
|
407
|
+
from = tostring(start),
|
|
408
|
+
goal = tostring(goal),
|
|
409
|
+
note = "No route exists. Pass direct=true to walk straight at it regardless.",
|
|
410
|
+
}
|
|
411
|
+
end
|
|
412
|
+
else
|
|
413
|
+
-- A direct walk has no pathfinder to ask, so it never jumps on purpose.
|
|
414
|
+
waypoints = { { position = goal, action = Enum.PathWaypointAction.Walk } }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
local deadline = os.clock() + MOVE_TIMEOUT
|
|
418
|
+
local reached = 0
|
|
419
|
+
local jumped = 0
|
|
420
|
+
local repaths = 0
|
|
421
|
+
local stuckAt: Vector3? = nil
|
|
422
|
+
local blockedBy: string? = nil
|
|
423
|
+
|
|
424
|
+
local index = 0
|
|
425
|
+
while index < #waypoints do
|
|
426
|
+
index += 1
|
|
427
|
+
local step = waypoints[index]
|
|
428
|
+
if os.clock() > deadline then
|
|
429
|
+
break
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
--[[
|
|
433
|
+
Jump when the ENGINE says to jump, not when the height changed.
|
|
434
|
+
|
|
435
|
+
The old test was `waypoint.Y - previous.Y > 1.5`, and it was wrong in
|
|
436
|
+
both directions. A gap in a flat floor is a jump with no height change
|
|
437
|
+
at all, so the character walked calmly off the edge; a ramp is a
|
|
438
|
+
height change with no jump needed, so it hopped its way up a slope for
|
|
439
|
+
no reason. `PathWaypointAction.Jump` is the pathfinder telling you
|
|
440
|
+
exactly which step needs it, and it was being ignored in favour of a
|
|
441
|
+
guess about the geometry it had already solved.
|
|
442
|
+
]]
|
|
443
|
+
if step.action == Enum.PathWaypointAction.Jump then
|
|
444
|
+
humanoid.Jump = true
|
|
445
|
+
jumped += 1
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
humanoid:MoveTo(step.position)
|
|
449
|
+
|
|
450
|
+
--[[
|
|
451
|
+
Watched while it walks, rather than waited on.
|
|
452
|
+
|
|
453
|
+
`MoveToFinished` fires on arrival or after its own eight seconds, and
|
|
454
|
+
those two are indistinguishable from the signal alone -- so a
|
|
455
|
+
character wedged against a doorframe costs eight seconds per waypoint
|
|
456
|
+
and reports the same as one that walked. Polling the position tells
|
|
457
|
+
the difference in a fraction of that: a character that has not moved
|
|
458
|
+
STUD_EPSILON in STUCK_SECONDS is not walking, whatever the humanoid
|
|
459
|
+
thinks it is doing.
|
|
460
|
+
]]
|
|
461
|
+
local lastPosition = positionOf(model)
|
|
462
|
+
local lastProgress = os.clock()
|
|
463
|
+
local arrivedHere = false
|
|
464
|
+
|
|
465
|
+
while os.clock() < deadline do
|
|
466
|
+
local now = positionOf(model)
|
|
467
|
+
--[[
|
|
468
|
+
Measured flat, ignoring height.
|
|
469
|
+
|
|
470
|
+
`positionOf` is the root part -- the middle of the character,
|
|
471
|
+
around three studs above its feet -- and a waypoint sits on the
|
|
472
|
+
floor. Comparing them in 3D therefore never gets closer than the
|
|
473
|
+
character is tall: standing exactly on a waypoint still measures
|
|
474
|
+
three studs away, which is the whole tolerance, so arrival
|
|
475
|
+
registered by luck or not at all. Measured: a walk stalled two
|
|
476
|
+
waypoints in with the character standing on the spot it was
|
|
477
|
+
walking to.
|
|
478
|
+
|
|
479
|
+
Horizontal distance is the honest test of "am I there"; height is
|
|
480
|
+
the pathfinder's business, not the arrival check's.
|
|
481
|
+
]]
|
|
482
|
+
local flat = Vector3.new(now.X - step.position.X, 0, now.Z - step.position.Z)
|
|
483
|
+
if flat.Magnitude <= WAYPOINT_RADIUS then
|
|
484
|
+
arrivedHere = true
|
|
485
|
+
break
|
|
486
|
+
end
|
|
487
|
+
if (now - lastPosition).Magnitude > STUD_EPSILON then
|
|
488
|
+
lastPosition = now
|
|
489
|
+
lastProgress = os.clock()
|
|
490
|
+
elseif os.clock() - lastProgress > STUCK_SECONDS then
|
|
491
|
+
stuckAt = now
|
|
492
|
+
break
|
|
493
|
+
end
|
|
494
|
+
task.wait(0.1)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if arrivedHere then
|
|
498
|
+
reached += 1
|
|
499
|
+
continue
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
--[[
|
|
503
|
+
Stuck once is not stuck.
|
|
504
|
+
|
|
505
|
+
Measured: a walk stopped seventeen waypoints in, and a path computed
|
|
506
|
+
from that exact spot reached the goal in eighteen more over open
|
|
507
|
+
ground -- nothing was blocking it, at chest height or at its feet. The
|
|
508
|
+
route had simply gone stale, which is the normal condition of a route
|
|
509
|
+
in a running game.
|
|
510
|
+
|
|
511
|
+
So the first answer to being stuck is to ask again from where we now
|
|
512
|
+
are, not to give up and report failure. Bounded, because a character
|
|
513
|
+
genuinely wedged would otherwise recompute until the deadline and
|
|
514
|
+
report nothing useful about why.
|
|
515
|
+
]]
|
|
516
|
+
if stuckAt ~= nil and repaths < MAX_REPATHS and os.clock() < deadline then
|
|
517
|
+
local status, fresh = routeFrom(stuckAt :: Vector3)
|
|
518
|
+
if #fresh > 0 then
|
|
519
|
+
repaths += 1
|
|
520
|
+
pathStatus = status
|
|
521
|
+
waypoints = fresh
|
|
522
|
+
index = 0
|
|
523
|
+
stuckAt = nil
|
|
524
|
+
continue
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
if stuckAt ~= nil then
|
|
529
|
+
--[[
|
|
530
|
+
What it is stuck ON, not just that it is stuck.
|
|
531
|
+
|
|
532
|
+
"Did not arrive" sends someone to look at the whole route. A ray
|
|
533
|
+
from the character towards the step it could not reach names the
|
|
534
|
+
thing in the way, which is usually the entire answer -- a door
|
|
535
|
+
that is closed, a prop dropped into a corridor, a wall that was
|
|
536
|
+
moved.
|
|
537
|
+
]]
|
|
538
|
+
--[[
|
|
539
|
+
Cast flat, at chest height, or it finds the floor.
|
|
540
|
+
|
|
541
|
+
The first version aimed from the root straight at the waypoint,
|
|
542
|
+
and since a waypoint is on the ground that ray points slightly
|
|
543
|
+
downwards -- so it hit whatever the character was standing on and
|
|
544
|
+
reported the floor as the obstruction. Measured: "blocked by
|
|
545
|
+
Workspace.SpawnLocation", which was the pad under its feet.
|
|
546
|
+
|
|
547
|
+
A horizontal ray from chest height asks the question that was
|
|
548
|
+
meant: what is in front of it.
|
|
549
|
+
]]
|
|
550
|
+
local eye = (stuckAt :: Vector3) + Vector3.new(0, 1, 0)
|
|
551
|
+
local towards = Vector3.new(step.position.X - eye.X, 0, step.position.Z - eye.Z)
|
|
552
|
+
if towards.Magnitude > 0.1 then
|
|
553
|
+
local filter = RaycastParams.new()
|
|
554
|
+
filter.FilterType = Enum.RaycastFilterType.Exclude
|
|
555
|
+
filter.FilterDescendantsInstances = { model }
|
|
556
|
+
local reach = towards.Unit * math.min(towards.Magnitude, 12)
|
|
557
|
+
local hit = Workspace:Raycast(eye, reach, filter)
|
|
558
|
+
if hit ~= nil then
|
|
559
|
+
blockedBy = string.format("%s (%s)", Paths.of(hit.Instance), hit.Instance.ClassName)
|
|
560
|
+
else
|
|
561
|
+
--[[
|
|
562
|
+
A second ray, low down, for the thing the first one misses.
|
|
563
|
+
|
|
564
|
+
Chest height answers "is there a wall", and a great many
|
|
565
|
+
stuck characters are not stopped by a wall: they are
|
|
566
|
+
stopped by a step, a kerb or a ledge a stud high, which a
|
|
567
|
+
ray at chest height sails straight over. Measured -- a walk
|
|
568
|
+
stalled seventeen waypoints in with the character alive,
|
|
569
|
+
Running, on a plastic floor, and nothing at all in front of
|
|
570
|
+
its chest.
|
|
571
|
+
|
|
572
|
+
Named differently too, because the fix is different. A wall
|
|
573
|
+
means route around it; a step means the character needed to
|
|
574
|
+
jump and the pathfinder did not think so.
|
|
575
|
+
]]
|
|
576
|
+
local low = Workspace:Raycast((stuckAt :: Vector3) - Vector3.new(0, 1.5, 0), reach, filter)
|
|
577
|
+
if low ~= nil then
|
|
578
|
+
blockedBy = string.format(
|
|
579
|
+
"%s (%s) -- a low step or ledge, not a wall: nothing blocks it at "
|
|
580
|
+
.. "chest height, so it needs a jump the route did not include",
|
|
581
|
+
Paths.of(low.Instance),
|
|
582
|
+
low.Instance.ClassName
|
|
583
|
+
)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
break
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
-- Ran out of time rather than getting stuck; the loop's own guard ends it.
|
|
591
|
+
break
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
local final = positionOf(model)
|
|
595
|
+
local distance = (final - goal).Magnitude
|
|
596
|
+
local arrived = distance <= ARRIVAL_RADIUS
|
|
597
|
+
|
|
598
|
+
local report: { [string]: any } = {
|
|
599
|
+
arrived = arrived,
|
|
600
|
+
serverControlled = controlled,
|
|
601
|
+
distance = math.floor(distance * 10 + 0.5) / 10,
|
|
602
|
+
waypoints = #waypoints,
|
|
603
|
+
waypointsReached = reached,
|
|
604
|
+
jumps = jumped,
|
|
605
|
+
-- Reported because it is the difference between a clear run and a route
|
|
606
|
+
-- the character had to be rescued along.
|
|
607
|
+
repaths = repaths,
|
|
608
|
+
pathStatus = pathStatus,
|
|
609
|
+
from = tostring(start),
|
|
610
|
+
to = tostring(final),
|
|
611
|
+
goal = tostring(goal),
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if not arrived then
|
|
615
|
+
report.stuckAt = if stuckAt ~= nil then tostring(stuckAt) else nil
|
|
616
|
+
report.blockedBy = blockedBy
|
|
617
|
+
--[[
|
|
618
|
+
Said plainly, because the numbers alone read as success to anything
|
|
619
|
+
skimming: waypointsReached counts the ones it walked, and a route that
|
|
620
|
+
stopped nine steps in still reports nine.
|
|
621
|
+
]]
|
|
622
|
+
report.note = if blockedBy ~= nil
|
|
623
|
+
then string.format(
|
|
624
|
+
"Stopped %.0f studs short at waypoint %d of %d, blocked by %s.",
|
|
625
|
+
distance,
|
|
626
|
+
reached + 1,
|
|
627
|
+
#waypoints,
|
|
628
|
+
blockedBy
|
|
629
|
+
)
|
|
630
|
+
else string.format(
|
|
631
|
+
"Stopped %.0f studs short at waypoint %d of %d. Nothing was in the ray's "
|
|
632
|
+
.. "way, so it is more likely a drop, a gap, or a route the character "
|
|
633
|
+
.. "cannot physically follow.",
|
|
634
|
+
distance,
|
|
635
|
+
reached + 1,
|
|
636
|
+
#waypoints
|
|
637
|
+
)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
return report
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
--[[
|
|
644
|
+
One-shot character actions: jump, sit, respawn, and the state changes worth
|
|
645
|
+
testing directly.
|
|
646
|
+
]]
|
|
647
|
+
function Character.act(params: { [string]: any }): { [string]: any }
|
|
648
|
+
local model, humanoid, player = humanoidOf(params)
|
|
649
|
+
local action = tostring(params.action or "jump")
|
|
650
|
+
|
|
651
|
+
-- Jumping and sitting are driven from the server too, and are overridden by
|
|
652
|
+
-- the owning client for exactly the same reason walking was.
|
|
653
|
+
takeControl(model)
|
|
654
|
+
|
|
655
|
+
if action == "jump" then
|
|
656
|
+
humanoid.Jump = true
|
|
657
|
+
return { action = action, at = tostring(positionOf(model)) }
|
|
658
|
+
elseif action == "stop" then
|
|
659
|
+
humanoid:MoveTo(positionOf(model))
|
|
660
|
+
return { action = action, at = tostring(positionOf(model)) }
|
|
661
|
+
elseif action == "sit" then
|
|
662
|
+
humanoid.Sit = true
|
|
663
|
+
return { action = action, sitting = humanoid.Sit }
|
|
664
|
+
elseif action == "stand" then
|
|
665
|
+
humanoid.Sit = false
|
|
666
|
+
return { action = action, sitting = humanoid.Sit }
|
|
667
|
+
elseif action == "respawn" then
|
|
668
|
+
player:LoadCharacter()
|
|
669
|
+
return { action = action, note = "The character was rebuilt; anything holding the old one is stale." }
|
|
670
|
+
elseif action == "kill" then
|
|
671
|
+
humanoid.Health = 0
|
|
672
|
+
return { action = action, note = "Killed, so the death and respawn path runs." }
|
|
673
|
+
elseif action == "equip" then
|
|
674
|
+
--[[
|
|
675
|
+
Tools are the other half of gameplay input, and the half that is
|
|
676
|
+
actually reachable. Keys and clicks are closed to plugins, but a
|
|
677
|
+
Tool's Activate is a plain server-side call, so "equip the sword and
|
|
678
|
+
swing it" -- which is what most combat tests come down to -- works
|
|
679
|
+
without simulating a single input event.
|
|
680
|
+
]]
|
|
681
|
+
local name = params.tool
|
|
682
|
+
if typeof(name) ~= "string" or name == "" then
|
|
683
|
+
Dispatch.fail("BAD_PARAMS", "equip needs a `tool` name.")
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
--[[
|
|
687
|
+
Every tool of that name, not the first one found.
|
|
688
|
+
|
|
689
|
+
A place under development commonly has two: the weapon being rebuilt
|
|
690
|
+
and the one it is replacing, both called "Pistol". `FindFirstChild`
|
|
691
|
+
picks one of them silently, so "equip the pistol, fire it, nothing
|
|
692
|
+
happened" is indistinguishable from a broken weapon -- measured, with
|
|
693
|
+
the working copy sitting next to the one that got equipped.
|
|
694
|
+
]]
|
|
695
|
+
local backpack = player:FindFirstChildOfClass("Backpack")
|
|
696
|
+
local candidates: { Tool } = {}
|
|
697
|
+
if backpack then
|
|
698
|
+
for _, child in backpack:GetChildren() do
|
|
699
|
+
if child.Name == name and child:IsA("Tool") then
|
|
700
|
+
table.insert(candidates, child)
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
local fromStarter = false
|
|
706
|
+
if #candidates == 0 then
|
|
707
|
+
for _, child in game:GetService("StarterPack"):GetChildren() do
|
|
708
|
+
if child.Name == name and child:IsA("Tool") then
|
|
709
|
+
local copy = child:Clone()
|
|
710
|
+
copy.Parent = backpack
|
|
711
|
+
table.insert(candidates, copy)
|
|
712
|
+
fromStarter = true
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
if #candidates == 0 then
|
|
718
|
+
Dispatch.fail(
|
|
719
|
+
"NO_TOOL",
|
|
720
|
+
string.format("No Tool named %q in the player's Backpack or StarterPack.", name)
|
|
721
|
+
)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
local tool = candidates[1]
|
|
725
|
+
humanoid:EquipTool(tool)
|
|
726
|
+
|
|
727
|
+
--[[
|
|
728
|
+
Waited for, because equipping is not finished when the call returns.
|
|
729
|
+
|
|
730
|
+
`EquipTool` reparents the tool and the scripts inside it start on
|
|
731
|
+
their own threads afterwards, so an `activate` sent immediately after
|
|
732
|
+
an `equip` reaches a tool whose code has not connected to `Activated`
|
|
733
|
+
yet. That one does nothing, reports success, and looks exactly like a
|
|
734
|
+
weapon that is broken. Measured: the first activate after an equip
|
|
735
|
+
printed nothing, the second fired.
|
|
736
|
+
]]
|
|
737
|
+
local waited = 0
|
|
738
|
+
while tool.Parent ~= model and waited < 2 do
|
|
739
|
+
waited += task.wait()
|
|
740
|
+
end
|
|
741
|
+
task.wait()
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
action = action,
|
|
745
|
+
tool = Paths.of(tool),
|
|
746
|
+
equipped = tool.Parent == model,
|
|
747
|
+
fromStarterPack = fromStarter,
|
|
748
|
+
-- Named only when it is a choice, so the ordinary case stays quiet.
|
|
749
|
+
alsoNamed = if #candidates > 1 then #candidates - 1 else nil,
|
|
750
|
+
note = if #candidates > 1
|
|
751
|
+
then string.format(
|
|
752
|
+
"%d tools are named %q; this equipped the first one in the Backpack. "
|
|
753
|
+
.. "If it behaved wrongly, check that it is the one you meant.",
|
|
754
|
+
#candidates,
|
|
755
|
+
name
|
|
756
|
+
)
|
|
757
|
+
else nil,
|
|
758
|
+
}
|
|
759
|
+
elseif action == "activate" then
|
|
760
|
+
local equipped = model:FindFirstChildOfClass("Tool")
|
|
761
|
+
if equipped == nil then
|
|
762
|
+
Dispatch.fail(
|
|
763
|
+
"NO_TOOL",
|
|
764
|
+
"The character is not holding a tool.",
|
|
765
|
+
'Equip one first with action "equip".'
|
|
766
|
+
)
|
|
767
|
+
end
|
|
768
|
+
-- Activate is what a mouse click triggers, so this is the closest thing
|
|
769
|
+
-- to "use it" that exists without input injection.
|
|
770
|
+
equipped:Activate()
|
|
771
|
+
-- A frame, so anything the tool prints in response is already in the log
|
|
772
|
+
-- by the time the caller reads `console`.
|
|
773
|
+
task.wait()
|
|
774
|
+
return {
|
|
775
|
+
action = action,
|
|
776
|
+
tool = Paths.of(equipped),
|
|
777
|
+
note = "Activated fired. Whether anything LISTENED for it is not visible "
|
|
778
|
+
.. "from here -- read `console` for what the tool's scripts printed.",
|
|
779
|
+
}
|
|
780
|
+
elseif action == "unequip" then
|
|
781
|
+
humanoid:UnequipTools()
|
|
782
|
+
return { action = action, holding = model:FindFirstChildOfClass("Tool") ~= nil }
|
|
783
|
+
elseif action == "teleport" then
|
|
784
|
+
local target = parseVector(params.to, "`to`")
|
|
785
|
+
model:PivotTo(CFrame.new(target))
|
|
786
|
+
return { action = action, at = tostring(positionOf(model)), note = "Moved without walking -- collisions and triggers along the way did not fire." }
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown character action %q", action))
|
|
790
|
+
return {}
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
--[[
|
|
794
|
+
Where the character is and what state it is in. Cheap, and the thing to call
|
|
795
|
+
before and after anything else here.
|
|
796
|
+
]]
|
|
797
|
+
function Character.state(params: { [string]: any }): { [string]: any }
|
|
798
|
+
local model, humanoid, player = humanoidOf(params)
|
|
799
|
+
return {
|
|
800
|
+
player = player.Name,
|
|
801
|
+
position = tostring(positionOf(model)),
|
|
802
|
+
health = humanoid.Health,
|
|
803
|
+
maxHealth = humanoid.MaxHealth,
|
|
804
|
+
walkSpeed = humanoid.WalkSpeed,
|
|
805
|
+
jumpPower = humanoid.JumpPower,
|
|
806
|
+
state = tostring(humanoid:GetState()),
|
|
807
|
+
sitting = humanoid.Sit,
|
|
808
|
+
floor = if humanoid.FloorMaterial ~= Enum.Material.Air
|
|
809
|
+
then tostring(humanoid.FloorMaterial)
|
|
810
|
+
else nil,
|
|
811
|
+
}
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
function Character.register()
|
|
815
|
+
Dispatch.registerAll("character", {
|
|
816
|
+
path = Character.path,
|
|
817
|
+
moveTo = Character.moveTo,
|
|
818
|
+
act = Character.act,
|
|
819
|
+
state = Character.state,
|
|
820
|
+
})
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
return Character
|