merge impl-v2: plugin

This commit is contained in:
Lucien Greathouse
2018-06-10 22:53:22 -07:00
parent 7d7f671920
commit e30545c132
13 changed files with 243 additions and 859 deletions

View File

@@ -1,4 +0,0 @@
[*.lua]
indent_style = tab
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -39,6 +39,10 @@ stds.testez = {
ignore = {
"212", -- unused arguments
"421", -- shadowing local variable
"422", -- shadowing argument
"431", -- shadowing upvalue
"432", -- shadowing upvalue argument
}
std = "lua51+roblox"

View File

@@ -18,14 +18,14 @@
"path": "modules/roact-rodux/lib",
"target": "ReplicatedStorage.Rojo.modules.RoactRodux"
},
"modules/promise": {
"path": "modules/promise/lib",
"target": "ReplicatedStorage.Rojo.modules.Promise"
},
"modules/testez": {
"path": "modules/testez/lib",
"target": "ReplicatedStorage.TestEZ"
},
"modules/promise": {
"path": "modules/promise/lib",
"target": "ReplicatedStorage.Rojo.modules.Promise"
},
"tests": {
"path": "tests",
"target": "TestService"

View File

@@ -1,114 +0,0 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Parent.modules.Promise)
local Config = require(script.Parent.Config)
local Version = require(script.Parent.Version)
local Api = {}
Api.__index = Api
Api.Error = {
ServerIdMismatch = "ServerIdMismatch",
}
setmetatable(Api.Error, {
__index = function(_, key)
error("Invalid API.Error name " .. key, 2)
end
})
--[[
Api.connect(Http) -> Promise<Api>
Create a new Api using the given HTTP implementation.
Attempting to invoke methods on an invalid conext will throw errors!
]]
function Api.connect(http)
local context = {
http = http,
serverId = nil,
currentTime = 0,
}
setmetatable(context, Api)
return context:_start()
:andThen(function()
return context
end)
end
function Api:_start()
return self.http:get("/")
:andThen(function(response)
response = response:json()
if response.protocolVersion ~= Config.protocolVersion then
local message = (
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
"\nYour server is version %s, with protocol version %s." ..
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedApiVersionString,
response.serverVersion, response.protocolVersion
)
return Promise.reject(message)
end
self.serverId = response.serverId
self.currentTime = response.currentTime
end)
end
function Api:getInfo()
return self.http:get("/")
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
return response
end)
end
function Api:read(paths)
local body = HttpService:JSONEncode(paths)
return self.http:post("/read", body)
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
return response.items
end)
end
function Api:getChanges()
local url = ("/changes/%f"):format(self.currentTime)
return self.http:get(url)
:andThen(function(response)
response = response:json()
if response.serverId ~= self.serverId then
return Promise.reject(Api.Error.ServerIdMismatch)
end
self.currentTime = response.currentTime
return response.changes
end)
end
return Api

119
plugin/src/ApiContext.lua Normal file
View File

@@ -0,0 +1,119 @@
local Promise = require(script.Parent.Parent.modules.Promise)
local Config = require(script.Parent.Config)
local Version = require(script.Parent.Version)
local Http = require(script.Parent.Http)
local HttpError = require(script.Parent.HttpError)
local ApiContext = {}
ApiContext.__index = ApiContext
-- TODO: Audit cases of errors and create enum values for each of them.
ApiContext.Error = {
ServerIdMismatch = "ServerIdMismatch",
}
setmetatable(ApiContext.Error, {
__index = function(_, key)
error("Invalid API.Error name " .. key, 2)
end
})
function ApiContext.new(url, onMessage)
assert(type(url) == "string")
assert(type(onMessage) == "function")
local context = {
url = url,
onMessage = onMessage,
serverId = nil,
connected = false,
messageCursor = -1,
}
setmetatable(context, ApiContext)
return context
end
function ApiContext:connect()
return Http.get(self.url)
:andThen(function(response)
local body = response:json()
if body.protocolVersion ~= Config.protocolVersion then
local message = (
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
"\nYour server is version %s, with protocol version %s." ..
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedApiContextVersionString,
body.serverVersion, body.protocolVersion
)
return Promise.reject(message)
end
self.serverId = body.serverId
self.connected = true
end)
end
function ApiContext:readAll()
if not self.connected then
return Promise.reject()
end
return Http.get(self.url .. "/read_all")
:andThen(function(response)
local body = response:json()
if body.serverId ~= self.serverId then
return Promise.reject("server changed ID")
end
self.messageCursor = body.messageCursor
return body.instances
end, function(err)
self.connected = false
return Promise.reject(err)
end)
end
function ApiContext:retrieveMessages()
if not self.connected then
return Promise.reject()
end
return Http.get(self.url .. "/subscribe/" .. self.messageCursor)
:andThen(function(response)
local body = response:json()
if body.serverId ~= self.serverId then
return Promise.reject("server changed ID")
end
for _, message in ipairs(body.messages) do
self.onMessage(message)
end
self.messageCursor = body.messageCursor
return self:retrieveMessages()
end, function(err)
if err.type == HttpError.Error.Timeout then
return self:retrieveMessages()
end
self.connected = false
return Promise.reject(err)
end)
end
return ApiContext

