@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.
Files changed (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. 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 Players = game:GetService("Players")
33
- local RunService = game:GetService("RunService")
34
-
35
- local Dispatch = require(script.Parent.Parent.Dispatch)
36
- local Paths = require(script.Parent.Parent.Paths)
37
-
38
- local Character = {}
39
-
40
- -- How long to wait for a walk before calling it stuck. Long enough to cross a
41
- -- large room, short enough that a failed route does not hold the request open.
42
- local MOVE_TIMEOUT = 30
43
-
44
- -- How close counts as arrived. `MoveToFinished` fires on its own timeout as
45
- -- well as on arrival, so distance is what actually decides success.
46
- local ARRIVAL_RADIUS = 6
47
-
48
- local function parseVector(value: any, what: string): Vector3
49
- if typeof(value) ~= "string" then
50
- Dispatch.fail("BAD_PARAMS", string.format("%s must be a string like \"10, 5, 0\".", what))
51
- end
52
- local x, y, z = string.match(value, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
53
- if x == nil then
54
- Dispatch.fail("BAD_PARAMS", string.format("%s must look like \"10, 5, 0\", got %q.", what, value))
55
- end
56
- return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
57
- end
58
-
59
- --[[
60
- The character to drive, and the Humanoid inside it.
61
-
62
- Named lookup comes first so a multiplayer test can address one player; with
63
- one player the name is unnecessary and omitting it is the normal case.
64
- ]]
65
- local function humanoidOf(params: { [string]: any }): (Model, Humanoid, Player)
66
- if RunService:IsEdit() then
67
- Dispatch.fail(
68
- "NOT_RUNNING",
69
- "There is no character in an edit session.",
70
- "Start a playtest with `playtest op=play`, then address this to the playtest's studioId."
71
- )
72
- end
73
-
74
- local player: Player? = nil
75
- if typeof(params.player) == "string" and params.player ~= "" then
76
- player = Players:FindFirstChild(params.player) :: Player?
77
- if player == nil then
78
- Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this test.", params.player))
79
- end
80
- else
81
- player = Players:GetPlayers()[1]
82
- if player == nil then
83
- Dispatch.fail(
84
- "NO_PLAYER",
85
- "No players are in this test.",
86
- "Run mode has no player at all -- use `playtest op=play` for a character."
87
- )
88
- end
89
- end
90
-
91
- local model = (player :: Player).Character
92
- if model == nil then
93
- Dispatch.fail("NO_CHARACTER", "The player has no character right now (probably respawning).")
94
- end
95
-
96
- local humanoid = (model :: Model):FindFirstChildOfClass("Humanoid")
97
- if humanoid == nil then
98
- Dispatch.fail("NO_HUMANOID", "The character has no Humanoid.")
99
- end
100
-
101
- return model :: Model, humanoid :: Humanoid, player :: Player
102
- end
103
-
104
- local function positionOf(model: Model): Vector3
105
- local root = model:FindFirstChild("HumanoidRootPart")
106
- if root and root:IsA("BasePart") then
107
- return root.Position
108
- end
109
- return model:GetPivot().Position
110
- end
111
-
112
- --[[
113
- Takes physics authority over the character so the server can actually move it.
114
-
115
- Without this the character does not move at all, and says nothing about why.
116
- A player's character is network-owned by their client, so a `MoveTo` issued
117
- from the server is overridden by the client's own controller on the next
118
- frame -- the humanoid accepts the request, `MoveToFinished` fires, waypoints
119
- are reported reached, and the character has not travelled a single stud.
120
- Measured: identical start and end positions across a three-waypoint path that
121
- reported Success.
122
-
123
- `SetNetworkOwner(nil)` hands authority to the server, after which the same
124
- call moved it 7.11 studs. This is what Studio's own "server-side character
125
- control" does, and it is the difference between driving the character and
126
- politely asking the client to.
127
- ]]
128
- local function takeControl(model: Model): boolean
129
- local root = model:FindFirstChild("HumanoidRootPart")
130
- if root == nil or not root:IsA("BasePart") then
131
- return false
132
- end
133
- -- Guarded: an anchored root, or a session where the part is not
134
- -- network-owned at all, throws rather than returning false.
135
- local ok = pcall(function()
136
- (root :: BasePart):SetNetworkOwner(nil)
137
- end)
138
- return ok
139
- end
140
-
141
- --[[
142
- Walks to a point, following a computed path around obstacles.
143
-
144
- Reports where it ended up rather than only whether the call returned, because
145
- `MoveTo` succeeds at being asked and says nothing about arriving. A route
146
- blocked by a wall the agent did not know about looks identical to a
147
- successful walk unless the final distance is measured.
148
- ]]
149
- function Character.moveTo(params: { [string]: any }): { [string]: any }
150
- local model, humanoid = humanoidOf(params)
151
-
152
- local goal: Vector3
153
- if typeof(params.path) == "string" and params.path ~= "" then
154
- local target = Paths.resolve(params.path)
155
- if target:IsA("BasePart") then
156
- goal = (target :: BasePart).Position
157
- elseif target:IsA("Model") then
158
- goal = (target :: Model):GetPivot().Position
159
- else
160
- Dispatch.fail("NOT_POSITIONED", string.format("%s has no position.", params.path))
161
- end
162
- else
163
- goal = parseVector(params.to, "`to`")
164
- end
165
-
166
- local start = positionOf(model)
167
- local controlled = takeControl(model)
168
-
169
- --[[
170
- Straight-line movement is available on request, because pathfinding
171
- deliberately refuses routes it considers unreachable -- and "walk at it
172
- anyway and tell me what happens" is a legitimate thing to test.
173
- ]]
174
- local waypoints: { Vector3 } = {}
175
- local pathStatus = "direct"
176
- if params.direct ~= true then
177
- local path = PathfindingService:CreatePath({
178
- AgentRadius = 2,
179
- AgentHeight = 5,
180
- AgentCanJump = params.canJump ~= false,
181
- })
182
- local ok, err = pcall(function()
183
- path:ComputeAsync(start, goal)
184
- end)
185
- if not ok then
186
- Dispatch.fail("PATH_FAILED", string.format("Could not compute a path: %s", tostring(err)))
187
- end
188
- pathStatus = tostring(path.Status)
189
- if path.Status == Enum.PathStatus.Success then
190
- for _, waypoint in path:GetWaypoints() do
191
- table.insert(waypoints, waypoint.Position)
192
- end
193
- else
194
- return {
195
- arrived = false,
196
- pathStatus = pathStatus,
197
- from = tostring(start),
198
- goal = tostring(goal),
199
- note = "No route exists. Pass direct=true to walk straight at it regardless.",
200
- }
201
- end
202
- else
203
- waypoints = { goal }
204
- end
205
-
206
- local deadline = os.clock() + MOVE_TIMEOUT
207
- local reached = 0
208
- for index, waypoint in waypoints do
209
- if os.clock() > deadline then
210
- break
211
- end
212
- humanoid:MoveTo(waypoint)
213
- -- Jumping is per-waypoint: a path that crosses a gap marks the waypoint
214
- -- before it as a jump, and walking it without jumping falls short.
215
- if index > 1 and waypoint.Y - waypoints[index - 1].Y > 1.5 then
216
- humanoid.Jump = true
217
- end
218
- local finished = humanoid.MoveToFinished:Wait()
219
- reached += 1
220
- if not finished then
221
- -- MoveToFinished(false) means its own 8-second timeout elapsed, which
222
- -- in practice means something is in the way.
223
- break
224
- end
225
- end
226
-
227
- local final = positionOf(model)
228
- local distance = (final - goal).Magnitude
229
-
230
- return {
231
- arrived = distance <= ARRIVAL_RADIUS,
232
- serverControlled = controlled,
233
- distance = math.floor(distance * 10 + 0.5) / 10,
234
- waypoints = #waypoints,
235
- waypointsReached = reached,
236
- pathStatus = pathStatus,
237
- from = tostring(start),
238
- to = tostring(final),
239
- goal = tostring(goal),
240
- }
241
- end
242
-
243
- --[[
244
- One-shot character actions: jump, sit, respawn, and the state changes worth
245
- testing directly.
246
- ]]
247
- function Character.act(params: { [string]: any }): { [string]: any }
248
- local model, humanoid, player = humanoidOf(params)
249
- local action = tostring(params.action or "jump")
250
-
251
- -- Jumping and sitting are driven from the server too, and are overridden by
252
- -- the owning client for exactly the same reason walking was.
253
- takeControl(model)
254
-
255
- if action == "jump" then
256
- humanoid.Jump = true
257
- return { action = action, at = tostring(positionOf(model)) }
258
- elseif action == "stop" then
259
- humanoid:MoveTo(positionOf(model))
260
- return { action = action, at = tostring(positionOf(model)) }
261
- elseif action == "sit" then
262
- humanoid.Sit = true
263
- return { action = action, sitting = humanoid.Sit }
264
- elseif action == "stand" then
265
- humanoid.Sit = false
266
- return { action = action, sitting = humanoid.Sit }
267
- elseif action == "respawn" then
268
- player:LoadCharacter()
269
- return { action = action, note = "The character was rebuilt; anything holding the old one is stale." }
270
- elseif action == "kill" then
271
- humanoid.Health = 0
272
- return { action = action, note = "Killed, so the death and respawn path runs." }
273
- elseif action == "equip" then
274
- --[[
275
- Tools are the other half of gameplay input, and the half that is
276
- actually reachable. Keys and clicks are closed to plugins, but a
277
- Tool's Activate is a plain server-side call, so "equip the sword and
278
- swing it" -- which is what most combat tests come down to -- works
279
- without simulating a single input event.
280
- ]]
281
- local name = params.tool
282
- if typeof(name) ~= "string" or name == "" then
283
- Dispatch.fail("BAD_PARAMS", "equip needs a `tool` name.")
284
- end
285
-
286
- -- Backpack first, then StarterPack, because a tool the player already
287
- -- holds is what a caller usually means.
288
- local backpack = player:FindFirstChildOfClass("Backpack")
289
- local tool = if backpack then backpack:FindFirstChild(name) else nil
290
- if tool == nil then
291
- local starter = game:GetService("StarterPack"):FindFirstChild(name)
292
- if starter then
293
- tool = starter:Clone()
294
- ;(tool :: Instance).Parent = backpack
295
- end
296
- end
297
- if tool == nil or not tool:IsA("Tool") then
298
- Dispatch.fail(
299
- "NO_TOOL",
300
- string.format("No Tool named %q in the player's Backpack or StarterPack.", name)
301
- )
302
- end
303
-
304
- humanoid:EquipTool(tool :: Tool)
305
- return { action = action, tool = name, equipped = (tool :: Tool).Parent == model }
306
- elseif action == "activate" then
307
- local equipped = model:FindFirstChildOfClass("Tool")
308
- if equipped == nil then
309
- Dispatch.fail(
310
- "NO_TOOL",
311
- "The character is not holding a tool.",
312
- 'Equip one first with action "equip".'
313
- )
314
- end
315
- -- Activate is what a mouse click triggers, so this is the closest thing
316
- -- to "use it" that exists without input injection.
317
- equipped:Activate()
318
- return { action = action, tool = equipped.Name }
319
- elseif action == "unequip" then
320
- humanoid:UnequipTools()
321
- return { action = action, holding = model:FindFirstChildOfClass("Tool") ~= nil }
322
- elseif action == "teleport" then
323
- local target = parseVector(params.to, "`to`")
324
- model:PivotTo(CFrame.new(target))
325
- return { action = action, at = tostring(positionOf(model)), note = "Moved without walking -- collisions and triggers along the way did not fire." }
326
- end
327
-
328
- Dispatch.fail("BAD_PARAMS", string.format("unknown character action %q", action))
329
- return {}
330
- end
331
-
332
- --[[
333
- Where the character is and what state it is in. Cheap, and the thing to call
334
- before and after anything else here.
335
- ]]
336
- function Character.state(params: { [string]: any }): { [string]: any }
337
- local model, humanoid, player = humanoidOf(params)
338
- return {
339
- player = player.Name,
340
- position = tostring(positionOf(model)),
341
- health = humanoid.Health,
342
- maxHealth = humanoid.MaxHealth,
343
- walkSpeed = humanoid.WalkSpeed,
344
- jumpPower = humanoid.JumpPower,
345
- state = tostring(humanoid:GetState()),
346
- sitting = humanoid.Sit,
347
- floor = if humanoid.FloorMaterial ~= Enum.Material.Air
348
- then tostring(humanoid.FloorMaterial)
349
- else nil,
350
- }
351
- end
352
-
353
- function Character.register()
354
- Dispatch.registerAll("character", {
355
- moveTo = Character.moveTo,
356
- act = Character.act,
357
- state = Character.state,
358
- })
359
- end
360
-
361
- return Character
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