diff --git a/plugin/src/Reconciler.lua b/plugin/src/Reconciler.lua new file mode 100644 index 00000000..60e637a7 --- /dev/null +++ b/plugin/src/Reconciler.lua @@ -0,0 +1,256 @@ +local Logging = require(script.Parent.Logging) + +local function makeInstanceMap() + local self = { + fromIds = {}, + fromInstances = {}, + } + + function self:insert(id, instance) + self.fromIds[id] = instance + self.fromInstances[instance] = id + end + + function self:removeId(id) + local instance = self.fromIds[id] + + if instance ~= nil then + self.fromIds[id] = nil + self.fromInstances[instance] = nil + else + Logging.warn("Attempted to remove nonexistant ID %s", tostring(id)) + end + end + + function self:removeInstance(instance) + local id = self.fromInstances[instance] + + if id ~= nil then + self.fromInstances[instance] = nil + self.fromIds[id] = nil + else + Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance)) + end + end + + function self:destroyId(id) + local instance = self.fromIds[id] + self:removeId(id) + + if instance ~= nil then + local descendantsToDestroy = {} + + for otherInstance in pairs(self.fromInstances) do + if otherInstance:IsDescendantOf(instance) then + table.insert(descendantsToDestroy, otherInstance) + end + end + + for _, otherInstance in ipairs(descendantsToDestroy) do + self:removeInstance(otherInstance) + end + + instance:Destroy() + else + Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id)) + end + end + + return self +end + +local function setProperty(instance, key, value) + local ok, err = pcall(function() + if instance[key] ~= value then + instance[key] = value + end + end) + + if not ok then + error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2) + end +end + +local Reconciler = {} +Reconciler.__index = Reconciler + +function Reconciler.new(instanceMetadataMap) + local self = { + instanceMap = makeInstanceMap(), + instanceMetadataMap = instanceMetadataMap, + } + + return setmetatable(self, Reconciler) +end + +function Reconciler:applyUpdate(requestedIds, virtualInstancesById) + -- This function may eventually be asynchronous; it will require calls to + -- the server to resolve instances that don't exist yet. + local visitedIds = {} + + for _, id in ipairs(requestedIds) do + self:__applyUpdatePiece(id, visitedIds, virtualInstancesById) + end +end + +--[[ + Update an existing instance, including its properties and children, to match + the given information. +]] +function Reconciler:reconcile(virtualInstancesById, id, instance) + local virtualInstance = virtualInstancesById[id] + + -- If an instance changes ClassName, we assume it's very different. That's + -- not always the case! + if virtualInstance.ClassName ~= instance.ClassName then + -- TODO: Preserve existing children instead? + local parent = instance.Parent + self.instanceMap:destroyId(id) + return self:__reify(virtualInstancesById, id, parent) + end + + self.instanceMap:insert(id, instance) + + -- Some instances don't like being named, even if their name already matches + setProperty(instance, "Name", virtualInstance.Name) + + for key, value in pairs(virtualInstance.Properties) do + setProperty(instance, key, value.Value) + end + + local existingChildren = instance:GetChildren() + + local unvisitedExistingChildren = {} + for _, child in ipairs(existingChildren) do + unvisitedExistingChildren[child] = true + end + + for _, childId in ipairs(virtualInstance.Children) do + local childData = virtualInstancesById[childId] + + local existingChildInstance + for instance in pairs(unvisitedExistingChildren) do + local ok, name, className = pcall(function() + return instance.Name, instance.ClassName + end) + + if ok then + if name == childData.Name and className == childData.ClassName then + existingChildInstance = instance + break + end + end + end + + if existingChildInstance ~= nil then + unvisitedExistingChildren[existingChildInstance] = nil + self:reconcile(virtualInstancesById, childId, existingChildInstance) + else + self:__reify(virtualInstancesById, childId, instance) + end + end + + if self:__shouldClearUnknownInstances(id) then + for existingChildInstance in pairs(unvisitedExistingChildren) do + self.instanceMap:removeInstance(existingChildInstance) + existingChildInstance:Destroy() + end + end + + -- The root instance of a project won't have a parent, like the DataModel, + -- so we need to be careful here. + if virtualInstance.Parent ~= nil then + local parent = self.instanceMap.fromIds[virtualInstance.Parent] + + if parent == nil then + Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent)) + error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.") + end + + -- Some instances, like services, don't like having their Parent + -- property poked, even if we're setting it to the same value. + setProperty(instance, "Parent", parent) + if instance.Parent ~= parent then + instance.Parent = parent + end + end + + return instance +end + +function Reconciler:__shouldClearUnknownInstances(id) + if self.instanceMetadataMap[id] then + return not self.instanceMetadataMap[id].ignoreUnknownInstances + else + return true + end +end + +function Reconciler:__reify(virtualInstancesById, id, parent) + local virtualInstance = virtualInstancesById[id] + + local instance = Instance.new(virtualInstance.ClassName) + + for key, value in pairs(virtualInstance.Properties) do + -- TODO: Branch on value.Type + setProperty(instance, key, value.Value) + end + + instance.Name = virtualInstance.Name + + for _, childId in ipairs(virtualInstance.Children) do + self:__reify(virtualInstancesById, childId, instance) + end + + setProperty(instance, "Parent", parent) + self.instanceMap:insert(id, instance) + + return instance +end + +function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById) + if visitedIds[id] then + return + end + + visitedIds[id] = true + + local virtualInstance = virtualInstancesById[id] + local instance = self.instanceMap.fromIds[id] + + -- The instance was deleted in this update + if virtualInstance == nil then + self.instanceMap:destroyId(id) + return + end + + -- An instance we know about was updated + if instance ~= nil then + self:reconcile(virtualInstancesById, id, instance) + return instance + end + + -- If the instance's parent already exists, we can stick it there + local parentInstance = self.instanceMap.fromIds[virtualInstance.Parent] + if parentInstance ~= nil then + self:__reify(virtualInstancesById, id, parentInstance) + return + end + + -- Otherwise, we can check if this response payload contained the parent and + -- work from there instead. + local parentData = virtualInstancesById[virtualInstance.Parent] + if parentData ~= nil then + if visitedIds[virtualInstance.Parent] then + error("Rojo bug: An instance was present and marked as visited but its instance was missing") + end + + self:__applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById) + return + end + + Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent)) + error("Rojo NYI: Instances with parents that weren't mentioned in an update payload") +end + +return Reconciler \ No newline at end of file diff --git a/plugin/src/Session.lua b/plugin/src/Session.lua index ce8de667..fd086ff7 100644 --- a/plugin/src/Session.lua +++ b/plugin/src/Session.lua @@ -1,247 +1,6 @@ local ApiContext = require(script.Parent.ApiContext) local Logging = require(script.Parent.Logging) - -local function makeInstanceMap() - local self = { - fromIds = {}, - fromInstances = {}, - } - - function self:insert(id, instance) - self.fromIds[id] = instance - self.fromInstances[instance] = id - end - - function self:removeId(id) - local instance = self.fromIds[id] - - if instance ~= nil then - self.fromIds[id] = nil - self.fromInstances[instance] = nil - else - Logging.warn("Attempted to remove nonexistant ID %s", tostring(id)) - end - end - - function self:removeInstance(instance) - local id = self.fromInstances[instance] - - if id ~= nil then - self.fromInstances[instance] = nil - self.fromIds[id] = nil - else - Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance)) - end - end - - function self:destroyId(id) - local instance = self.fromIds[id] - self:removeId(id) - - if instance ~= nil then - local descendantsToDestroy = {} - - for otherInstance in pairs(self.fromInstances) do - if otherInstance:IsDescendantOf(instance) then - table.insert(descendantsToDestroy, otherInstance) - end - end - - for _, otherInstance in ipairs(descendantsToDestroy) do - self:removeInstance(otherInstance) - end - - instance:Destroy() - else - Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id)) - end - end - - return self -end - -local function setProperty(instance, key, value) - local ok, err = pcall(function() - if instance[key] ~= value then - instance[key] = value - end - end) - - if not ok then - error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2) - end -end - -local function shouldClearUnknown(id, instanceMetadataMap) - if instanceMetadataMap[id] then - return not instanceMetadataMap[id].ignoreUnknownInstances - else - return true - end -end - -local function reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parent) - local virtualInstance = virtualInstancesById[id] - - local instance = Instance.new(virtualInstance.ClassName) - - for key, value in pairs(virtualInstance.Properties) do - -- TODO: Branch on value.Type - setProperty(instance, key, value.Value) - end - - instance.Name = virtualInstance.Name - - for _, childId in ipairs(virtualInstance.Children) do - reify(virtualInstancesById, instanceMap, instanceMetadataMap, childId, instance) - end - - setProperty(instance, "Parent", parent) - instanceMap:insert(id, instance) - - return instance -end - ---[[ - Update an existing instance, including its properties and children, to match - the given information. -]] -local function reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, id, existingInstance) - local virtualInstance = virtualInstancesById[id] - - -- If an instance changes ClassName, we assume it's very different. That's - -- not always the case! - if virtualInstance.ClassName ~= existingInstance.ClassName then - -- TODO: Preserve existing children instead? - local parent = existingInstance.Parent - instanceMap:destroyId(id) - reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parent) - return - end - - instanceMap:insert(id, existingInstance) - - -- Some instances don't like being named, even if their name already matches - setProperty(existingInstance, "Name", virtualInstance.Name) - - for key, value in pairs(virtualInstance.Properties) do - setProperty(existingInstance, key, value.Value) - end - - local existingChildren = existingInstance:GetChildren() - - local unvisitedExistingChildren = {} - for _, child in ipairs(existingChildren) do - unvisitedExistingChildren[child] = true - end - - for _, childId in ipairs(virtualInstance.Children) do - local childData = virtualInstancesById[childId] - - local existingChildInstance - for instance in pairs(unvisitedExistingChildren) do - local ok, name, className = pcall(function() - return instance.Name, instance.ClassName - end) - - if ok then - if name == childData.Name and className == childData.ClassName then - existingChildInstance = instance - break - end - end - end - - if existingChildInstance ~= nil then - unvisitedExistingChildren[existingChildInstance] = nil - reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, childId, existingChildInstance) - else - reify(virtualInstancesById, instanceMap, instanceMetadataMap, childId, existingInstance) - end - end - - if shouldClearUnknown(id, instanceMetadataMap) then - for existingChildInstance in pairs(unvisitedExistingChildren) do - instanceMap:removeInstance(existingChildInstance) - existingChildInstance:Destroy() - end - end - - -- The root instance of a project won't have a parent, like the DataModel, - -- so we need to be careful here. - if virtualInstance.Parent ~= nil then - local parent = instanceMap.fromIds[virtualInstance.Parent] - - if parent == nil then - Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent)) - error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.") - end - - -- Some instances, like services, don't like having their Parent - -- property poked, even if we're setting it to the same value. - setProperty(existingInstance, "Parent", parent) - if existingInstance.Parent ~= parent then - existingInstance.Parent = parent - end - end - - return existingInstance -end - -local function applyUpdatePiece(id, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap) - if visitedIds[id] then - return - end - - visitedIds[id] = true - - local virtualInstance = virtualInstancesById[id] - local instance = instanceMap.fromIds[id] - - -- The instance was deleted in this update - if virtualInstance == nil then - instanceMap:destroyId(id) - return - end - - -- An instance we know about was updated - if instance ~= nil then - reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, id, instance) - return instance - end - - -- If the instance's parent already exists, we can stick it there - local parentInstance = instanceMap.fromIds[virtualInstance.Parent] - if parentInstance ~= nil then - reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parentInstance) - return - end - - -- Otherwise, we can check if this response payload contained the parent and - -- work from there instead. - local parentData = virtualInstancesById[virtualInstance.Parent] - if parentData ~= nil then - if visitedIds[virtualInstance.Parent] then - error("Rojo bug: An instance was present and marked as visited but its instance was missing") - end - - applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap) - return - end - - Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent)) - error("Rojo NYI: Instances with parents that weren't mentioned in an update payload") -end - -local function applyUpdate(requestedIds, virtualInstancesById, instanceMap, instanceMetadataMap) - -- This function may eventually be asynchronous; it will require calls to - -- the server to resolve instances that don't exist yet. - local visitedIds = {} - - for _, id in ipairs(requestedIds) do - applyUpdatePiece(id, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap) - end -end +local Reconciler = require(script.Parent.Reconciler) local Session = {} Session.__index = Session @@ -251,7 +10,7 @@ function Session.new(config) self.onError = config.onError - local instanceMap = makeInstanceMap() + local reconciler local remoteUrl = ("http://%s:%s"):format(config.address, config.port) @@ -274,7 +33,7 @@ function Session.new(config) return api:read(requestedIds) :andThen(function(response) - return applyUpdate(requestedIds, response.instances, instanceMap, api.instanceMetadataMap) + return reconciler:applyUpdate(requestedIds, response.instances) end) :catch(function(message) Logging.warn("%s", tostring(message)) @@ -284,11 +43,12 @@ function Session.new(config) api:connect() :andThen(function() + reconciler = Reconciler.new(api.instanceMetadataMap) + return api:read({api.rootInstanceId}) end) :andThen(function(response) - reconcile(response.instances, instanceMap, api.instanceMetadataMap, api.rootInstanceId, game) - -- reify(response.instances, instanceMap, instanceMetadataMap, api.rootInstanceId, game.ReplicatedStorage) + reconciler:reconcile(response.instances, api.rootInstanceId, game) return api:retrieveMessages() end) :catch(function(message)