View File

@@ -1,12 +1,7 @@
return {
pollingRate = 0.2,
version = {0, 4, 11},
expectedServerVersionString = "0.4.x",
protocolVersion = 1,
icons = {
syncIn = "rbxassetid://1820320573",
togglePolling = "rbxassetid://1820320064",
testConnection = "rbxassetid://1820320989",
},
version = {0, 5, 0},
expectedServerVersionString = "0.5.x",
protocolVersion = 2,
port = 34872,
dev = false,
}

View File

@@ -13,27 +13,15 @@ local function dprint(...)
end
end
-- TODO: Factor out into separate library, especially error handling
local Http = {}
Http.__index = Http
function Http.new(baseUrl)
assert(type(baseUrl) == "string", "Http.new needs a baseUrl!")
local http = {
baseUrl = baseUrl
}
setmetatable(http, Http)
return http
end
function Http:get(endpoint)
dprint("\nGET", endpoint)
function Http.get(url)
dprint("\nGET", url)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:GetAsync(self.baseUrl .. endpoint, true)
return HttpService:GetAsync(url, true)
end)
if ok then
@@ -46,13 +34,13 @@ function Http:get(endpoint)
end)
end
function Http:post(endpoint, body)
dprint("\nPOST", endpoint)
function Http.post(url, body)
dprint("\nPOST", url)
dprint(body)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:PostAsync(self.baseUrl .. endpoint, body)
return HttpService:PostAsync(url, body)
end)
if ok then
@@ -65,4 +53,12 @@ function Http:post(endpoint, body)
end)
end
function Http.jsonEncode(object)
return HttpService:JSONEncode(object)
end
function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http

View File

@@ -10,6 +10,9 @@ HttpError.Error = {
message = "Rojo plugin couldn't connect to the Rojo server.\n" ..
"Make sure the server is running -- use 'Rojo serve' to run it!",
},
Timeout = {
message = "Rojo timed out during a request.",
},
Unknown = {
message = "Rojo encountered an unknown error: {{message}}",
},
@@ -44,7 +47,11 @@ function HttpError.fromErrorString(err)
end
if err:find("^curl error") then
return HttpError.new(HttpError.Error.ConnectFailed)
if err:find("couldn't connect to server") then
return HttpError.new(HttpError.Error.ConnectFailed)
elseif err:find("timeout was reached") then
return HttpError.new(HttpError.Error.Timeout)
end
end
return HttpError.new(HttpError.Error.Unknown, err)

View File

@@ -2,7 +2,7 @@ if not plugin then
return
end
local Plugin = require(script.Parent.Plugin)
local Session = require(script.Parent.Session)
local Config = require(script.Parent.Config)
local Version = require(script.Parent.Version)
@@ -39,40 +39,25 @@ local function checkUpgrade()
end
local function main()
local pluginInstance = Plugin.new()
local displayedVersion = Config.dev and "DEV" or Version.display(Config.version)
local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion)
local toolbar = plugin:CreateToolbar("Rojo " .. displayedVersion)
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", Config.icons.testConnection)
local currentSession
-- TODO: More robust session tracking to handle errors
-- TODO: Icon!
toolbar:CreateButton("Connect", "Connect to Rojo Session", "")
.Click:Connect(function()
checkUpgrade()
pluginInstance:connect()
:catch(function(err)
warn(err)
end)
end)
if currentSession ~= nil then
warn("Rojo: A session is already running!")
return
end
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", Config.icons.syncIn)
.Click:Connect(function()
checkUpgrade()
pluginInstance:syncIn()
:catch(function(err)
warn(err)
end)
end)
toolbar:CreateButton("Toggle Polling", "Poll server for changes", Config.icons.togglePolling)
.Click:Connect(function()
checkUpgrade()
pluginInstance:togglePolling()
:catch(function(err)
warn(err)
end)
print("Rojo: Started session.")
currentSession = Session.new()
end)
end

