local HttpService = game:GetService("HttpService") local StudioService = game:GetService("StudioService") local RunService = game:GetService("RunService") local Selection = game:GetService("Selection") local ChangeHistoryService = game:GetService("ChangeHistoryService") local TweenService = game:GetService("TweenService") local CollectionService = game:GetService("CollectionService") local LogService = game:GetService("LogService") if not RunService:IsStudio() then return end local VERSION = "2.4" local SITE_URL = "https://sably-ai.vercel.app" local API = SITE_URL .. "/api/plugin" local SETTING_DEVICE = "SablyDeviceId" local SETTING_TOKEN = "SablyPluginToken" local SETTING_USER = "SablyLinkedUser" local POLL_SEC = 2 local MAX_SOURCE = 200000 local MAX_LIST = 150 local MAX_SEARCH = 50 local ScriptEditorService = nil pcall(function() ScriptEditorService = game:GetService("ScriptEditorService") end) local alive = true local sessionId = HttpService:GenerateGUID(false) local currentToken = nil local StudioTestService = nil pcall(function() StudioTestService = game:GetService("StudioTestService") end) local function runState() if not RunService:IsEdit() then return "play" end local ok, edit = pcall(function() return StudioTestService.EditModeActive end) if not ok or type(edit) ~= "boolean" then return "unknown" end return if edit then "edit" else "play" end local function requireEdit() local mode = runState() if mode ~= "edit" then error({ code = "STUDIO_PLAY_MODE", runState = mode, message = "Агент ждёт выхода из Play. Остановите тестирование в Roblox Studio." }, 0) end end local function getDeviceId() local id = plugin:GetSetting(SETTING_DEVICE) if type(id) ~= "string" or #id < 16 then id = HttpService:GenerateGUID(false) plugin:SetSetting(SETTING_DEVICE, id) end return id end local function request(method, path, body, token) local headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" } if token then headers["Authorization"] = "Bearer " .. token end local ok, res = pcall(function() return HttpService:RequestAsync({ Url = API .. path, Method = method, Headers = headers, Body = if body ~= nil then HttpService:JSONEncode(body) else nil, }) end) if not ok then local msg = tostring(res) if string.find(msg, "not enabled", 1, true) or string.find(msg, "permission", 1, true) or string.find(msg, "denied", 1, true) then return nil, "Разрешите плагину HTTP-запросы: Plugins → Manage Plugins → Sably → разрешить " .. SITE_URL, 0 end return nil, "Нет связи с " .. SITE_URL .. ". Проверьте, что сайт доступен.", 0 end local data = nil pcall(function() data = HttpService:JSONDecode(res.Body) end) if not res.Success then local msg = if type(data) == "table" and data.error then tostring(data.error) else ("Ошибка сервера (" .. res.StatusCode .. ")") return nil, msg, res.StatusCode end return data, nil, res.StatusCode end -- The edit DataModel stays connected during Play. Only the server copy runs -- this small control bridge; it never reads or mutates the runtime place. local SESSION_ATTRIBUTE = "_SablyEditSession" local sessionCarrier = game:GetService("ReplicatedStorage") if not RunService:IsEdit() then if not RunService:IsServer() then return end local parentSessionId = sessionCarrier:GetAttribute(SESSION_ATTRIBUTE) local token = plugin:GetSetting(SETTING_TOKEN) if type(parentSessionId) ~= "string" or type(token) ~= "string" then return end plugin.Unloading:Connect(function() alive = false end) task.spawn(function() local seq = 0 while alive do seq += 1 local data = request("POST", "/poll", { sessionId = sessionId, parentSessionId = parentSessionId, kind = "play", runState = "play", stateSeq = seq, place = game.Name, placeId = game.PlaceId, version = VERSION }, token) if data and type(data.commands) == "table" then for _, cmd in data.commands do local supported = cmd.tool == "stop_play" and StudioTestService ~= nil -- Acknowledge the request before teardown can destroy this script. -- The website waits for the edit bridge to confirm EditModeActive. local sent = nil for attempt = 1, 3 do sent = request("POST", "/result", { id = cmd.id, deliveryId = cmd.deliveryId, ok = supported, result = { requested = supported }, error = if supported then nil else "Нажмите Stop в Roblox Studio." }, token) if sent and sent.ok then break end task.wait(attempt) end if supported and sent and sent.ok then local ok, err = pcall(function() StudioTestService:EndTest("Sably: stop requested by user") end) if not ok then warn("[Sably] Нажмите Stop в Roblox Studio: " .. tostring(err)) end end end else task.wait(2) end end end) return end sessionCarrier:SetAttribute(SESSION_ATTRIBUTE, sessionId) local SERVICES = { "Workspace", "Players", "Lighting", "MaterialService", "ReplicatedFirst", "ReplicatedStorage", "ServerScriptService", "ServerStorage", "StarterGui", "StarterPack", "StarterPlayer", "Teams", "SoundService", "TextChatService", "Chat", } local function pathOf(inst) if inst == game then return "game" end local parts = {} local cur = inst while cur and cur ~= game do table.insert(parts, 1, cur.Name) cur = cur.Parent end return table.concat(parts, "/") end local function resolve(path) if type(path) ~= "string" then return nil end local p = string.gsub(string.gsub(path, "^%s+", ""), "%s+$", "") p = string.gsub(p, "^/+", "") if p == "" or p == "game" then return game end if string.sub(p, 1, 5) == "game/" then p = string.sub(p, 6) end local cur = game for seg in string.gmatch(p, "[^/]+") do local nextInst = nil for _, child in cur:GetChildren() do if child.Name == seg then if nextInst then error("Неоднозначный путь: несколько объектов «" .. seg .. "» в " .. pathOf(cur), 0) end nextInst = child end end if not nextInst and cur == game then local ok, svc = pcall(function() return game:GetService(seg) end) if ok then nextInst = svc end end if not nextInst then return nil, pathOf(cur), seg end cur = nextInst end return cur end local function need(path) local inst, at, seg = resolve(path) if not inst then if at then error(string.format("Не найден «%s» в %s", tostring(seg), at), 0) end error("Некорректный путь: " .. tostring(path), 0) end return inst end local function countLines(s) if type(s) ~= "string" or s == "" then return 0 end local _, n = string.gsub(s, "\n", "") return n + 1 end local function getSource(s) if ScriptEditorService then local ok, src = pcall(function() return ScriptEditorService:GetEditorSource(s) end) if not ok then error(tostring(src), 0) end if type(src) ~= "string" then error("Не удалось прочитать исходник редактора", 0) end return src end return s.Source end local function setSource(s, src, expected) if type(src) ~= "string" then error("source должен быть строкой", 0) end if #src > MAX_SOURCE then error("Исходник длиннее 200000 байт", 0) end if ScriptEditorService and s.Parent then local conflict = false local blocked = false local ok, err = pcall(function() ScriptEditorService:UpdateSourceAsync(s, function(current) if runState() ~= "edit" then blocked = true; return nil end if expected ~= nil and current ~= expected then conflict = true return nil end return src end) end) if blocked then requireEdit(); error("Режим Studio изменился. Перечитайте скрипт.", 0) end if conflict then error("Скрипт изменился после чтения. Перечитайте код и повторите правку.", 0) end if not ok then error(tostring(err), 0) end return end if expected ~= nil and s.Source ~= expected then error("Скрипт изменился после чтения. Перечитайте код и повторите правку.", 0) end s.Source = src end local function boundedInt(value, fallback, low, high) local n = if value == nil then fallback else tonumber(value) if not n or n ~= n or n == math.huge or n == -math.huge then error("Ожидалось конечное число", 0) end return math.clamp(math.floor(n), low, high) end -- Cut excerpts only between UTF-8 codepoints so JSONEncode can send Russian text. local function excerpt(s, first, bytes) local last = math.min(#s, first + bytes - 1) while first <= last and (string.byte(s, first) or 0) >= 128 and (string.byte(s, first) or 0) < 192 do first += 1 end if last < #s then local after = last + 1 while after > first and (string.byte(s, after) or 0) >= 128 and (string.byte(s, after) or 0) < 192 do after -= 1 end last = after - 1 end return string.sub(s, first, last) end local function jsonValue(v) local t = typeof(v) if t == "nil" then return { type = "nil" } end if t == "string" then return excerpt(v, 1, 8000) end if t == "boolean" then return v end if t == "number" then return if v == v and math.abs(v) ~= math.huge then v else tostring(v) end if t == "Instance" then return pathOf(v) end if t == "EnumItem" then return tostring(v) end if t == "Vector3" then return { v.X, v.Y, v.Z } end if t == "Vector2" then return { v.X, v.Y } end if t == "Color3" then return { v.R * 255, v.G * 255, v.B * 255 } end if t == "CFrame" then return { v:GetComponents() } end if t == "UDim" then return { v.Scale, v.Offset } end if t == "UDim2" then return { v.X.Scale, v.X.Offset, v.Y.Scale, v.Y.Offset } end if t == "BrickColor" then return v.Name end if t == "NumberRange" then return { v.Min, v.Max } end if t == "NumberSequence" or t == "ColorSequence" then local points = {} for _, p in v.Keypoints do table.insert(points, { time = p.Time, value = jsonValue(p.Value), envelope = if t == "NumberSequence" then p.Envelope else nil }) end return { type = t, keypoints = points } end return { type = t, value = tostring(v) } end local function sameValue(a: any, b: any): boolean if type(a) ~= type(b) then return false end if type(a) == "number" then return math.abs(a - b) <= 0.00001 * math.max(1, math.abs(a), math.abs(b)) end if type(a) ~= "table" then return a == b end for k, v in a do if not sameValue(v, b[k]) then return false end end for k in b do if a[k] == nil then return false end end return true end local function num(v, i) local n = v[i] if type(n) ~= "number" then error("ожидалось число на позиции " .. i, 0) end return n end local function parseColor(v) if type(v) == "string" then local hex = string.match(v, "^#?(%x%x%x%x%x%x)$") if hex then return Color3.fromHex(hex) end local bc = (BrickColor.new :: any)(v) if bc.Name == v then return bc.Color end elseif type(v) == "table" then return Color3.fromRGB(num(v, 1), num(v, 2), num(v, 3)) end error('цвет: ожидалось "#rrggbb" или [r,g,b] (0-255)', 0) end local function convert(current, v) local t = typeof(current) if t == "Vector3" and type(v) == "table" then return Vector3.new(num(v, 1), num(v, 2), num(v, 3)) elseif t == "Vector2" and type(v) == "table" then return Vector2.new(num(v, 1), num(v, 2)) elseif t == "Color3" then return parseColor(v) elseif t == "UDim2" and type(v) == "table" then return UDim2.new(num(v, 1), num(v, 2), num(v, 3), num(v, 4)) elseif t == "UDim" and type(v) == "table" then return UDim.new(num(v, 1), num(v, 2)) elseif t == "CFrame" and type(v) == "table" then if #v >= 12 then return CFrame.new(table.unpack(v, 1, 12)) end return current.Rotation + Vector3.new(num(v, 1), num(v, 2), num(v, 3)) elseif t == "EnumItem" then if type(v) == "string" then local name = string.match(v, "([%w_]+)$") or v for _, item in current.EnumType:GetEnumItems() do if item.Name == name then return item end end error(string.format("нет значения «%s» в Enum.%s", v, tostring(current.EnumType)), 0) elseif type(v) == "number" then for _, item in current.EnumType:GetEnumItems() do if item.Value == v then return item end end end elseif t == "BrickColor" then if type(v) == "string" then return (BrickColor.new :: any)(v) elseif type(v) == "table" then return BrickColor.new(parseColor(v)) end elseif t == "NumberRange" then if type(v) == "number" then return NumberRange.new(v) elseif type(v) == "table" then return NumberRange.new(num(v, 1), num(v, 2)) end elseif t == "NumberSequence" then if type(v) == "number" then return NumberSequence.new(v) elseif type(v) == "table" then return NumberSequence.new(num(v, 1), num(v, 2)) end elseif t == "ColorSequence" then return ColorSequence.new(parseColor(v)) elseif (t == "Instance" or t == "nil") and type(v) == "string" then return need(v) end return v end local function setProp(inst, key, value) if key == "Parent" then local parent = need(value) local existing = parent:FindFirstChild(inst.Name) if existing and existing ~= inst then error("В новом Parent уже есть объект с таким Name", 0) end inst.Parent = parent return end if key == "Source" and inst:IsA("LuaSourceContainer") then error("Для Source используйте edit_script или write_script", 0) end if key == "Name" and (type(value) ~= "string" or value == "" or string.find(value, "/", 1, true)) then error("Name должен быть непустым и не содержать /", 0) end if key == "Name" and inst.Parent then local existing = inst.Parent:FindFirstChild(value) if existing and existing ~= inst then error("Объект с таким Name уже существует", 0) end end local okGet, current = pcall(function() return inst[key] end) if not okGet then error(string.format("у %s нет свойства %s", inst.ClassName, key), 0) end local conv = convert(current, value) local okSet, err = pcall(function() inst[key] = conv end) if not okSet then error(tostring(err), 0) end end local function applyProps(inst, props, allowParent) local applied, errs = {}, nil if type(props) ~= "table" then return applied, errs end local count = 0 for _ in props do count += 1 end if count > 32 then error("Не больше 32 свойств за один вызов", 0) end local parentValue = nil for k, v in props do if type(k) == "string" then if k == "Parent" then parentValue = v else local ok, err = pcall(setProp, inst, k, v) if ok then table.insert(applied, k) else errs = errs or {} errs[k] = tostring(err) end end end end if parentValue ~= nil and allowParent then local ok, err = pcall(setProp, inst, "Parent", parentValue) if ok then table.insert(applied, "Parent") else errs = errs or {} errs.Parent = tostring(err) end end return applied, errs end local function describe(inst, depth) local kids = inst:GetChildren() local out = { name = inst.Name, className = inst.ClassName } if #kids > 0 then out.childCount = #kids end if depth > 0 and #kids > 0 then local list = {} for i, c in kids do if i > MAX_LIST then out.truncated = true break end table.insert(list, describe(c, depth - 1)) end out.children = list end return out end local function topLevel() local list = {} for _, name in SERVICES do local ok, svc = pcall(function() return game:GetService(name) end) if ok and svc then table.insert(list, svc) end end return list end local TOOLS = {} -- These tools are read-only and inspect the current editor state, including unsaved source. TOOLS.search_code = function(a) if type(a.query) ~= "string" or a.query == "" or #a.query > 1000 or string.find(a.query, "\n", 1, true) then error("query: непустой однострочный текст до 1000 байт", 0) end local roots = if a.path and a.path ~= "game" then { need(a.path) } else topLevel() local query = if a.caseSensitive then a.query else string.lower(a.query) local limit = boundedInt(a.limit, 30, 1, MAX_SEARCH) local results, scanned, skipped = {}, 0, 0 for _, root in roots do local all = { root } for _, inst in root:GetDescendants() do table.insert(all, inst) end for _, inst in all do if inst:IsA("LuaSourceContainer") then local ok, src = pcall(getSource, inst) if ok then scanned += 1 for line, text in string.split(src, "\n") do local hay = if a.caseSensitive then text else string.lower(text) local at = string.find(hay, query, 1, true) if at then if #results >= limit then return { results = results, scanned = scanned, skipped = skipped, truncated = true } end local start = math.max(1, at - 100) table.insert(results, { path = pathOf(inst), line = line, column = at, text = excerpt(text, start, 500), excerpt = #text > 500 }) end end else skipped += 1 end end end end return { results = results, scanned = scanned, skipped = skipped, truncated = false } end TOOLS.read_properties = function(a) local inst = need(a.path) local names = a.properties if names == nil then names = { "Name", "ClassName", "Parent", "Archivable" } if inst:IsA("BasePart") then for _, k in { "Anchored", "CanCollide", "Size", "CFrame", "Color", "Material", "Transparency" } do table.insert(names, k) end elseif inst:IsA("GuiObject") then for _, k in { "Position", "Size", "Visible", "BackgroundColor3", "LayoutOrder" } do table.insert(names, k) end elseif inst:IsA("BaseScript") then table.insert(names, "Disabled") end end if type(names) ~= "table" or #names > 32 then error("properties: список до 32 имён свойств", 0) end local props, errors, attributes = {}, {}, {} for _, name in names do if type(name) ~= "string" then error("Имя свойства должно быть строкой", 0) end local ok, value = pcall(function() if name == "Source" then error("Используйте read_script", 0) end local v = inst[name] if type(v) == "function" or typeof(v) == "RBXScriptSignal" then error("Это не свойство", 0) end return jsonValue(v) end) if ok then props[name] = value else errors[name] = tostring(value) end end for k, v in inst:GetAttributes() do attributes[k] = jsonValue(v) end return { path = pathOf(inst), className = inst.ClassName, properties = props, errors = errors, attributes = attributes, tags = CollectionService:GetTags(inst) } end TOOLS.get_tags = function(a) if a.tag ~= nil then if type(a.tag) ~= "string" or a.tag == "" then error("tag должен быть непустой строкой", 0) end local all, instances = CollectionService:GetTagged(a.tag), {} for _, inst in all do if #instances >= MAX_SEARCH then break end table.insert(instances, { path = pathOf(inst), className = inst.ClassName }) end return { tag = a.tag, instances = instances, total = #all, truncated = #all > #instances } end local tags = CollectionService:GetAllTags() table.sort(tags) local total = #tags while #tags > 200 do table.remove(tags) end return { tags = tags, total = total, truncated = total > #tags } end TOOLS.read_output = function(a) local ok, history = pcall(function() return LogService:GetLogHistory() end) if not ok then return { available = false, entries = {}, reason = tostring(history) } end local limit = boundedInt(a.limit, 30, 1, 50) local since = tonumber(a.since) or 0 local entries, total = {}, 0 for i = #history, 1, -1 do local e = history[i] if (e.messageType == Enum.MessageType.MessageError or e.messageType == Enum.MessageType.MessageWarning) and (e.timestamp or 0) >= since then total += 1 if #entries < limit then table.insert(entries, 1, { message = excerpt(e.message or "", 1, 700), level = e.messageType.Name, timestamp = e.timestamp }) end end end return { available = true, entries = entries, total = total, truncated = total > #entries, capturedAt = os.time(), runtimeVerified = false } end TOOLS.list_children = function(a) local inst = need(a.path or "game") local depth = math.clamp(math.floor(tonumber(a.depth) or 1), 1, 3) local all = if inst == game then topLevel() else inst:GetChildren() local children = {} for _, c in all do if #children >= MAX_LIST then break end local ok, d = pcall(describe, c, depth - 1) if ok then table.insert(children, d) end end return { path = pathOf(inst), className = inst.ClassName, count = #all, children = children, truncated = if #all > #children then true else nil, } end TOOLS.search = function(a) local q = if type(a.query) == "string" and a.query ~= "" then string.lower(a.query) else nil local cls = if type(a.className) == "string" and a.className ~= "" then a.className else nil if not q and not cls then error("Укажите query или className", 0) end local results, total = {}, 0 for _, root in topLevel() do local ok, descendants = pcall(function() return root:GetDescendants() end) if ok then for _, d in descendants do local match = (not q or string.find(string.lower(d.Name), q, 1, true) ~= nil) and (not cls or d.ClassName == cls or d:IsA(cls)) if match then total += 1 if #results < MAX_SEARCH then table.insert(results, { path = pathOf(d), className = d.ClassName }) end end end end end return { results = results, total = total } end TOOLS.read_script = function(a) local s = need(a.path) if not s:IsA("LuaSourceContainer") then error(string.format("%s — это %s, а не скрипт", pathOf(s), s.ClassName), 0) end local src = getSource(s) local lines = if src == "" then {} else string.split(src, "\n") local first = boundedInt(a.startLine, 1, 1, math.max(1, #lines)) if a.startLine and tonumber(a.startLine) > math.max(1, #lines) then error("startLine за концом скрипта", 0) end local last = boundedInt(a.endLine, first + 399, 1, math.max(1, #lines)) if last < first then error("endLine меньше startLine", 0) end last = math.min(last, first + 499, #lines) local selected, size, actualLast = {}, 0, first - 1 for i = first, last do if size + #lines[i] + 1 > 24000 then break end table.insert(selected, lines[i]) size += #lines[i] + 1 actualLast = i end if #selected == 0 and #lines > 0 then error("Строка длиннее 24000 байт. Используйте search_code для поиска и edit_script для точечной правки.", 0) end return { path = pathOf(s), className = s.ClassName, source = table.concat(selected, "\n"), lines = #lines, startLine = if #lines > 0 then first else 0, endLine = actualLast, nextLine = if actualLast < #lines then actualLast + 1 else nil, truncated = first > 1 or actualLast < #lines, } end TOOLS.get_selection = function() local list = {} for i, inst in Selection:Get() do if i > MAX_SEARCH then break end table.insert(list, { path = pathOf(inst), className = inst.ClassName }) end return { selection = list } end TOOLS.create_instance = function(a) local parent = need(a.parent) if type(a.className) ~= "string" or type(a.name) ~= "string" or a.name == "" or string.find(a.name, "/", 1, true) then error("Нужны className и name", 0) end if a.source ~= nil and (type(a.source) ~= "string" or #a.source > MAX_SOURCE) then error("source: строка до 200000 байт", 0) end if parent:FindFirstChild(a.name) then error(pathOf(parent) .. "/" .. a.name .. " уже существует", 0) end local okNew, inst = pcall(Instance.new, a.className) if not okNew then error(string.format("Нельзя создать объект класса «%s»", a.className), 0) end local isScript = inst:IsA("LuaSourceContainer") if isScript then inst.Source = a.source or "" end inst.Name = a.name local applied, errs = applyProps(inst, a.properties, false) if parent:FindFirstChild(inst.Name) then inst:Destroy() error("Объект с итоговым Name уже существует", 0) end inst.Parent = parent return { path = pathOf(inst), className = inst.ClassName, isScript = isScript, applied = applied, errors = errs } end TOOLS.write_script = function(a) local s = need(a.path) if not s:IsA("LuaSourceContainer") then error(string.format("%s — это %s, а не скрипт", pathOf(s), s.ClassName), 0) end if type(a.source) ~= "string" then error("source обязателен", 0) end local prev = getSource(s) if #prev > MAX_SOURCE then error("Скрипт длиннее 200000 байт: правка не выполнена", 0) end if a.expectedSource ~= nil and a.expectedSource ~= prev then error("Скрипт изменился после чтения. Перечитайте код.", 0) end setSource(s, a.source, prev) return { path = pathOf(s), className = s.ClassName, previousSource = prev, source = a.source } end TOOLS.edit_script = function(a) local s = need(a.path) if not s:IsA("LuaSourceContainer") then error("Это не скрипт", 0) end if type(a.oldText) ~= "string" or a.oldText == "" or type(a.newText) ~= "string" then error("Нужны непустой oldText и строка newText (может быть пустой)", 0) end local prev = getSource(s) if #prev > MAX_SOURCE then error("Скрипт длиннее 200000 байт: правка не выполнена", 0) end local first, last = string.find(prev, a.oldText, 1, true) if not first then error("oldText не найден. Перечитайте нужные строки и скопируйте точный фрагмент.", 0) end if string.find(prev, a.oldText, first + 1, true) then error("oldText встречается несколько раз. Добавьте контекст для единственного совпадения.", 0) end local source = string.sub(prev, 1, first - 1) .. a.newText .. string.sub(prev, last + 1) setSource(s, source, prev) return { path = pathOf(s), className = s.ClassName, previousSource = prev, source = source } end TOOLS.set_properties = function(a) local inst = need(a.path) if inst == game then error("Свойства game менять нельзя", 0) end if type(a.properties) ~= "table" then error("properties обязателен", 0) end local previousPath = pathOf(inst) local applied, errs = applyProps(inst, a.properties, true) if #applied == 0 and errs then local parts = {} for k, e in errs do table.insert(parts, k .. ": " .. e) end error(table.concat(parts, "; "), 0) end return { ok = true, path = pathOf(inst), previousPath = previousPath, className = inst.ClassName, applied = applied, errors = errs } end TOOLS.delete_instance = function(a) local inst = need(a.path) if inst == game or inst.Parent == game then error("Сервисы удалять нельзя", 0) end local path, cls = pathOf(inst), inst.ClassName local previousSource = if inst:IsA("LuaSourceContainer") then getSource(inst) else nil local lines = countLines(previousSource) inst.Parent = nil return { path = path, className = cls, lines = lines, previousSource = if previousSource then excerpt(previousSource, 1, MAX_SOURCE) else nil } end -- Internal server command, not a model tool. Re-resolve and re-read after the mutation. TOOLS.verify_change = function(a) local inst = resolve(a.path) local checks = {} local function check(kind, ok, message) table.insert(checks, { kind = kind, ok = ok, message = message }) end if a.exists == false then check("exists", inst == nil, if inst == nil then "Объект удалён" else "Объект всё ещё существует по этому пути") return { checks = checks } end check("exists", inst ~= nil, if inst then "Объект существует" else "Объект не найден после изменения") if not inst then return { checks = checks } end if a.className then check("class", inst.ClassName == a.className, "Класс объекта: " .. inst.ClassName) end if a.source ~= nil then local ok, source = pcall(getSource, inst) check("source", ok and source == a.source, if ok and source == a.source then "Исходник перечитан и совпадает" else "Исходник не совпадает или недоступен") end for name, value in a.properties or {} do local ok, matches = pcall(function() if a.propertyErrors and a.propertyErrors[name] then return false end if name == "Source" then return false end local current = inst[name] local expected = if name == "Parent" then need(value) else convert(current, value) if typeof(current) == "CFrame" and type(value) == "table" and #value == 3 then return sameValue(jsonValue(current.Position), value) end if type(current) == "string" or typeof(current) == "Instance" then return current == expected end return sameValue(jsonValue(current), jsonValue(expected)) end) check("property:" .. name, ok and matches == true, name .. (if ok and matches then " — совпадает" else " — не применилось или отличается")) end return { checks = checks } end local MUTATING = { create_instance = "Sably: создание объекта", write_script = "Sably: изменение скрипта", edit_script = "Sably: правка фрагмента скрипта", set_properties = "Sably: изменение свойств", delete_instance = "Sably: удаление объекта", } local function recorded(name, fn) local rec = nil pcall(function() rec = ChangeHistoryService:TryBeginRecording(name, name) end) local ok, res = pcall(fn) if rec then pcall(function() ChangeHistoryService:FinishRecording( rec, if ok then Enum.FinishRecordingOperation.Commit else Enum.FinishRecordingOperation.Cancel ) end) else pcall(function() ChangeHistoryService:SetWaypoint(name) end) end if not ok then error(res, 0) end return res end local function execute(cmd) requireEdit() local fn = TOOLS[cmd.tool] if not fn then error("Неизвестная команда " .. tostring(cmd.tool), 0) end local args = if type(cmd.args) == "table" then cmd.args else {} if MUTATING[cmd.tool] then return recorded(MUTATING[cmd.tool], function() return fn(args) end) end return fn(args) end local function leaf(p) return string.match(tostring(p or ""), "([^/]+)$") or "game" end local function stepTitle(tool, a) a = if type(a) == "table" then a else {} if tool == "list_children" then return "Смотрю " .. tostring(a.path or "game") elseif tool == "search" then return "Ищу «" .. tostring(a.query or a.className or "") .. "»" elseif tool == "search_code" then return "Ищу в коде «" .. tostring(a.query or "") .. "»" elseif tool == "read_script" then return "Читаю " .. leaf(a.path) elseif tool == "read_properties" then return "Изучаю свойства " .. leaf(a.path) elseif tool == "get_tags" then return "Изучаю теги" elseif tool == "read_output" then return "Читаю ошибки Output" elseif tool == "verify_change" then return "Проверяю " .. leaf(a.path) elseif tool == "get_selection" then return "Смотрю выделение" elseif tool == "create_instance" then return "Создаю " .. tostring(a.className) .. " «" .. tostring(a.name) .. "»" elseif tool == "write_script" then return "Переписываю " .. leaf(a.path) elseif tool == "edit_script" then return "Правлю фрагмент " .. leaf(a.path) elseif tool == "set_properties" then return "Меняю " .. leaf(a.path) elseif tool == "delete_instance" then return "Удаляю " .. leaf(a.path) end return tostring(tool) end local C = { bg = Color3.fromRGB(11, 11, 13), surface = Color3.fromRGB(19, 19, 21), surface2 = Color3.fromRGB(28, 28, 31), warm = Color3.fromRGB(33, 29, 23), line = Color3.fromRGB(40, 40, 45), text = Color3.fromRGB(236, 236, 237), muted = Color3.fromRGB(163, 163, 169), faint = Color3.fromRGB(108, 108, 116), strong = Color3.fromRGB(246, 244, 239), white = Color3.fromRGB(255, 255, 255), ink = Color3.fromRGB(23, 19, 13), sand = Color3.fromRGB(216, 188, 146), green = Color3.fromRGB(111, 207, 151), red = Color3.fromRGB(240, 122, 115), amber = Color3.fromRGB(232, 180, 90), } -- Builder Sans — современный шрифт Roblox, по характеру ближе всего к Inter на сайте. local FONT = Enum.Font.BuilderSans local FONT_MEDIUM = Enum.Font.BuilderSansMedium local FONT_BOLD = Enum.Font.BuilderSansBold local FONT_MONO = Enum.Font.RobotoMono local AUTO_Y = Enum.AutomaticSize.Y local function new(className, props, children) local inst = Instance.new(className) for k, v in props or {} do if k ~= "Parent" then inst[k] = v end end for _, c in children or {} do c.Parent = inst end if props and props.Parent then inst.Parent = props.Parent end return inst end local function corner(r) return new("UICorner", { CornerRadius = UDim.new(0, r) }) end local function stroke(color, transparency, thickness) return new("UIStroke", { Color = color, Thickness = thickness or 1, Transparency = transparency or 0, ApplyStrokeMode = Enum.ApplyStrokeMode.Border, }) end local function padding(t, r, b, l) return new("UIPadding", { PaddingTop = UDim.new(0, t), PaddingRight = UDim.new(0, r or t), PaddingBottom = UDim.new(0, b or t), PaddingLeft = UDim.new(0, l or r or t), }) end local function vlist(gap) return new("UIListLayout", { Padding = UDim.new(0, gap), SortOrder = Enum.SortOrder.LayoutOrder, FillDirection = Enum.FillDirection.Vertical, }) end local function hlist(gap) return new("UIListLayout", { Padding = UDim.new(0, gap), SortOrder = Enum.SortOrder.LayoutOrder, FillDirection = Enum.FillDirection.Horizontal, VerticalAlignment = Enum.VerticalAlignment.Center, }) end local function group(order, gap, parent) return new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, LayoutOrder = order, Parent = parent, }, { vlist(gap) }) end local function label(text, size, color, order, font) return new("TextLabel", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, Font = font or FONT, TextSize = size, TextColor3 = color, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, LineHeight = 1.2, Text = text, LayoutOrder = order or 0, }) end local function tween(inst, props, t, style) TweenService:Create(inst, TweenInfo.new(t or 0.14, style or Enum.EasingStyle.Quad, Enum.EasingDirection.Out), props):Play() end -- Кнопки как на сайте: светлая основная, тёмная с обводкой и тихая текстовая. local function makeButton(text, kind, order) local primary = kind == "primary" local quiet = kind == "quiet" or kind == "danger" local b = new("TextButton", { Size = UDim2.new(1, 0, 0, if primary then 40 elseif quiet then 30 else 36), BackgroundColor3 = if primary then C.strong else C.surface2, BackgroundTransparency = if quiet then 1 else 0, AutoButtonColor = false, Font = if primary then FONT_BOLD else FONT_MEDIUM, TextSize = if primary then 15 elseif quiet then 13 else 14, TextColor3 = if primary then C.ink elseif kind == "danger" then C.red elseif quiet then C.muted else C.text, Text = text, LayoutOrder = order or 0, }, { corner(10) }) if kind == "secondary" then stroke(C.line).Parent = b end b:SetAttribute("enabled", true) b:SetAttribute("kind", kind) b.MouseEnter:Connect(function() if not b:GetAttribute("enabled") then return end if primary then tween(b, { BackgroundColor3 = C.white }) elseif quiet then tween(b, { BackgroundTransparency = 0.35 }) else tween(b, { BackgroundColor3 = C.line }) end end) b.MouseLeave:Connect(function() if primary then tween(b, { BackgroundColor3 = C.strong }) elseif quiet then tween(b, { BackgroundTransparency = 1 }) else tween(b, { BackgroundColor3 = C.surface2 }) end end) return b end local function setEnabled(b, on) b:SetAttribute("enabled", on) b.Active = on b.TextTransparency = if on then 0 else 0.45 if b:GetAttribute("kind") == "primary" then b.BackgroundColor3 = C.strong b.BackgroundTransparency = if on then 0 else 0.35 end end local toolbar = plugin:CreateToolbar("Sably") local toolbarButton = toolbar:CreateButton("SablyOpen", "Панель Sably: вход и связь с чатом", "", "Sably") toolbarButton.ClickableWhenViewportHidden = true -- Окно открывается само только пока Studio не подключена к Sably. Studio по умолчанию -- восстанавливает, было ли окно открыто в прошлый раз, поэтому третий аргумент (true) -- отключает это восстановление: решение при запуске принимает сам плагин. local widgetInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, true, 360, 540, 300, 420) local okWidget, widget = pcall(function() return (plugin :: any):CreateDockWidgetPluginGuiAsync("SablyAuthWidget", widgetInfo) end) if not okWidget or not widget then widget = (plugin :: any):CreateDockWidgetPluginGui("SablyAuthWidget", widgetInfo) end widget.Title = "Sably" widget.Name = "Sably" widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling toolbarButton.Click:Connect(function() widget.Enabled = not widget.Enabled end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() toolbarButton:SetActive(widget.Enabled) end) local root = new("ScrollingFrame", { Size = UDim2.fromScale(1, 1), BackgroundColor3 = C.bg, BorderSizePixel = 0, ScrollBarThickness = 4, ScrollBarImageColor3 = C.line, CanvasSize = UDim2.new(0, 0, 0, 0), AutomaticCanvasSize = AUTO_Y, ScrollingDirection = Enum.ScrollingDirection.Y, Parent = widget, }, { padding(14, 16, 16, 16), vlist(12) }) -- Шапка: знак Sably, имя, версия в рамке (как на сайте) и статус справа. local header = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 30), LayoutOrder = 1, Parent = root, }) local brandRow = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, -140, 1, 0), Parent = header, }, { hlist(9) }) local brandMark = new("Frame", { Size = UDim2.fromOffset(24, 24), BackgroundTransparency = 1, LayoutOrder = 1, Parent = brandRow, }) local function brandSegment(x1, y1, x2, y2) local scale = 24 / 32 local dx, dy = x2 - x1, y2 - y1 new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromOffset((x1 + x2) / 2 * scale, (y1 + y2) / 2 * scale), Size = UDim2.fromOffset(math.sqrt(dx * dx + dy * dy) * scale + 3, 4 * scale), Rotation = math.deg(math.atan2(dy, dx)), BackgroundColor3 = C.strong, BorderSizePixel = 0, Parent = brandMark, }, { corner(2) }) end brandSegment(24, 8, 12, 8) brandSegment(12, 8, 7, 13) brandSegment(7, 13, 25, 19) brandSegment(25, 19, 20, 24) brandSegment(20, 24, 8, 24) new("TextLabel", { BackgroundTransparency = 1, Size = UDim2.fromOffset(0, 30), AutomaticSize = Enum.AutomaticSize.X, Font = FONT_BOLD, TextSize = 18, TextColor3 = C.strong, Text = "Sably", LayoutOrder = 2, Parent = brandRow, }) new("TextLabel", { BackgroundTransparency = 1, Size = UDim2.fromOffset(0, 17), AutomaticSize = Enum.AutomaticSize.X, Font = FONT_MONO, TextSize = 10, TextColor3 = C.faint, Text = "v" .. VERSION, LayoutOrder = 3, Parent = brandRow, }, { corner(5), stroke(C.line), padding(0, 5, 0, 5) }) local pill = new("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.fromOffset(0, 24), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = C.surface, Parent = header, }, { corner(12), stroke(C.line), padding(0, 10, 0, 9), hlist(7) }) local pillDot = new("Frame", { Size = UDim2.fromOffset(6, 6), BackgroundColor3 = C.faint, LayoutOrder = 1, Parent = pill, }, { corner(3) }) local pillText = new("TextLabel", { BackgroundTransparency = 1, Size = UDim2.fromOffset(0, 24), AutomaticSize = Enum.AutomaticSize.X, Font = FONT_MEDIUM, TextSize = 12, TextColor3 = C.muted, Text = "", LayoutOrder = 2, Parent = pill, }) -- Главная карточка: тёплый свет сверху, золотая линия по краю и несколько звёзд — как на сайте. local card = new("Frame", { BackgroundColor3 = C.white, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, ClipsDescendants = true, LayoutOrder = 2, Parent = root, }, { corner(14), stroke(C.line), new("UIGradient", { Rotation = 90, Color = ColorSequence.new({ ColorSequenceKeypoint.new(0, C.warm), ColorSequenceKeypoint.new(0.42, C.surface), ColorSequenceKeypoint.new(1, C.surface), }), }), }) new("Frame", { BackgroundColor3 = C.sand, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1), ZIndex = 3, Parent = card, }, { new("UIGradient", { Transparency = NumberSequence.new({ NumberSequenceKeypoint.new(0, 1), NumberSequenceKeypoint.new(0.5, 0.2), NumberSequenceKeypoint.new(1, 1), }), }), }) local skyDecor = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 112), ZIndex = 1, Parent = card, }) -- x, y (доли), размер, прозрачность, тёплый цвет, мерцает local STARS = { { 0.6, 0.2, 1, 0.55 }, { 0.68, 0.46, 1, 0.7 }, { 0.74, 0.14, 2, 0.35, false, true }, { 0.8, 0.36, 1, 0.6 }, { 0.86, 0.2, 3, 0.15, true, true }, { 0.9, 0.56, 1, 0.5 }, { 0.95, 0.3, 1, 0.65 }, { 0.64, 0.72, 1, 0.75 }, { 0.78, 0.66, 2, 0.55, false, true }, { 0.93, 0.8, 1, 0.7 }, { 0.71, 0.9, 1, 0.8 }, { 0.98, 0.1, 1, 0.6 }, } local twinklers = {} for _, s in STARS do local dot = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(s[1], s[2]), Size = UDim2.fromOffset(s[3], s[3]), BackgroundColor3 = if s[5] then C.sand else C.strong, BackgroundTransparency = s[4], BorderSizePixel = 0, ZIndex = 1, Parent = skyDecor, }, { corner(2) }) if s[3] >= 3 then new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(11, 11), BackgroundColor3 = C.sand, BackgroundTransparency = 0.88, BorderSizePixel = 0, ZIndex = 1, Parent = dot, }, { corner(6) }) end if s[6] then table.insert(twinklers, { dot = dot, base = s[4] }) end end local body = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, ZIndex = 2, Parent = card, }, { padding(18, 18, 18, 18), vlist(14) }) local eyebrow = label("", 11, C.sand, 1, FONT_MONO) eyebrow.Parent = body local heading = group(2, 6, body) local title = label("", 20, C.strong, 1, FONT_BOLD) title.Parent = heading local sub = label("", 13, C.muted, 2) sub.LineHeight = 1.3 sub.Parent = heading -- Шаги подключения — видны, пока Studio не подключена. local steps = group(3, 10, body) local function stepRow(n, text) local row = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, LayoutOrder = n, Parent = steps, }) new("TextLabel", { BackgroundColor3 = C.bg, Size = UDim2.fromOffset(20, 20), Font = FONT_MONO, TextSize = 11, TextColor3 = C.sand, Text = tostring(n), Parent = row, }, { corner(10), stroke(C.line) }) local t = label(text, 13, C.text, 0) t.Position = UDim2.fromOffset(30, 2) t.Size = UDim2.new(1, -30, 0, 0) t.Parent = row end stepRow(1, "Получите код в этом окне") stepRow(2, "Откройте сайт Sably → «Войти» и введите код") stepRow(3, "Подтвердите вход — Studio подключится сама") -- Код: восемь ячеек двумя группами по четыре и тонкая полоска оставшегося времени. local codeWrap = group(3, 10, body) codeWrap.Visible = false local codeRow = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 48), LayoutOrder = 1, Parent = codeWrap, }) local cells = {} for g = 0, 1 do local half = new("Frame", { BackgroundTransparency = 1, Position = UDim2.new(g * 0.5, g * 5, 0, 0), Size = UDim2.new(0.5, -5, 1, 0), Parent = codeRow, }, { new("UIGridLayout", { CellSize = UDim2.new(0.25, -4, 1, 0), CellPadding = UDim2.fromOffset(5, 0), SortOrder = Enum.SortOrder.LayoutOrder, }), }) for i = 1, 4 do table.insert(cells, new("TextLabel", { BackgroundColor3 = C.bg, Font = FONT_MONO, TextSize = 22, TextColor3 = C.strong, Text = "", LayoutOrder = i, Parent = half, }, { corner(9), stroke(C.sand, 0.6) })) end end local timerBar = new("Frame", { BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 2), LayoutOrder = 2, Parent = codeWrap, }, { corner(1) }) local timerFill = new("Frame", { BackgroundColor3 = C.sand, BorderSizePixel = 0, Size = UDim2.fromScale(1, 1), Parent = timerBar, }, { corner(1) }) local urlWrap = group(4, 6, body) urlWrap.Visible = false label("Страница входа — выделите и скопируйте адрес", 11, C.faint, 1).Parent = urlWrap local urlBox = new("TextBox", { BackgroundColor3 = C.bg, Size = UDim2.new(1, 0, 0, 32), Font = FONT_MONO, TextSize = 12, TextColor3 = C.sand, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, ClearTextOnFocus = false, TextEditable = false, Text = "", LayoutOrder = 2, Parent = urlWrap, }, { corner(8), stroke(C.line), padding(0, 10, 0, 10) }) -- Подключённый аккаунт: аватар Roblox с индикатором связи, имя и текущий плейс. local account = new("Frame", { BackgroundColor3 = C.bg, BackgroundTransparency = 0.25, Size = UDim2.new(1, 0, 0, 58), LayoutOrder = 3, Visible = false, Parent = body, }, { corner(12), stroke(C.line) }) local avatar = new("ImageLabel", { BackgroundColor3 = C.surface2, Position = UDim2.fromOffset(11, 11), Size = UDim2.fromOffset(36, 36), Image = "", Parent = account, }, { corner(18) }) local accDot = new("Frame", { Position = UDim2.fromOffset(38, 38), Size = UDim2.fromOffset(10, 10), BackgroundColor3 = C.faint, ZIndex = 3, Parent = account, }, { corner(5), stroke(C.bg, 0, 2) }) local accName = new("TextLabel", { BackgroundTransparency = 1, Position = UDim2.fromOffset(58, 11), Size = UDim2.new(1, -70, 0, 18), Font = FONT_BOLD, TextSize = 15, TextColor3 = C.strong, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, Text = "", Parent = account, }) local accPlace = new("TextLabel", { BackgroundTransparency = 1, Position = UDim2.fromOffset(58, 30), Size = UDim2.new(1, -70, 0, 16), Font = FONT, TextSize = 12, TextColor3 = C.muted, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, Text = "", Parent = account, }) local status = label("", 12, C.faint, 6) status.Visible = false status.Parent = body local primaryBtn = makeButton("Получить код", "primary", 7) primaryBtn.Parent = body local cancelBtn = makeButton("Отмена", "secondary", 8) cancelBtn.Visible = false cancelBtn.Parent = body -- Журнал действий ассистента — отдельная тихая карточка под главной. local activity = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = AUTO_Y, LayoutOrder = 3, Visible = false, Parent = root, }, { corner(14), stroke(C.line), padding(14, 16, 14, 16), vlist(8) }) label("Последние действия", 11, C.faint, 0, FONT_MONO).Parent = activity local activityEmpty = label("Пока пусто. Включите «Studio» в чате Sably и опишите задачу.", 12, C.faint, 1) activityEmpty.Parent = activity local unlinkBtn = makeButton("Отвязать Studio", "danger", 4) unlinkBtn.Visible = false unlinkBtn.Parent = root local footer = label("Sably не связан с Roblox Corporation", 11, C.faint, 5) footer.TextXAlignment = Enum.TextXAlignment.Center footer.Parent = root local logRows = {} local logCount = 0 local function pushLog(ok, text) activityEmpty.Visible = false logCount += 1 local row = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 20), LayoutOrder = 1000000 - logCount, Parent = activity, }, { new("Frame", { AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 0, 0.5, 0), Size = UDim2.fromOffset(6, 6), BackgroundColor3 = if ok then C.green else C.red, }, { corner(3) }), new("TextLabel", { BackgroundTransparency = 1, Position = UDim2.fromOffset(15, 0), Size = UDim2.new(1, -62, 1, 0), Font = FONT, TextSize = 13, TextColor3 = if ok then C.text else C.red, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, Text = text, }), new("TextLabel", { BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 0, 0, 0), Size = UDim2.new(0, 44, 1, 0), Font = FONT_MONO, TextSize = 11, TextColor3 = C.faint, TextXAlignment = Enum.TextXAlignment.Right, Text = os.date("%H:%M"), }), }) table.insert(logRows, 1, row) while #logRows > 6 do local old = table.remove(logRows) if old then old:Destroy() end end end -- Звёзды в карточке мягко мерцают, только пока окно открыто. task.spawn(function() local t = 0 while true do task.wait(1.4) if widget.Enabled then t += 1 for i, s in twinklers do local on = (t + i) % 3 == 0 tween(s.dot, { BackgroundTransparency = if on then math.min(0.9, s.base + 0.45) else s.base }, 1.3, Enum.EasingStyle.Sine) end end end end) local function setPill(text, color) pillText.Text = text pillText.TextColor3 = if color == C.faint then C.muted else color pillDot.BackgroundColor3 = color accDot.BackgroundColor3 = color end local function setStatus(text, color) status.Text = text or "" status.TextColor3 = color or C.faint status.Visible = text ~= nil and text ~= "" end local function showCode(code, url) if code then -- Сервер присылает код как «ABCD-EFGH»: дефис не занимает ячейку — его роль играет -- промежуток между группами по четыре. local chars = string.upper((string.gsub(tostring(code), "[^%w]", ""))) for i, cell in cells do cell.Text = string.sub(chars, i, i) end end if url then urlBox.Text = url end end local function linkedUser() local u = plugin:GetSetting(SETTING_USER) return if type(u) == "string" then u else "?" end local function placeName() local n = game.Name return if n ~= "" then n else "Без названия" end local function studioUserId() local ok, id = pcall(function() return StudioService:GetUserId() end) return if ok and type(id) == "number" then id else 0 end local view = "idle" local online = nil -- Какие блоки видны в каждом состоянии окна. local function layout(v) steps.Visible = v == "idle" codeWrap.Visible = v == "code" urlWrap.Visible = v == "code" account.Visible = v == "linked" activity.Visible = v == "linked" unlinkBtn.Visible = v == "linked" primaryBtn.Visible = v == "idle" cancelBtn.Visible = v == "code" end local function renderLinked() view = "linked" layout("linked") eyebrow.Text = "Подключено" title.Text = "Studio подключена" sub.Text = "В чате Sably включите «Studio» — ассистент сам изучит и изменит плейс. Любое изменение отменяется через Ctrl+Z." accName.Text = "@" .. linkedUser() accPlace.Text = "Плейс «" .. placeName() .. "»" local uid = studioUserId() avatar.Image = if uid > 0 then string.format("rbxthumb://type=AvatarHeadShot&id=%d&w=150&h=150", uid) else "" if online == false then setPill("Нет связи", C.amber) elseif online then setPill("В сети", C.green) else setPill("Подключаемся", C.faint) end end local function renderIdle(err) view = "idle" layout("idle") eyebrow.Text = "Подключение" title.Text = "Подключите Studio к Sably" sub.Text = "Ассистент сможет изучать и менять ваш плейс прямо из чата на сайте." primaryBtn.Text = "Получить код" setEnabled(primaryBtn, true) setPill("Не подключено", C.faint) setStatus(err, C.red) end local bridgeToken = nil local unlink local function setOnline(on, err) if online == on then return end online = on if view == "linked" then renderLinked() if on then setStatus("Соединение с сайтом активно", C.green) else setStatus((err or "Нет связи с сайтом") .. " Переподключаемся…", C.amber) end end end local function handleCommand(cmd, token) if type(cmd) ~= "table" or type(cmd.id) ~= "string" then return end local text = stepTitle(cmd.tool, cmd.args) local ok, res = pcall(execute, cmd) local blocked = not ok and type(res) == "table" and res.code == "STUDIO_PLAY_MODE" local body = if ok then { id = cmd.id, ok = true, result = res } else { id = cmd.id, ok = false, error = if blocked then res.message else tostring(res), code = if blocked then res.code else nil, runState = if blocked then res.runState else nil } if not pcall(function() HttpService:JSONEncode(body) end) then body = { id = cmd.id, ok = false, error = "Не удалось передать результат на сайт" } end body.deliveryId = cmd.deliveryId -- Retry the receipt, never the operation: reconnects must not repeat mutations. for attempt = 1, 3 do local sent = request("POST", "/result", body, token) if sent then break end if attempt < 3 then task.wait(attempt) end end if blocked then setStatus(res.message, C.amber); return end local verified = true if ok and cmd.tool == "verify_change" then for _, c in res.checks do if not c.ok then verified = false end end end pushLog(ok and verified, if not ok then text .. " — " .. tostring(res) elseif not verified then text .. " — есть несоответствия" else text) if MUTATING[cmd.tool] then if ok then print("[Sably] " .. text) else warn("[Sably] " .. text .. ": " .. tostring(res)) end end end local function placeId() local ok, id = pcall(function() return game.PlaceId end) return if ok and type(id) == "number" then id else 0 end local stateSeq = 0 local function sessionPayload() stateSeq += 1 return { sessionId = sessionId, place = placeName(), placeId = placeId(), version = VERSION, kind = "edit", runState = runState(), stateSeq = stateSeq } end -- Independent of long-poll: Play/Pause/Stop changes must arrive immediately. -- A periodic heartbeat also covers missed transitions and keeps the edit session alive. local function publishState() local token = currentToken if token then request("POST", "/state", sessionPayload(), token) end end pcall(function() StudioTestService:GetPropertyChangedSignal("EditModeActive"):Connect(function() task.spawn(publishState) end) end) pcall(function() RunService:GetPropertyChangedSignal("RunState"):Connect(function() task.spawn(publishState) end) end) task.spawn(function() while alive do task.wait(5); if alive then publishState() end end end) local function startBridge(token) currentToken = token if bridgeToken == token then return end bridgeToken = token -- Long-poll держит запрос до 20 секунд, поэтому связь подтверждаем отдельным быстрым запросом. task.spawn(function() local hb = request("POST", "/heartbeat", {}, token) if hb and alive and currentToken == token then setOnline(true) end end) task.spawn(function() local backoff = 1 while alive and bridgeToken == token and currentToken == token do local okIter, iterErr = pcall(function() local data, err, code = request("POST", "/poll", sessionPayload(), token) if not alive or currentToken ~= token then return end if code == 401 then bridgeToken = nil unlink("Сайт отключил эту Studio: " .. tostring(err) .. ". Авторизуйтесь заново.") return end if not data then setOnline(false, err) task.wait(backoff) backoff = math.min(backoff * 2, 30) else backoff = 1 setOnline(true) if type(data.commands) == "table" then for _, cmd in data.commands do handleCommand(cmd, token) end end end end) if not okIter then warn("[Sably] Ошибка связи с сайтом: " .. tostring(iterErr)) task.wait(backoff) backoff = math.min(backoff * 2, 30) end end if bridgeToken == token then bridgeToken = nil end end) end unlink = function(reason) local t = currentToken currentToken = nil online = nil if t then task.spawn(request, "POST", "/bye", { sessionId = sessionId }, t) end plugin:SetSetting(SETTING_TOKEN, nil) plugin:SetSetting(SETTING_USER, nil) if reason then warn("[Sably] " .. reason) end renderIdle(reason) -- Studio больше не подключена — окно снова открывается, чтобы сразу можно было войти. widget.Enabled = true end local attempt = 0 local function authorize() if view == "requesting" or view == "code" then return end local userId = studioUserId() if userId <= 0 then setStatus("Войдите в аккаунт Roblox в Studio и попробуйте снова.", C.red) return end attempt += 1 local my = attempt view = "requesting" primaryBtn.Text = "Получаем код…" setEnabled(primaryBtn, false) setStatus(nil) local data, err = request("POST", "/start", { userId = userId, deviceId = getDeviceId() }) if my ~= attempt then return end if not data then renderIdle(err or "Ошибка") primaryBtn.Text = "Попробовать снова" return end view = "code" layout("code") eyebrow.Text = "Код для входа" title.Text = "Введите код на сайте" sub.Text = "Откройте страницу входа Sably в браузере и введите эти символы. Код одноразовый." showCode(data.code, data.siteUrl) timerFill.Size = UDim2.fromScale(1, 1) setPill("Ждём код", C.amber) local ttl = tonumber(data.expiresIn) or 600 local deadline = os.clock() + ttl local requestId = HttpService:UrlEncode(data.requestId) while alive and my == attempt and os.clock() < deadline do local left = math.max(0, math.floor(deadline - os.clock())) setStatus(string.format("@%s · код действует ещё %d:%02d", tostring(data.username), math.floor(left / 60), left % 60), C.faint) tween(timerFill, { Size = UDim2.fromScale(math.clamp(left / ttl, 0, 1), 1) }, 0.4) task.wait(POLL_SEC) if my ~= attempt then return end local st, perr = request("GET", "/status?requestId=" .. requestId) if my ~= attempt then return end if st and st.status == "linked" and st.pluginToken then plugin:SetSetting(SETTING_TOKEN, st.pluginToken) plugin:SetSetting(SETTING_USER, st.username) print("[Sably] Studio подключена к @" .. tostring(st.username)) online = nil renderLinked() setStatus("Готово! Окно закроется само — кнопка Sably останется на вкладке Plugins.", C.green) startBridge(st.pluginToken) task.delay(3, function() if alive and view == "linked" then widget.Enabled = false end end) return elseif st and st.status == "expired" then break elseif not st and perr then setStatus(perr, C.red) end end if my ~= attempt or not alive then return end renderIdle() eyebrow.Text = "Код истёк" title.Text = "Получите новый код" sub.Text = "Код действует 10 минут. Получите новый и введите его на сайте." primaryBtn.Text = "Получить новый код" end primaryBtn.MouseButton1Click:Connect(function() if primaryBtn:GetAttribute("enabled") then task.spawn(authorize) end end) cancelBtn.MouseButton1Click:Connect(function() if view == "code" or view == "requesting" then attempt += 1 renderIdle() end end) unlinkBtn.MouseButton1Click:Connect(function() if view == "linked" then unlink(nil) setStatus("Studio отвязана. Чтобы снова работать с Sably, получите новый код.", C.faint) end end) plugin.Unloading:Connect(function() alive = false if sessionCarrier:GetAttribute(SESSION_ATTRIBUTE) == sessionId then sessionCarrier:SetAttribute(SESSION_ATTRIBUTE, nil) end local t = currentToken if t then pcall(request, "POST", "/bye", { sessionId = sessionId }, t) end end) -- Запуск: если Studio уже подключена, окно остаётся закрытым (связь работает в фоне, -- окно открывается кнопкой Sably на вкладке Plugins). Если нет — окно открывается само. local saved = plugin:GetSetting(SETTING_TOKEN) if type(saved) == "string" and #saved > 0 then renderLinked() setStatus("Подключаемся к сайту…", C.faint) widget.Enabled = false startBridge(saved) else renderIdle() widget.Enabled = true -- Вход мог завершиться в другом окне Studio: тогда подхватываем токен и закрываем окно, -- если пользователь в нём ничего не начал. task.spawn(function() while alive and not currentToken do task.wait(5) local t = plugin:GetSetting(SETTING_TOKEN) if type(t) == "string" and #t > 0 and not currentToken and view ~= "code" and view ~= "requesting" then online = nil renderLinked() startBridge(t) widget.Enabled = false return end end end) end