@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.5

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 (37) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/instances.js +49 -2
  12. package/dist/tools/instances.js.map +1 -1
  13. package/dist/tools/perf.js +9 -8
  14. package/dist/tools/perf.js.map +1 -1
  15. package/dist/tools/scripts.js +10 -1
  16. package/dist/tools/scripts.js.map +1 -1
  17. package/package.json +1 -1
  18. package/plugin/src/Config.luau +1 -1
  19. package/plugin/src/Console.luau +257 -116
  20. package/plugin/src/Serialize.luau +43 -3
  21. package/plugin/src/ThemePicker.luau +458 -0
  22. package/plugin/src/Themes/Aurora.luau +147 -0
  23. package/plugin/src/Themes/Blueprint.luau +213 -0
  24. package/plugin/src/Themes/Draw.luau +177 -0
  25. package/plugin/src/Themes/Lattice.luau +304 -0
  26. package/plugin/src/Themes/Nebula.luau +182 -0
  27. package/plugin/src/Themes/Observatory.luau +200 -0
  28. package/plugin/src/Themes/Orbit.luau +268 -0
  29. package/plugin/src/Themes/Phosphor.luau +200 -0
  30. package/plugin/src/Themes/Theme.luau +121 -0
  31. package/plugin/src/Themes/Void.luau +230 -0
  32. package/plugin/src/Themes/init.luau +116 -0
  33. package/plugin/src/Visuals.luau +788 -907
  34. package/plugin/src/handlers/Discover.luau +75 -1
  35. package/plugin/src/handlers/Exec.luau +17 -5
  36. package/plugin/src/handlers/Instances.luau +35 -3
  37. package/plugin/src/init.server.luau +386 -354
@@ -39,6 +39,72 @@ local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]:
39
39
  }
40
40
  end
41
41
 