View File

@@ -1,311 +0,0 @@
local CoreGui = game:GetService("CoreGui")
local Promise = require(script.Parent.Parent.modules.Promise)
local Config = require(script.Parent.Config)
local Http = require(script.Parent.Http)
local Api = require(script.Parent.Api)
local Reconciler = require(script.Parent.Reconciler)
local Version = require(script.Parent.Version)
local MESSAGE_SERVER_CHANGED = "Rojo: The server has changed since the last request, reloading plugin..."
local MESSAGE_PLUGIN_CHANGED = "Rojo: Another instance of Rojo came online, unloading..."
local function collectMatch(source, pattern)
local result = {}
for match in source:gmatch(pattern) do
table.insert(result, match)
end
return result
end
local Plugin = {}
Plugin.__index = Plugin
function Plugin.new()
local address = "localhost"
local port = Config.dev and 8001 or 8000
local remote = ("http://%s:%d"):format(address, port)
local self = {
_http = Http.new(remote),
_reconciler = Reconciler.new(),
_api = nil,
_polling = false,
_syncInProgress = false,
}
setmetatable(self, Plugin)
do
local uiName = ("Rojo %s UI"):format(Version.display(Config.version))
if Config.dev then
uiName = "Rojo Dev UI"
end
-- If there's an existing Rojo UI, like from a Roblox plugin upgrade
-- that wasn't Rojo, make sure we clean it up.
local existingUi = CoreGui:FindFirstChild(uiName)
if existingUi ~= nil then
existingUi:Destroy()
end
local screenGui = Instance.new("ScreenGui")
screenGui.Name = uiName
screenGui.Parent = CoreGui
screenGui.DisplayOrder = -1
screenGui.Enabled = false
local label = Instance.new("TextLabel")
label.Font = Enum.Font.SourceSans
label.TextSize = 20
label.Text = "Rojo polling..."
label.BackgroundColor3 = Color3.fromRGB(31, 31, 31)
label.BackgroundTransparency = 0.5
label.BorderSizePixel = 0
label.TextColor3 = Color3.new(1, 1, 1)
label.Size = UDim2.new(0, 120, 0, 28)
label.Position = UDim2.new(0, 0, 0, 0)
label.Parent = screenGui
self._label = screenGui
-- If our UI was destroyed, we assume it was from another instance of
-- the Rojo plugin coming online.
--
-- Roblox doesn't notify plugins when they get unloaded, so this is the
-- best trigger we have right now unless we create a dedicated event
-- object.
screenGui.AncestryChanged:Connect(function(_, parent)
if parent == nil then
warn(MESSAGE_PLUGIN_CHANGED)
self:restart()
end
end)
end
return self
end
--[[
Clears all state and issues a notice to the user that the plugin has
restarted.
]]
function Plugin:restart()
self:stopPolling()
self._reconciler:destruct()
self._reconciler = Reconciler.new()
self._api = nil
self._polling = false
self._syncInProgress = false
end
function Plugin:getApi()
if self._api == nil then
return Api.connect(self._http)
:andThen(function(api)
self._api = api
return api
end, function(err)
return Promise.reject(err)
end)
end
return Promise.resolve(self._api)
end
function Plugin:connect()
print("Rojo: Testing connection...")
return self:getApi()
:andThen(function(api)
local ok, info = api:getInfo():await()
if not ok then
return Promise.reject(info)
end
print("Rojo: Server found!")
print("Rojo: Protocol version:", info.protocolVersion)
print("Rojo: Server version:", info.serverVersion)
end)
:catch(function(err)
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:connect()
else
return Promise.reject(err)
end
end)
end
function Plugin:togglePolling()
if self._polling then
return self:stopPolling()
else
return self:startPolling()
end
end
function Plugin:stopPolling()
if not self._polling then
return Promise.resolve(false)
end
print("Rojo: Stopped polling server for changes.")
self._polling = false
self._label.Enabled = false
return Promise.resolve(true)
end
function Plugin:_pull(api, project, fileRoutes)
return api:read(fileRoutes)
:andThen(function(items)
for index = 1, #fileRoutes do
local fileRoute = fileRoutes[index]
local partitionName = fileRoute[1]
local partition = project.partitions[partitionName]
local item = items[index]
local partitionTargetRbxRoute = collectMatch(partition.target, "[^.]+")
-- If the item route's length was 1, we need to rename the instance to
-- line up with the partition's root object name.
if item ~= nil and #fileRoute == 1 then
local objectName = partition.target:match("[^.]+$")
item.Name = objectName
end
local itemRbxRoute = {}
for _, piece in ipairs(partitionTargetRbxRoute) do
table.insert(itemRbxRoute, piece)
end
for i = 2, #fileRoute do
table.insert(itemRbxRoute, fileRoute[i])
end
self._reconciler:reconcileRoute(itemRbxRoute, item, fileRoute)
end
end)
end
function Plugin:startPolling()
if self._polling then
return
end
print("Rojo: Starting to poll server for changes...")
self._polling = true
self._label.Enabled = true
return self:getApi()
:andThen(function(api)
local infoOk, info = api:getInfo():await()
if not infoOk then
return Promise.reject(info)
end
local syncOk, result = self:syncIn():await()
if not syncOk then
return Promise.reject(result)
end
while self._polling do
local changesOk, changes = api:getChanges():await()
if not changesOk then
return Promise.reject(changes)
end
if #changes > 0 then
local routes = {}
for _, change in ipairs(changes) do
table.insert(routes, change.route)
end
local pullOk, pullResult = self:_pull(api, info.project, routes):await()
if not pullOk then
return Promise.reject(pullResult)
end
end
wait(Config.pollingRate)
end
end)
:catch(function(err)
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:startPolling()
else
self:stopPolling()
return Promise.reject(err)
end
end)
end
function Plugin:syncIn()
if self._syncInProgress then
warn("Rojo: Can't sync right now, because a sync is already in progress.")
return Promise.resolve()
end
self._syncInProgress = true
print("Rojo: Syncing from server...")
return self:getApi()
:andThen(function(api)
local ok, info = api:getInfo():await()
if not ok then
return Promise.reject(info)
end
local fileRoutes = {}
for name in pairs(info.project.partitions) do
table.insert(fileRoutes, {name})
end
local pullSuccess, pullResult = self:_pull(api, info.project, fileRoutes):await()
self._syncInProgress = false
if not pullSuccess then
return Promise.reject(pullResult)
end
print("Rojo: Sync successful!")
end)
:catch(function(err)
self._syncInProgress = false
if err == Api.Error.ServerIdMismatch then
warn(MESSAGE_SERVER_CHANGED)
self:restart()
return self:syncIn()
else
return Promise.reject(err)
end
end)
end
return Plugin

View File

@@ -1,246 +0,0 @@
local RouteMap = require(script.Parent.RouteMap)
local function classEqual(a, b)
assert(typeof(a) == "string")
assert(typeof(b) == "string")
if a == "*" or b == "*" then
return true
end
return a == b
end
local function applyProperties(target, properties)
assert(typeof(target) == "Instance")
assert(typeof(properties) == "table")
for key, property in pairs(properties) do
-- TODO: Transform property value based on property.Type
-- Right now, we assume that 'value' is primitive!
target[key] = property.Value
end
end
--[[
Attempt to parent `rbx` to `parent`, doing nothing if:
* parent is already `parent`
* Changing parent threw an error
]]
local function reparent(rbx, parent)
assert(typeof(rbx) == "Instance")
assert(typeof(parent) == "Instance")
if rbx.Parent == parent then
return
end
-- Setting `Parent` can fail if:
-- * The object has been destroyed
-- * The object is a service and cannot be reparented
pcall(function()
rbx.Parent = parent
end)
end
--[[
Attempts to match up Roblox instances and object specifiers for
reconciliation.
An object is considered a match if they have the same Name and ClassName.
primaryChildren and secondaryChildren can each be either a list of Roblox
instances or object specifiers. Since they share a common shape, switching
the two around isn't problematic!
visited is expected to be an empty table initially. It will be filled with
the set of children that have been visited so far.
]]
local function findNextChildPair(primaryChildren, secondaryChildren, visited)
for _, primaryChild in ipairs(primaryChildren) do
if not visited[primaryChild] then
visited[primaryChild] = true
for _, secondaryChild in ipairs(secondaryChildren) do
if classEqual(primaryChild.ClassName, secondaryChild.ClassName) and primaryChild.Name == secondaryChild.Name then
visited[secondaryChild] = true
return primaryChild, secondaryChild
end
end
return primaryChild, nil
end
end
return nil, nil
end
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new()
local reconciler = {
_routeMap = RouteMap.new(),
}
setmetatable(reconciler, Reconciler)
return reconciler
end
--[[
A semi-smart algorithm that attempts to apply the given item's children to
an existing Roblox object.
]]
function Reconciler:_reconcileChildren(rbx, item)
local visited = {}
local rbxChildren = rbx:GetChildren()
-- Reconcile any children that were added or updated
while true do
local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited)
if itemChild == nil then
break
end
local newRbxChild = self:reconcile(rbxChild, itemChild)
if newRbxChild ~= nil then
newRbxChild.Parent = rbx
end
end
-- Reconcile any children that were deleted
while true do
local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited)
if rbxChild == nil then
break
end
local newRbxChild = self:reconcile(rbxChild, itemChild)
if newRbxChild ~= nil then
newRbxChild.Parent = rbx
end
end
end
--[[
Construct a new Roblox object from the given item.
]]
function Reconciler:_reify(item)
local className = item.ClassName
-- "*" represents a match of any class. It reifies as a folder!
if className == "*" then
className = "Folder"
end
local rbx = Instance.new(className)
rbx.Name = item.Name
applyProperties(rbx, item.Properties)
for _, child in ipairs(item.Children) do
reparent(self:_reify(child), rbx)
end
if item.Route ~= nil then
self._routeMap:insert(item.Route, rbx)
end
return rbx
end
--[[
Clears any state that the Reconciler has, stopping it completely.
]]
function Reconciler:destruct()
self._routeMap:destruct()
end
--[[
Apply the changes represented by the given item to a Roblox object that's a
child of the given instance.
]]
function Reconciler:reconcile(rbx, item)
-- Item was deleted
if item == nil then
if rbx ~= nil then
self._routeMap:removeByRbx(rbx)
rbx:Destroy()
end
return nil
end
-- Item was created!
if rbx == nil then
return self:_reify(item)
end
-- Item changed type!
if not classEqual(rbx.ClassName, item.ClassName) then
self._routeMap:removeByRbx(rbx)
rbx:Destroy()
return self:_reify(item)
end
-- It's possible that the instance we're associating with this item hasn't
-- been inserted into the RouteMap yet.
if item.Route ~= nil then
self._routeMap:insert(item.Route, rbx)
end
applyProperties(rbx, item.Properties)
self:_reconcileChildren(rbx, item)
return rbx
end
function Reconciler:reconcileRoute(rbxRoute, item, fileRoute)
local parent
local rbx = game
for i = 1, #rbxRoute do
local piece = rbxRoute[i]
local child = rbx:FindFirstChild(piece)
-- We should get services instead of making folders here.
if rbx == game and child == nil then
local success
success, child = pcall(game.GetService, game, piece)
-- That isn't a valid service!
if not success then
child = nil
end
end
-- We don't want to create a folder if we're reaching our target item!
if child == nil and i ~= #rbxRoute then
child = Instance.new("Folder")
child.Parent = rbx
child.Name = piece
end
parent = rbx
rbx = child
end
-- Let's check the route map!
if rbx == nil then
rbx = self._routeMap:get(fileRoute)
end
rbx = self:reconcile(rbx, item)
reparent(rbx, parent)
end
return Reconciler