42
+ --[[
43
+ Puts a result list into an order that does not change between calls.
44
+
45
+ `GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
46
+ twelve parts named P01..P12 with nothing mutating between the two calls, the
47
+ engine returned them in ascending order once and as P08, P06, P04, P03, P11,
48
+ P02, P01, P12, P10, P07, P05, P09 the next time.
49
+
50
+ Unsorted, that made paging incoherent rather than merely untidy: `offset` is
51
+ a position in this list, so page two came from a different ordering than page
52
+ one, which silently skips some instances and repeats others. Nothing about
53
+ the response would have said so -- the counts stay right and every row is a
54
+ real instance.
55
+
56
+ Sorted by name, with the engine's per-instance debug id breaking ties so
57
+ same-named siblings hold a fixed order too. Name first because it is also
58
+ the order a reader expects: P01 before P02.
59
+ ]]
60
+ local function stableOrder(list: { Instance })
61
+ local ids: { [Instance]: string } = {}
62
+ for _, instance in list do
63
+ -- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
64
+ -- makes O(n log n) of them.
65
+ local ok, id = pcall(function()
66
+ return instance:GetDebugId()
67
+ end)
68
+ ids[instance] = if ok then id else tostring(instance)
69
+ end
70
+ table.sort(list, function(a: Instance, b: Instance): boolean
71
+ if a.Name ~= b.Name then
72
+ return a.Name < b.Name
73
+ end
74
+ return (ids[a] or "") < (ids[b] or "")
75
+ end)
76
+ end
77
+
78
+ --[[
79
+ Compares a property against the text a caller asked for.
80
+
81
+ This was an exact `tostring` match, which made `find` disagree with `modify`
82
+ about its own notation: `modify` documents an enum as either "Neon" or
83
+ "Enum.Material.Neon", but only the qualified form matched here, and "False"
84
+ matched nothing at all. Both came back as a confident zero rather than an
85
+ error, which is the hardest kind of wrong answer to notice.
86
+
87
+ The bare-name shortcut is allowed only for enums, so a part named
88
+ "Map.Backup" is not matched by a search for "Backup".
89
+ ]]
90
+ local function valueMatches(actual: any, wanted: string): boolean
91
+ local text = tostring(actual)
92
+ if text == wanted then
93
+ return true
94
+ end
95
+
96
+ local lowered = string.lower(text)
97
+ local target = string.lower(wanted)
98
+ if lowered == target then
99
+ return true
100
+ end
101
+
102
+ if string.sub(lowered, 1, 5) == "enum." then
103
+ return string.match(lowered, "([^%.]+)$") == target
104
+ end
105
+ return false
106
+ end
107
+
42
108
  --[[
43
109
  Breadth-first walk to `depth`, newest level last, so a truncated result is
44
110
  still a coherent picture of the top of the tree rather than one deep spur.
@@ -86,12 +152,18 @@ function Discover.tree(params: { [string]: any }): { [string]: any }
86
152
  end
87
153
  end
88
154
  end
155
+ -- Sorted per level rather than at the end, so the breadth-first shape is
156
+ -- kept -- shallow instances still come before deep ones -- while the
157
+ -- siblings inside each level stop reshuffling between calls.
158
+ stableOrder(nextFrontier)
89
159
  frontier = nextFrontier
90
160
  if #frontier == 0 or visits > MAX_VISITS then
91
161
  break
92
162
  end
93
163
  end
94
164
 
165
+ stableOrder(matched)
166
+
95
167
  local items: { { [string]: any } } = {}
96
168
  local memo: Paths.NameIndex = {}
97
169
  for index = offset + 1, math.min(offset + limit, #matched) do
@@ -257,13 +329,15 @@ function Discover.find(params: { [string]: any }): { [string]: any }
257
329
  if not readOk then
258
330
  continue
259
331
  end
260
- if propertyValue ~= nil and tostring(value) ~= tostring(propertyValue) then
332
+ if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
261
333
  continue
262
334
  end
263
335
  end
264
336
  table.insert(matched, instance)
265
337
  end
266
338
 
339
+ stableOrder(matched)
340
+
267
341
  local items: { { [string]: any } } = {}
268
342
  local memo: Paths.NameIndex = {}
269
343
  for index = offset + 1, math.min(offset + limit, #matched) do
@@ -43,8 +43,9 @@ local Exec = {}
43
43
  serializer the rest of the server uses, so a Vector3 reads the way it does
44
44
  everywhere else.
45
45
  ]]
46
- local function describe(value: any, depth: number?): any
46
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
47
47
  local level = depth or 0
48
+ local visited = seen or {}
48
49
  local kind = typeof(value)
49
50
 
50
51
  if kind == "function" or kind == "thread" then
@@ -55,11 +56,18 @@ local function describe(value: any, depth: number?): any
55
56
  end
56
57
 
57
58
  local source = value :: { [any]: any }
58
- -- Also the cycle guard: a self-referencing table stops here rather than
59
- -- recursing until the stack gives out.
59
+ -- Named separately from the depth cap, because they mean opposite things to
60
+ -- a reader. The depth cap says "there is more below this"; a cycle says
61
+ -- "this is the table you are already inside". Reporting the second as the
62
+ -- first printed `t.self.self.self` as three distinct nested tables, which
63
+ -- reads as real structure that does not exist.
64
+ if visited[source] then
65
+ return "<circular reference>"
66
+ end
60
67
  if level >= MAX_TABLE_DEPTH then
61
68
  return "<nested table>"
62
69
  end
70
+ visited[source] = true
63
71
 
64
72
  local count = 0
65
73
  for _ in source do
@@ -75,8 +83,11 @@ local function describe(value: any, depth: number?): any
75
83
  table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
76
84
  break
77
85
  end
78
- table.insert(list, describe(item, level + 1))
86
+ table.insert(list, describe(item, level + 1, visited))
79
87
  end
88
+ -- Cleared on the way out so the same table appearing twice side by side
89
+ -- still renders twice; only a table containing itself is a cycle.
90
+ visited[source] = nil
80
91
  return list
81
92
  end
82
93
 
@@ -88,8 +99,9 @@ local function describe(value: any, depth: number?): any
88
99
  map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
89
100
  break
90
101
  end
91
- map[tostring(key)] = describe(item, level + 1)
102
+ map[tostring(key)] = describe(item, level + 1, visited)
92
103
  end
104
+ visited[source] = nil
93
105
  return map
94
106
  end
95
107
 
@@ -53,13 +53,45 @@ local function applyProperty(target: Instance, name: string, spec: PropertySpec)
53
53
  return nil
54
54
  end
55
55
 
56
+ --[[
57
+ Sets attributes, honouring an explicit type where one is given.
58
+
59
+ A bare value is written as it arrives, so a string stays a string. That is
60
+ the safe default and it is also a trap: attributes hold Vector3, Color3,
61
+ UDim2 and the rest, and `"0, 5, 0"` -- the exact text that sets a Vector3
62
+ property -- was landing on an attribute as five characters. The write looked
63
+ like it worked and the game read a string. So a value may instead arrive as
64
+ { type = "Vector3", value = "0, 5, 0" }, which goes through the same parser
65
+ properties use.
66
+ ]]
56
67
  local function applyAttributes(target: Instance, attributes: { [string]: any }): { string }
57
68
  local failures: { string } = {}
58
69
  for name, value in attributes do
59
- -- A JSON null means "remove this attribute", which SetAttribute spells
60
- -- as nil. There is no other way to express removal in the payload.
70
+ local resolved: any = value
71
+ local typed = typeof(value) == "table" and (value :: any).type ~= nil
72
+ if typed then
73
+ local spec = value :: { type: string, value: any }
74
+ local parsedOk, parsed, reason = Serialize.parse(spec.value, spec.type)
75
+ if not parsedOk then
76
+ table.insert(
77
+ failures,
78
+ string.format(
79
+ "attribute %s: %s",
80
+ name,
81
+ reason or string.format("could not be read as a %s", tostring(spec.type))
82
+ )
83
+ )
84
+ continue
85
+ end
86
+ resolved = parsed
87
+ end
88
+
89
+ -- An empty string means "remove this attribute", which SetAttribute spells
90
+ -- as nil. There is no other way to express removal in the payload -- and
91
+ -- the typed form is how to set an attribute to a genuinely empty string,
92
+ -- since only the bare form is read as removal.
61
93
  local ok, err = pcall(function()
62
- target:SetAttribute(name, if value == "" then nil else value)
94
+ target:SetAttribute(name, if not typed and value == "" then nil else resolved)
63
95
  end)
64
96
  if not ok then
65
97
  table.insert(failures, string.format("attribute %s: %s", name, tostring(err)))