View File

@@ -1,123 +0,0 @@
--[[
A map from Route objects (given by the server) to Roblox instances (created
by the plugin).
]]
local function hashRoute(route)
return table.concat(route, "/")
end
local RouteMap = {}
RouteMap.__index = RouteMap
function RouteMap.new()
local self = {
_map = {},
_reverseMap = {},
_connectionsByRbx = {},
}
setmetatable(self, RouteMap)
return self
end
function RouteMap:insert(route, rbx)
local hashed = hashRoute(route)
-- Make sure that each route and instance are only present in RouteMap once.
self:removeByRoute(route)
self:removeByRbx(rbx)
self._map[hashed] = rbx
self._reverseMap[rbx] = hashed
self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent)
if parent == nil then
self:removeByRbx(rbx)
end
end)
end
function RouteMap:get(route)
return self._map[hashRoute(route)]
end
function RouteMap:removeByRoute(route)
local hashedRoute = hashRoute(route)
local rbx = self._map[hashedRoute]
if rbx ~= nil then
self:_removeInternal(rbx, hashedRoute)
end
end
function RouteMap:removeByRbx(rbx)
local hashedRoute = self._reverseMap[rbx]
if hashedRoute ~= nil then
self:_removeInternal(rbx, hashedRoute)
end
end
--[[
Correcly removes the given Roblox Instance/Route pair from the RouteMap.
]]
function RouteMap:_removeInternal(rbx, hashedRoute)
self._map[hashedRoute] = nil
self._reverseMap[rbx] = nil
self._connectionsByRbx[rbx]:Disconnect()
self._connectionsByRbx[rbx] = nil
self:_removeRbxDescendants(rbx)
end
--[[
Ensure that there are no descendants of the given Roblox Instance still
present in the map, guaranteeing that it has been cleaned out.
]]
function RouteMap:_removeRbxDescendants(parentRbx)
for rbx in pairs(self._reverseMap) do
if rbx:IsDescendantOf(parentRbx) then
self:removeByRbx(rbx)
end
end
end
--[[
Remove all items from the map and disconnect all connections, cleaning up
the RouteMap.
]]
function RouteMap:destruct()
self._map = {}
self._reverseMap = {}
for _, connection in pairs(self._connectionsByRbx) do
connection:Disconnect()
end
self._connectionsByRbx = {}
end
function RouteMap:visualize()
-- Log all of our keys so that the visualization has a stable order.
local keys = {}
for key in pairs(self._map) do
table.insert(keys, key)
end
table.sort(keys)
local buffer = {}
for _, key in ipairs(keys) do
local visualized = ("- %s: %s"):format(
key,
self._map[key]:GetFullName()
)
table.insert(buffer, visualized)
end
return table.concat(buffer, "\n")
end
return RouteMap

76
plugin/src/Session.lua Normal file
View File

@@ -0,0 +1,76 @@
local Config = require(script.Parent.Config)
local ApiContext = require(script.Parent.ApiContext)
local REMOTE_URL = ("http://localhost:%d"):format(Config.port)
local Session = {}
Session.__index = Session
function Session.new()
local self = {}
setmetatable(self, Session)
-- TODO: Rewrite all instance tracking logic and implement a real reconciler
local created = {}
created["0"] = game:GetService("ReplicatedFirst")
local api
local function readAll()
print("Reading all...")
return api:readAll()
:andThen(function(instances)
local visited = {}
for id, instance in pairs(instances) do
visited[id] = true
if id ~= "0" then
local existing = created[id]
if existing ~= nil then
pcall(existing.Destroy, existing)
end
local real = Instance.new(instance.className)
real.Name = instance.name
for key, value in pairs(instance.properties) do
real[key] = value
end
created[id] = real
end
end
for id, instance in pairs(instances) do
if id ~= "0" then
created[id].Parent = created[tostring(instance.parent)]
end
end
for id, object in pairs(created) do
if not visited[id] then
object:Destroy()
end
end
end)
end
api = ApiContext.new(REMOTE_URL, function(message)
if message.type == "InstanceChanged" then
print("Instance", message.id, "changed!")
readAll()
else
warn("Unknown message type " .. message.type)
end
end)
api:connect()
:andThen(readAll)
:andThen(function()
return api:retrieveMessages()
end)
return self
end
return Session