diff --git a/.moonwave/static/roam/GetVectored.jpg b/.moonwave/static/roam/GetVectored.jpg new file mode 100644 index 0000000..309b3f2 Binary files /dev/null and b/.moonwave/static/roam/GetVectored.jpg differ diff --git a/.moonwave/static/roam/RoamLifecycleAdvanced.png b/.moonwave/static/roam/RoamLifecycleAdvanced.png new file mode 100644 index 0000000..d45b19f Binary files /dev/null and b/.moonwave/static/roam/RoamLifecycleAdvanced.png differ diff --git a/.rbxsync-trash/manifest.json b/.rbxsync-trash/manifest.json new file mode 100644 index 0000000..c43cb32 --- /dev/null +++ b/.rbxsync-trash/manifest.json @@ -0,0 +1,3 @@ +{ + "entries": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b24afa8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "[lua]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua", + "editor.formatOnSave": true + }, + "[luau]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua", + "editor.formatOnSave": true + }, + "selene.selenePath": "", + "stylua.targetReleaseVersion": "latest" +} \ No newline at end of file diff --git a/README.md b/README.md index 20e471e..33fa142 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ModulesOnRails is a collection of Wally packages to streamline Roblox developmen | [PromValue](https://raild3x.github.io/ModulesOnRails/api/PromValue) | `PromValue = "raild3x/promvalue@0.1.0"` | An object class that allows for delayed setting | | [Queue](https://raild3x.github.io/ModulesOnRails/api/Queue) | `Queue = "raild3x/queue@1.0.0"` | A generic queue implementation in luau. | | [RemoteComponent](https://raild3x.github.io/ModulesOnRails/api/RemoteComponent) | `RemoteComponent = "raild3x/remotecomponent@0.1.3"` | A component extension to provide easy networking functionality. | -| [Roam](https://raild3x.github.io/ModulesOnRails/api/Roam) | `Roam = "raild3x/roam@0.1.6"` | Roam is a service initialization framework for Roblox. | +| [Roam](https://raild3x.github.io/ModulesOnRails/api/Roam) | `Roam = "raild3x/roam@0.2.0"` | Roam is a service initialization framework for Roblox. | | [TableManager](https://raild3x.github.io/ModulesOnRails/api/TableManager) | `TableManager = "raild3x/tablemanager@0.2.2"` | A class for managing and observing data in a table. Includes some additional classes for extending functionality. | | [TableReplicator](https://raild3x.github.io/ModulesOnRails/api/ServerTableReplicator) | `TableReplicator = "raild3x/tablereplicator@0.2.7"` | A set of classes for replicating tables and their changes between server and client with minimal effort. | diff --git a/lib/dropletmanager/src/Client/DropletClientManager.luau b/lib/dropletmanager/src/Client/DropletClientManager.luau index c7e8ab1..efda178 100644 --- a/lib/dropletmanager/src/Client/DropletClientManager.luau +++ b/lib/dropletmanager/src/Client/DropletClientManager.luau @@ -31,7 +31,7 @@ local LocalPlayer = Players.LocalPlayer --// Util Functions //-- -------------------------------------------------------------------------------- ---[=[ +--[[ Calculates the ejection velocity for a droplet with support for arbitrary ejection directions. @param hForce number | NumberRange -- Horizontal force (perpendicular to direction) @@ -39,44 +39,42 @@ local LocalPlayer = Players.LocalPlayer @param direction Vector3? -- Direction to eject towards (defaults to Vector3.yAxis) @param NumGen Random -- Random number generator @return Vector3 -- The calculated ejection velocity -]=] +]] local function CalculateEjectionVelocity(hForce, vForce, direction: Vector3?, NumGen: Random): Vector3 - local ejectionDirection = (direction or Vector3.yAxis).Unit -- Ensure normalized - - local HorizontalForce = DropletUtil.parse(hForce, NumGen) or NumGen:NextInteger(2, 25) - local VerticalForce = DropletUtil.parse(vForce, NumGen) or NumGen:NextInteger(25, 50) - - -- Create orthonormal basis from direction - -- We need two perpendicular vectors to the direction for the horizontal plane - local up = if math.abs(ejectionDirection.Y) < 0.99 then Vector3.yAxis else Vector3.xAxis - local right = ejectionDirection:Cross(up).Unit - local forward = right:Cross(ejectionDirection).Unit - - -- Generate a random horizontal direction in the plane perpendicular to ejection direction - local RandomRotation = NumGen:NextNumber(-math.pi, math.pi) - local horizontalX = math.cos(RandomRotation) * HorizontalForce - local horizontalZ = math.sin(RandomRotation) * HorizontalForce - - -- Combine: vertical force along direction, horizontal force perpendicular to it - local velocity = ejectionDirection * VerticalForce - + right * horizontalX - + forward * horizontalZ - - return velocity + local ejectionDirection = (direction or Vector3.yAxis).Unit -- Ensure normalized + + local HorizontalForce = DropletUtil.parse(hForce, NumGen) or NumGen:NextInteger(2, 25) + local VerticalForce = DropletUtil.parse(vForce, NumGen) or NumGen:NextInteger(25, 50) + + -- Create orthonormal basis from direction + -- We need two perpendicular vectors to the direction for the horizontal plane + local up = if math.abs(ejectionDirection.Y) < 0.99 then Vector3.yAxis else Vector3.xAxis + local right = ejectionDirection:Cross(up).Unit + local forward = right:Cross(ejectionDirection).Unit + + -- Generate a random horizontal direction in the plane perpendicular to ejection direction + local RandomRotation = NumGen:NextNumber(-math.pi, math.pi) + local horizontalX = math.cos(RandomRotation) * HorizontalForce + local horizontalZ = math.sin(RandomRotation) * HorizontalForce + + -- Combine: vertical force along direction, horizontal force perpendicular to it + local velocity = ejectionDirection * VerticalForce + right * horizontalX + forward * horizontalZ + + return velocity end local function ParseLocation(location): CFrame - if typeof(location) == "Vector3" then - return CFrame.new(location) - elseif typeof(location) == "CFrame" then - return location - elseif typeof(location) == "table" then - if location.Obj then - return location.Obj:GetPivot() - end - return location.CF - end - error("Invalid location type: "..tostring(location)) + if typeof(location) == "Vector3" then + return CFrame.new(location) + elseif typeof(location) == "CFrame" then + return location + elseif typeof(location) == "table" then + if location.Obj then + return location.Obj:GetPivot() + end + return location.CF + end + error("Invalid location type: " .. tostring(location)) end -------------------------------------------------------------------------------- @@ -84,47 +82,47 @@ end -------------------------------------------------------------------------------- local DropletClientManager = {} -local DropletStorage: {[number]: Droplet} = {} -local ResourceTypeDataMap: {[string]: ResourceTypeData} = {} +local DropletStorage: { [number]: Droplet } = {} +local ResourceTypeDataMap: { [string]: ResourceTypeData } = {} local RenderOctree: Octree.Octree = Octree.new() local MagnetOctree: Octree.Octree = Octree.new() local DistanceHeap: Heap.Heap = Heap.max() -local CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) +local CollectionTracker = setmetatable({} :: { [Droplet]: boolean }, { __mode = "k" }) local Replicator = NetWire.Client("DropletServerManager") Replicator.DropletCreated:Connect(function(...) - (DropletClientManager :: any):_OnCreateDroplet(...) + (DropletClientManager :: any):_OnCreateDroplet(...) end) Replicator.DropletClaimed:Connect(function(...: any) - (DropletClientManager :: any):_OnClaimDroplet(...) + (DropletClientManager :: any):_OnClaimDroplet(...) end) RunService.PreSimulation:Connect(function(dt: number) - (DropletClientManager :: any):_Update(dt) + (DropletClientManager :: any):_Update(dt) end) -------------------------------------------------------------------------------- - --// METHODS //-- +--// METHODS //-- -------------------------------------------------------------------------------- --[=[ Registers a new resource type. ]=] function DropletClientManager:RegisterResourceType(resourceType: string, data: ResourceTypeData) - assert(not ResourceTypeDataMap[resourceType], "Resource type already registered") - ResourceTypeDataMap[resourceType] = data + assert(not ResourceTypeDataMap[resourceType], "Resource type already registered") + ResourceTypeDataMap[resourceType] = data end --[=[ Returns the resource type data for the given resource type ]=] function DropletClientManager:GetResourceTypeData(resourceType: string): ResourceTypeData? - return ResourceTypeDataMap[resourceType] + return ResourceTypeDataMap[resourceType] end -------------------------------------------------------------------------------- - --// Private //-- +--// Private //-- -------------------------------------------------------------------------------- --[=[ @@ -135,49 +133,49 @@ end them as being collected as well as updates the droplet visualization ]=] function DropletClientManager:_Update(dt: number) - dt = dt or 0 - - debug.profilebegin("Droplet Collection Check") - if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then - local PlayerPos = LocalPlayer.Character:GetPivot().Position - - -- Use the maximum magnetization radius from the heap to determine search radius - local maxMagnetizationRadius = DistanceHeap:Peek() or DropletUtil.DEFAULT_COLLECTION_RADIUS - - for node in MagnetOctree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do - local droplet = node.Object - if CollectionTracker[droplet] then - continue - end - local dropletPos = node.Position - local distance = (PlayerPos - dropletPos).Magnitude - - -- Check if within this droplet's specific magnetization radius - if distance <= droplet:GetMagnetizationRadius() then - -- Check if it must settle before collecting - if not droplet:MustSettleBeforeCollect() or droplet:IsSettled() then - CollectionTracker[droplet] = true - DropletClientManager:_RequestClaimDroplet(droplet) - end - end - end - end - debug.profileend() - - debug.profilebegin("Droplet Position Update") - Droplet.processRendering() - debug.profileend() - - debug.profilebegin("Droplet Visualization Update") - local RENDER_RADIUS = DropletUtil.RENDER_RADIUS - local isOnScreen = RailUtil.Camera.isOnScreen - local pos: Vector3 = (Camera.CFrame + Camera.CFrame.LookVector * (RENDER_RADIUS/2)).Position - for node in RenderOctree:ForEachInRadius(pos, RENDER_RADIUS + 1) do - if isOnScreen(node.Position) then - node.Object:_Render(dt) - end - end - debug.profileend() + dt = dt or 0 + + debug.profilebegin("Droplet Collection Check") + if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then + local PlayerPos = LocalPlayer.Character:GetPivot().Position + + -- Use the maximum magnetization radius from the heap to determine search radius + local maxMagnetizationRadius = DistanceHeap:Peek() or DropletUtil.DEFAULT_COLLECTION_RADIUS + + for node in MagnetOctree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do + local droplet = node.Object + if CollectionTracker[droplet] then + continue + end + local dropletPos = node.Position + local distance = (PlayerPos - dropletPos).Magnitude + + -- Check if within this droplet's specific magnetization radius + if distance <= droplet:GetMagnetizationRadius() then + -- Check if it must settle before collecting + if not droplet:MustSettleBeforeCollect() or droplet:IsSettled() then + CollectionTracker[droplet] = true + DropletClientManager:_RequestClaimDroplet(droplet) + end + end + end + end + debug.profileend() + + debug.profilebegin("Droplet Position Update") + Droplet.processRendering() + debug.profileend() + + debug.profilebegin("Droplet Visualization Update") + local RENDER_RADIUS = DropletUtil.RENDER_RADIUS + local isOnScreen = RailUtil.Camera.isOnScreen + local pos: Vector3 = (Camera.CFrame + Camera.CFrame.LookVector * (RENDER_RADIUS / 2)).Position + for node in RenderOctree:ForEachInRadius(pos, RENDER_RADIUS + 1) do + if isOnScreen(node.Position) then + node.Object:_Render(dt) + end + end + debug.profileend() end --[=[ @@ -185,23 +183,23 @@ end Marks a droplet to be checked for collection ]=] function DropletClientManager:_MarkForCollection(droplet: Droplet) - assert(not MagnetOctree:FindFirstNode(droplet), "Droplet already marked for collection") - local node = MagnetOctree:CreateNode(droplet:GetPosition(), droplet) - - droplet:GetSignal("PositionChanged"):Connect(function(newPos) - MagnetOctree:ChangeNodePosition(node, newPos) - end) - - -- Track magnetization radius in heap for dynamic radius checking - local magnetizationRadius = droplet:GetMagnetizationRadius() - DistanceHeap:Push(magnetizationRadius) - - local function Remove() - MagnetOctree:RemoveNode(node) - DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) - end - droplet:GetSignal("TimedOut"):Once(Remove) - droplet:GetDestroyedSignal():Once(Remove) + assert(not MagnetOctree:FindFirstNode(droplet), "Droplet already marked for collection") + local node = MagnetOctree:CreateNode(droplet:GetPosition(), droplet) + + droplet:GetSignal("PositionChanged"):Connect(function(newPos) + MagnetOctree:ChangeNodePosition(node, newPos) + end) + + -- Track magnetization radius in heap for dynamic radius checking + local magnetizationRadius = droplet:GetMagnetizationRadius() + DistanceHeap:Push(magnetizationRadius) + + local function Remove() + MagnetOctree:RemoveNode(node) + DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) + end + droplet:GetSignal("TimedOut"):Once(Remove) + droplet:GetDestroyedSignal():Once(Remove) end --[=[ @@ -209,16 +207,18 @@ end Marks a droplet for rendering by placing it into the octree ]=] function DropletClientManager:_MarkForRender(droplet: Droplet) - assert(not RenderOctree:FindFirstNode(droplet), "Droplet already marked for render") - local node = RenderOctree:CreateNode(droplet:GetPosition(), droplet) - - droplet:GetSignal("PositionChanged"):Connect(function(newPos) - RenderOctree:ChangeNodePosition(node, newPos) - end) - - local function Remove() RenderOctree:RemoveNode(node) end - droplet:GetSignal("TimedOut"):Once(Remove) - droplet:GetDestroyedSignal():Once(Remove) + assert(not RenderOctree:FindFirstNode(droplet), "Droplet already marked for render") + local node = RenderOctree:CreateNode(droplet:GetPosition(), droplet) + + droplet:GetSignal("PositionChanged"):Connect(function(newPos) + RenderOctree:ChangeNodePosition(node, newPos) + end) + + local function Remove() + RenderOctree:RemoveNode(node) + end + droplet:GetSignal("TimedOut"):Once(Remove) + droplet:GetDestroyedSignal():Once(Remove) end --[=[ @@ -226,102 +226,103 @@ end Called when the server informs us that a new droplet has been created ]=] function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.DropletNetworkPacket) - local rtData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType) - assert(rtData, `Resource type '{tostring(networkPacket.ResourceType)}' not registered`) - local DEFAULTS = rtData.Defaults - - local Seed = networkPacket.Seed - local Value = networkPacket.Value - local Count = networkPacket.Count - local LifeTime = networkPacket.LifeTime - networkPacket.Metadata = networkPacket.Metadata or DEFAULTS.Metadata - - local Droplets: {[number]: Droplet} = {} - DropletStorage[Seed] = { - NetworkPacket = networkPacket, - Droplets = Droplets, - } - - local NumGen = Random.new(Seed) - local EjectionDuration = DropletUtil.parse(networkPacket.EjectionDuration, NumGen) - - for i, rawData in DropletUtil.calculateDropletValues(Value, Count, Seed, LifeTime) do - local droplet = Droplet.new({ - Id = i, - NetworkPacket = networkPacket, - ResourceTypeData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType), - - Value = rawData.RawValue, - LifeTime = rawData.RawLifeTime, - - DropletClientManager = DropletClientManager, - }) - - droplet:AddTask(function() - Droplets[i] = nil - end) - - droplet:GetSignal("Collected"):Connect(function(collector) - if collector == LocalPlayer then - DropletClientManager:_RequestCollectDroplet(droplet) - end - end) - - - local dropletModel: Model = droplet:GetModel() - local spawnCFrame = ParseLocation(networkPacket.SpawnLocation) - dropletModel:PivotTo(spawnCFrame) - dropletModel.Parent = DropletsFolder - local dropletPrimaryPart = dropletModel.PrimaryPart - assert(dropletPrimaryPart, "Droplet model has no primary part") - - -- Extract ejection direction from spawn location if it's a CFrame, otherwise use default - local ejectionDirection = networkPacket.EjectionDirection or DEFAULTS.EjectionDirection - if not ejectionDirection and typeof(networkPacket.SpawnLocation) == "CFrame" then - ejectionDirection = networkPacket.SpawnLocation.LookVector - elseif not ejectionDirection and typeof(networkPacket.SpawnLocation) == "table" and networkPacket.SpawnLocation.CF then - ejectionDirection = networkPacket.SpawnLocation.CF.LookVector - end - - dropletPrimaryPart.AssemblyLinearVelocity = CalculateEjectionVelocity( - networkPacket.EjectionHorizontalVelocity or DEFAULTS.EjectionHorizontalVelocity, - networkPacket.EjectionVerticalVelocity or DEFAULTS.EjectionVerticalVelocity, - ejectionDirection, - NumGen - ) - - Droplets[i] = droplet - task.wait(EjectionDuration/Count) - end + local rtData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType) + assert(rtData, `Resource type '{tostring(networkPacket.ResourceType)}' not registered`) + local DEFAULTS = rtData.Defaults + + local Seed = networkPacket.Seed + local Value = networkPacket.Value + local Count = networkPacket.Count + local LifeTime = networkPacket.LifeTime + networkPacket.Metadata = networkPacket.Metadata or DEFAULTS.Metadata + + local Droplets: { [number]: Droplet } = {} + DropletStorage[Seed] = { + NetworkPacket = networkPacket, + Droplets = Droplets, + } + + local NumGen = Random.new(Seed) + local EjectionDuration = DropletUtil.parse(networkPacket.EjectionDuration, NumGen) + + for i, rawData in DropletUtil.calculateDropletValues(Value, Count, Seed, LifeTime) do + local droplet = Droplet.new { + Id = i, + NetworkPacket = networkPacket, + ResourceTypeData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType), + + Value = rawData.RawValue, + LifeTime = rawData.RawLifeTime, + + DropletClientManager = DropletClientManager, + } + + droplet:AddTask(function() + Droplets[i] = nil + end) + + droplet:GetSignal("Collected"):Connect(function(collector) + if collector == LocalPlayer then + DropletClientManager:_RequestCollectDroplet(droplet) + end + end) + + local dropletModel: Model = droplet:GetModel() + local spawnCFrame = ParseLocation(networkPacket.SpawnLocation) + dropletModel:PivotTo(spawnCFrame) + dropletModel.Parent = DropletsFolder + local dropletPrimaryPart = dropletModel.PrimaryPart + assert(dropletPrimaryPart, "Droplet model has no primary part") + + -- Extract ejection direction from spawn location if it's a CFrame, otherwise use default + local ejectionDirection = networkPacket.EjectionDirection or DEFAULTS.EjectionDirection + if not ejectionDirection and typeof(networkPacket.SpawnLocation) == "CFrame" then + ejectionDirection = networkPacket.SpawnLocation.LookVector + elseif + not ejectionDirection + and typeof(networkPacket.SpawnLocation) == "table" + and networkPacket.SpawnLocation.CF + then + ejectionDirection = networkPacket.SpawnLocation.CF.LookVector + end + + dropletPrimaryPart.AssemblyLinearVelocity = CalculateEjectionVelocity( + networkPacket.EjectionHorizontalVelocity or DEFAULTS.EjectionHorizontalVelocity, + networkPacket.EjectionVerticalVelocity or DEFAULTS.EjectionVerticalVelocity, + ejectionDirection, + NumGen + ) + + Droplets[i] = droplet + task.wait(EjectionDuration / Count) + end end - --[=[ @private Called when the server informs us that a droplet has been claimed ]=] function DropletClientManager:_OnClaimDroplet(collector: Player, seed: number, dropletId: number) - local dropletRequest = DropletStorage[seed] - assert(dropletRequest, `No Droplet-Request found with seed '{seed}'`) + local dropletRequest = DropletStorage[seed] + assert(dropletRequest, `No Droplet-Request found with seed '{seed}'`) - local droplet = dropletRequest.Droplets[dropletId] - if not droplet then - warn(dropletRequest.Droplets) - error(`No Droplet found for [{seed}][{dropletId}]`) - end + local droplet = dropletRequest.Droplets[dropletId] + if not droplet then + warn(dropletRequest.Droplets) + error(`No Droplet found for [{seed}][{dropletId}]`) + end - droplet:Claim(collector) + droplet:Claim(collector) end - --[=[ @private Ask the server to claim the droplet so that it can be collected by the player ]=] function DropletClientManager:_RequestClaimDroplet(droplet: Droplet) - local seed, dropletId = droplet:Identify() - --print(`Requesting claim of droplet [{seed}][{dropletId}]`) - Replicator.DropletClaimed:Fire(seed, dropletId) + local seed, dropletId = droplet:Identify() + --print(`Requesting claim of droplet [{seed}][{dropletId}]`) + Replicator.DropletClaimed:Fire(seed, dropletId) end --[=[ @@ -329,10 +330,9 @@ end Inform the server the client successfully collected the droplet. ]=] function DropletClientManager:_RequestCollectDroplet(droplet: Droplet) - local seed, dropletId = droplet:Identify() - --print(`Requesting collect of droplet [{seed}][{dropletId}]`) - Replicator.DropletCollected:Fire(seed, dropletId) + local seed, dropletId = droplet:Identify() + --print(`Requesting collect of droplet [{seed}][{dropletId}]`) + Replicator.DropletCollected:Fire(seed, dropletId) end - -return DropletClientManager \ No newline at end of file +return DropletClientManager diff --git a/lib/roam/src/Bootstrappers/ClientBootstrapper.luau b/lib/roam/src/Bootstrappers/ClientBootstrapper.luau index 86f0232..5b9f937 100644 --- a/lib/roam/src/Bootstrappers/ClientBootstrapper.luau +++ b/lib/roam/src/Bootstrappers/ClientBootstrapper.luau @@ -4,47 +4,68 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local BootstrapperModule = script +local Promise = require(BootstrapperModule.Parent.Parent.Parent.Promise) +local types = require(BootstrapperModule.Parent.Parent.types) -local function StartGame(script) - if script.Name == "start" then - script:Destroy() - local Promise = require(BootstrapperModule.Parent.Parent.Parent.Promise) - return Promise.reject() - end - - local Roam = require(BootstrapperModule.Parent.Parent) - local SRC_NAME = Roam.DEFAULT_SRC_NAME - -- Roam.Debug = true -- Enables prints to see when services Init and Start - - -- Require modules under the Client folder that end. - Roam.requireModules({ - ReplicatedStorage[SRC_NAME].Client; - ReplicatedStorage[SRC_NAME].Shared; - }, { - DeepSearch = true; - RequirePredicate = function(obj: ModuleScript) -- Only require modules that end in "Service" or "Controller" - local isService = obj.Name:match("Service$") or obj.Name:match("Controller$") - return isService - end; - IgnoreDescendantsPredicate = function(obj: Instance) -- Ignore the "node_modules_dependencies" folder and anything under "Server" - return obj.Name == "Server" -- or obj.Name == DPDN - end; - }) - - - -- Wait for the server to start Orion - if not workspace:GetAttribute("RoamStarted") then - workspace:GetAttributeChangedSignal("RoamStarted"):Wait() - end - - - -- Start Roam - return Roam.start():andThen(function() - print("[CLIENT] Roam Started!") - end):catch(function(err) - warn(err) - error("[CLIENT] Roam Failed to Start!") - end) +local function checkConfig(config: any, field: string, default: any?) + if config and config[field] ~= nil then + return config[field] + end + return default end -return StartGame \ No newline at end of file +local function StartGame( + script, + config: ({ + AllowYieldingRequires: boolean?, -- Default: false + StopOnFailedRequire: boolean?, -- Default: true + } & types.StartConfig)? +) + local Roam = require(BootstrapperModule.Parent.Parent) + if script.Name == "start" then -- We may want to remove this check later. It doesn't allow nice custom structures since its tailored to my opinion. + script:Destroy() + return Promise.resolve(false) + end + + -- Require modules under the Client and Shared folder that end in "Service" or "Controller" + local SRC_NAME = Roam.DEFAULT_SRC_NAME + local result = Roam.requireModules({ + ReplicatedStorage[SRC_NAME].Client, + ReplicatedStorage[SRC_NAME].Shared, + }, { + DeepSearch = true, + StopOnFailedRequire = checkConfig(config, "StopOnFailedRequire", true), + AllowYieldingRequires = checkConfig(config, "AllowYieldingRequires", false), + RequirePredicate = function(obj: ModuleScript) -- Only require modules that end in "Service" or "Controller" + local isService = obj.Name:match("Service$") or obj.Name:match("Controller$") + return isService + end, + IgnoreDescendantsPredicate = function(obj: Instance) -- Ignore anything under "Server" + return obj.Name == "Server" + end, + }) + + if not result.Success then + task.spawn(error, "šŸ›‘ [CLIENT] Failed module requires during client bootstrap šŸ›‘") + return Promise.resolve(false) + end + + -- Wait for the server to start + if not workspace:GetAttribute("RoamStarted") then + workspace:GetAttributeChangedSignal("RoamStarted"):Wait() + end + + -- Start Roam + return Roam.start(config) + :andThen(function() + print("[CLIENT] Roam Started!") + return true + end) + :catch(function(err) + local sanitizedError = tostring(err):gsub("%[(.-):", "") -- Replaces failure warning so I can add a fancier looking one + task.spawn(error, "šŸ›‘ [CLIENT] Roam Failed to Start! šŸ›‘" .. sanitizedError) + return false + end) +end + +return StartGame diff --git a/lib/roam/src/Bootstrappers/ServerBootstrapper.luau b/lib/roam/src/Bootstrappers/ServerBootstrapper.luau index c878f71..394dd28 100644 --- a/lib/roam/src/Bootstrappers/ServerBootstrapper.luau +++ b/lib/roam/src/Bootstrappers/ServerBootstrapper.luau @@ -8,6 +8,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local BootstrapperModule = script +local types = require(BootstrapperModule.Parent.Parent.types) +local Promise = require(BootstrapperModule.Parent.Parent.Parent.Promise) local function ShutdownServer(err) local shutdownMsg = `This server has shutdown due to ROAM failing to boot, please contact a dev:\n{err}` @@ -25,42 +27,63 @@ local function ShutdownServer(err) end end -local function StartGame(script) - if script.Name == "start" then - script:Destroy() - local Promise = require(BootstrapperModule.Parent.Parent.Parent.Promise) - return Promise.reject() +local function checkConfig(config: any, field: string, default: any?) + if config and config[field] ~= nil then + return config[field] end + return default +end +local function StartGame( + script, + config: ({ + AllowYieldingRequires: boolean?, -- Default: false + StopOnFailedRequire: boolean?, -- Default: true + } & types.StartConfig)? +) local Roam = require(BootstrapperModule.Parent.Parent) - local SRC_NAME = Roam.DEFAULT_SRC_NAME - -- Roam.Debug = true -- Enables prints to see when services Init and Start + if script.Name == "start" then -- We may want to remove this check later. It doesn't allow nice custom structures since its tailored to my opinion. + script:Destroy() + return Promise.resolve(false) + end - -- Register modules under the Server folder. - Roam.requireModules({ + -- Register modules under the Server and Shared folders. + local SRC_NAME = Roam.DEFAULT_SRC_NAME + local result = Roam.requireModules({ ServerScriptService[SRC_NAME].Server, ReplicatedStorage[SRC_NAME].Shared, }, { DeepSearch = true, + StopOnFailedRequire = checkConfig(config, "StopOnFailedRequire", true), + AllowYieldingRequires = checkConfig(config, "AllowYieldingRequires", false), RequirePredicate = function(obj: ModuleScript) -- Only require modules that end in "Service" local isService = obj.Name:match("Service$") return isService end, - IgnoreDescendantsPredicate = function(obj: Instance) -- Ignore the "node_modules_dependencies" folder and anything under "Client" - return obj.Name == "Client" -- or obj.Name == DPDN + IgnoreDescendantsPredicate = function(obj: Instance) -- Ignore anything under "Client" + return obj.Name == "Client" end, }) + if result.Success == false then + task.spawn(error, "šŸ›‘ [SERVER] Failed module requires during server bootstrap šŸ›‘") + ShutdownServer("Failed module requires during server bootstrap") + return Promise.resolve(false) + end + -- Start Roam - return Roam.start() + return Roam.start(config) :andThen(function() print("[SERVER] Roam Started!") workspace:SetAttribute("RoamStarted", true) -- Alert the Client that the Server is ready + return true end) :catch(function(err) + local sanitizedError = tostring(err):gsub("%[(.-):", "") -- Replaces failure warning so I can add a fancier looking one + task.spawn(error, "šŸ›‘ [SERVER] Roam Failed to Start! šŸ›‘" .. sanitizedError) ShutdownServer(err) - task.spawn(error, "šŸ›‘ [SERVER] Roam Failed to Start! šŸ›‘\n\t" .. tostring(err)) + return false end) end -return StartGame \ No newline at end of file +return StartGame diff --git a/lib/roam/src/Bootstrappers/_ExampleSetup.luau b/lib/roam/src/Bootstrappers/_ExampleSetup.luau index d342e5e..3bbf8d6 100644 --- a/lib/roam/src/Bootstrappers/_ExampleSetup.luau +++ b/lib/roam/src/Bootstrappers/_ExampleSetup.luau @@ -9,19 +9,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local Packages = ReplicatedStorage.Packages -local Roam = require(Packages.Roam) :: any ---@module Roam +local Roam = require(Packages.Roam) local function StartGame() - -- Register modules under the Server folder. - Roam.requireModules( - { - ServerScriptService.Orion.Server, - }, - true, - function(obj: ModuleScript) -- Only require modules that end in "Service" - return obj.Name:match("Service$") ~= nil - end - ) + for _, module in ServerScriptService.Server:QueryDescendants("ModuleScript") do + require(module) + end -- Start Roam return Roam.start() @@ -44,15 +37,12 @@ StartGame() local ReplicatedStorage = game:GetService("ReplicatedStorage") local Packages = ReplicatedStorage.Packages -local Roam = require(Packages.Roam) :: any ---@module Roam +local Roam = require(Packages.Roam) local function StartGame() - -- Require modules under the Client folder that end. - Roam.requireModules( - { - ReplicatedStorage.Orion.Client, - } - ) + for _, module in ReplicatedStorage.Client:QueryDescendants("ModuleScript") do + require(module) + end -- Wait for the server to start Roam if not workspace:GetAttribute("RoamStarted") then @@ -77,11 +67,9 @@ StartGame() -------------------------------------------------------------------------------- local ReplicatedStorage = game:GetService("ReplicatedStorage") - local Packages = ReplicatedStorage.Packages -local Roam = require(Packages.Roam) :: any ---@module Roam -local MyService = Roam.createService("MyService") ---@class MyService +local MyService = {} function MyService:RoamStart() -- Game Logic @@ -91,4 +79,7 @@ function MyService:RoamInit() -- Initialize the Service end +local Roam = require(Packages.Roam) +Roam.registerService(MyService, "MyService") + return MyService diff --git a/lib/roam/src/init.luau b/lib/roam/src/init.luau index c2f700c..e7e8749 100644 --- a/lib/roam/src/init.luau +++ b/lib/roam/src/init.luau @@ -9,37 +9,42 @@ initialize and start services in a topologically sorted manner without the need to manually order and start services. - Roam follows a design pattern similar to [Knit](https://sleitnick.github.io/Knit/), but is more lightweight. It removes all networking and replication - functionality, and instead focuses on providing a simple methodology to easily - initialize Services given to it. - - Roam gathers a collection of specified services and initializes 'syncronously'. - Once all services have been fully initialized, it then starts them 'asyncronously' by - spawning their 'RoamStart' method in a new thread. + Roam follows a design pattern similar to [Knit](https://sleitnick.github.io/Knit/), but is more lightweight. + It removes all networking and replication functionality, and instead focuses on providing a simple methodology + to easily initialize and start Services given to it. Roam is RunContext agnostic, meaning it can be used on both the server and client in the same manner. It makes no distinction between the two, and instead focuses on providing a simple interface for initializing and starting services. This means you could create a service and register it on both the server and client, and it will be initialized and started on both ends. - **[CONTRACTS]** - - Services must be created/registered before Roam is started. - - Services must be created/registered with a unique name. - - Services with `RoamInit` and `RoamStart` methods will have those methods - called when Roam is started at the appropriate time. (Names are configurable) - - `RequiredServices` boot in proper topological order if specified in the ServiceConfig. - - Roam functions the same regardless of RunContext (Server/Client). + **[EXAMPLE SERVICE]** + ```lua + -- MyService.lua + local MyService = {} + + function MyService:RoamInit() + print("MyService initialized!") + end + + function MyService:RoamStart() + print("MyService started!") + end + + -- Register the service table with Roam + local Roam = require(ReplicatedStorage.Roam) + Roam.registerService(MyService, "MyService") + + return MyService + ``` **[EXAMPLE STARTUP]** - ```lua -- ServerBootstrapper.Server.lua + ```lua + -- ServerBootstrapper.server.lua local Roam = require(ReplicatedStorage.Roam) - -- Just iterates through all the children of the given parents - -- and requires any module scripts that match the given predicate - Roam.requireModules({ - ReplicatedStorage.Shared; - ServerScriptService.Server; - }) + -- Require your services. (Tip: Roam.requireModules can help abstract this process!) + require(ReplicatedStorage.MyService) -- Start Roam Roam.start() @@ -47,6 +52,14 @@ :catch(warn) ``` + **[CONTRACTS]** + - Services must be created/registered before Roam is started. + - Services must be created/registered with a unique name. + - Services with `RoamInit` and `RoamStart` methods will have those methods + called when Roam is started at the appropriate time. (Names are configurable) + - `RequiredServices` boot in proper topological order if specified in the ServiceConfig. + - Roam functions the same regardless of RunContext (Server/Client). + :::info Setting up Services Services can be set up in a variety of ways. The most common way is to create a ModuleScript that returns a table with the methods you want to define, and then register it with Roam just prior @@ -57,290 +70,462 @@ :::tip Networking Roam does not inherently have networking functionality. However, it can easily be added through the use of NetWire's - **[.setupServiceNetworking](https://raild3x.github.io/ModulesOnRails/api/ServerNetWire/#setupServiceNetworking)** funtion. + **[.setupServiceNetworking](https://raild3x.github.io/ModulesOnRails/api/ServerNetWire/#setupServiceNetworking)** function. ::: -]=] + ---- + LIFECYCLE + ---- + For those interested in the full execution order roam follows for booting services, see the diagrams below: + + **Initialization Phase (Synchronous):** + - `GlobalPreInit` - Called once before ANY service initializes (global setup) + - For each service (in dependency order): + - Await dependencies to finish initializing + - `PreInit(service)` - Called before this service's RoamInit + - Service's `RoamInit()` method executes + - `PostInit(service)` - Called after this service's RoamInit + - `GlobalPostInit` - Called once after ALL services finish initializing + + **Start Phase (Fully Async - Does Not Block):** + - `GlobalPreStart` - Called once before ANY service starts. + - For each service (in dependency order): + - `PreStart(service)` - Called before this service's RoamStart + - Service's `RoamStart()` method executes + - `PostStart(service)` - Called after this service's RoamStart + - `GlobalPostStart` - Called once after ALL services start + + + **Key Concepts:** + - **Parallel Initialization**: Services without dependencies initialize concurrently via coroutines + - **Dependency Blocking**: Services wait for their dependencies' PostInit before starting their PreInit + - **Synchronous Init Phase**: The Initialization Phase completes fully before Start Phase begins + - **Async Start Phase**: The Start Phase spawns services lifecycle methods asynchronously + + ![Advanced Roam Lifecycle Diagram](../roam/RoamLifecycleAdvanced.png) +]=] local RunService = game:GetService("RunService") local Packages = script.Parent -local Promise = require(Packages.Promise) ---@module _Promise -local Symbol = require(Packages.Symbol) ---@module _Symbol - -local KEY_CONFIG = Symbol("RoamServiceConfig") +local Promise = require(Packages.Promise) +local Symbol = require(Packages.Symbol) +local Types = require(script.types) -local Roam_START_METHOD_NAME = "RoamStart" -local Roam_INIT_METHOD_NAME = "RoamInit" - -local RunContext = RunService:IsServer() and "SERVER" or "CLIENT" +local Roam = {} +-------------------------------------------------------------------------------- --// Types //-- -type table = { [any]: any } -export type Service = table - ---[=[ - @within Roam - @interface ServiceConfig - @field Name string -- Name of the Service. Must be unique. Used when accessing via .getService - @field RequiredServices {Service}? -- The Services that this Service depends on. Roam will ensure that these Services are initialized before this Service. - @field StartMethodName string? -- Overrides default StartMethodName of "RoamStart" - @field InitMethodName string? -- Overrides default InitMethodName of "RoamInit" - - ```lua - local myOtherService = require(ReplicatedStorage.MyOtherService) - - ------------------------------------------------- - - local MyService = {} - - function MyService:CustomStartMethod() - print("MyService started!") - end - - ------------------------------------------------- +-------------------------------------------------------------------------------- - Roam.registerService(MyService, { - Name = "MyService", - RequiredServices = {myOtherService}, - StartMethodName = "CustomStartMethod", - }) +export type Service = Types.Service +export type ServiceConfig = Types.ServiceConfig +export type ServiceState = Types.ServiceState +export type StartConfig = Types.StartConfig - return MyService - ``` +type Promise = typeof(Promise.new()) +type table = { [any]: any } - :::caution Deffering RequiredServices - Do NOT add services to the RequiredServices after you have created or registered the service. This will cause undefined behavior. - ::: -]=] -export type ServiceConfig = { - Name: string, -- Name of the Service. Must be unique. Used when accessing via .getService - RequiredServices: {Service}?, -- The Services that this Service depends on. Roam will ensure that these Services are initialized before this Service. - StartMethodName: string?, -- Overrides default StartMethodName of "RoamStart" - InitMethodName: string?, -- Overrides default InitMethodName of "RoamInit" - [any]: any, -} +-------------------------------------------------------------------------------- +--// Constants //-- +-------------------------------------------------------------------------------- -type Promise = typeof(Promise.new()) +local KEY_CONFIG = Symbol("RoamServiceConfig") +local DEFAULT_START_METHOD = "RoamStart" +local DEFAULT_INIT_METHOD = "RoamInit" +local INIT_TIMEOUT_SECONDS = 120 +local RUN_CONTEXT = RunService:IsServer() and "SERVER" or "CLIENT" -------------------------------------------------------------------------------- ---// Volatiles //-- +--// State //-- -------------------------------------------------------------------------------- local services: { [string]: Service } = {} - +local serviceStates: { [Service]: ServiceState } = {} -- Track state of each service local started = false local startedComplete = false local onStartedComplete = Instance.new("BindableEvent") -------------------------------------------------------------------------------- ---// Private Functions //-- +--// Utility Functions //-- -------------------------------------------------------------------------------- --- Checks to see if an Instance's Name ends in `Service` -local function ServiceNameMatch(obj: Instance) +local function serviceNameMatch(obj: Instance): boolean return obj.Name:match("Service$") ~= nil end --- Reconciles a primary table with a secondary table -local function Reconcile(primary, secondary) - primary = primary or {} - secondary = secondary or {} - for i in pairs(secondary) do - primary[i] = primary[i] or secondary[i] - end - return primary +local function setServiceState(service: Service, state: ServiceState): () + serviceStates[service] = state end --- checks if a service exists with the given name -local function DoesServiceExist(serviceName: string): boolean - local service: Service? = services[serviceName] - return service ~= nil +local function serviceExists(service: string | table): boolean + if type(service) == "string" then + return services[service] ~= nil + elseif type(service) == "table" then + -- Check if the service table is registered + for _, registeredService in pairs(services) do + if registeredService == service then + return true + end + end + return false + else + error("serviceExists expects a string or table as argument") + end end --- requires a given modulescript and throws a safe error if it yields -local function EnsureUnyieldingRequire(module: ModuleScript) - local moduleContent +local FAILED_REQUIRE_YIELD = Symbol("FailedRequireYield") +local function ensureUnyieldingRequire(module: ModuleScript) + local moduleContent = FAILED_REQUIRE_YIELD task.spawn(function() - local current + local current: thread? = nil task.spawn(function() current = coroutine.running() local success, msg = pcall(function() moduleContent = require(module) :: any + return moduleContent end) assert(success, `Failed to load module: {module.Name}\n{msg}`) end) - assert(coroutine.status(current) == "dead", "Roam Require Yielded: ".. module:GetFullName()) + + if current and coroutine.status(current) ~= "dead" then + -- This is not a great traceback. I wish it could find the original yield point, but I can't detect which modules are being required. + error( + `Roam Require detected yield. Check the following module and the modules it requires: {module:GetFullName()}` + ) + end end) + return moduleContent end +local function formatError(context: string, details: string): string + return `[Roam {RUN_CONTEXT}] {context}: {details}` +end + -------------------------------------------------------------------------------- ---// Roam //-- +--// Validation Functions //-- -------------------------------------------------------------------------------- -local Roam = {} -Roam.ClassName = "Roam" -Roam.Services = services -- A table of Services. Only properly accessible after Roam has been started. -Roam.ServiceNameMatch = ServiceNameMatch -Roam.Debug = false -- Whether or not to print debug messages -Roam.DEFAULT_SRC_NAME = "src" +local function validateServiceConfig(serviceConfig: any, serviceName: string?): ServiceConfig + if typeof(serviceConfig) == "string" then + serviceConfig = { Name = serviceConfig } + elseif not serviceConfig then + serviceConfig = {} + end -Roam.Bootstrappers = { -- Generic Bootstrappers for Roam / Orion - Server = require(script:FindFirstChild("Bootstrappers"):FindFirstChild("ServerBootstrapper")) :: (script: Script) -> (Promise); - Client = require(script:FindFirstChild("Bootstrappers"):FindFirstChild("ClientBootstrapper")) :: (script: Script) -> (Promise); -} + if typeof(serviceConfig) ~= "table" then + error(formatError("Invalid ServiceConfig", `Expected table, got {typeof(serviceConfig)}`)) + end ---[=[ - @private - @within Roam - @tag ReadOnly - @prop ClassName "Roam" - The ClassName of the Roam module. -]=] + local name = serviceConfig.Name or serviceName + if not name then + error(formatError("Missing Service Name", "Service must have a name")) + end ---[=[ - @within Roam - @prop Debug boolean - Whether or not to print debug messages. Default is false. -]=] + if type(name) ~= "string" or #name == 0 then + error(formatError("Invalid Service Name", `Name must be a non-empty string, got {type(name)}`)) + end ---[=[ - @within Roam - @prop Bootstrappers {Server: (script: Script) -> (), Client: (script: Script) -> ()} - A table of generic bootstrappers for Roam that you can use to quickly setup new projects. - ```lua - local Roam = require(Packages.Roam) + if serviceExists(name) then + error(formatError("Duplicate Service", `Service "{name}" already exists`)) + end - Roam.Bootstrappers.Server(script) - :andThenCall(print, "Roam Server Bootstrapped!") - ``` -]=] + if serviceConfig.RequiredServices then + for i, requiredService in ipairs(serviceConfig.RequiredServices :: { Service }) do + if type(requiredService) ~= "table" then + error( + formatError( + "Invalid Required Service", + `RequiredServices[{i}] must be a Service table, got {typeof(requiredService)} while registering "{name}"` + ) + ) + end + if not serviceExists(requiredService :: Service) then + warn( + formatError( + "Unregistered Required Service", + `RequiredServices[{i}] is not a registered service while registering "{name}"` + ) + ) + end + end + end ---[=[ - Registers a Service/Table with Roam to be Initialized and Started when Roam starts. - Cannot be called after Roam has been started. + -- Detect and remove duplicates from RequiredServices + if serviceConfig.RequiredServices then + local seen = {} + local uniqueRequiredServices = {} + for i, reqService in ipairs(serviceConfig.RequiredServices :: { Service }) do + if not seen[reqService] then + seen[reqService] = true + table.insert(uniqueRequiredServices, reqService) + else + warn( + formatError( + "Duplicate RequiredService", + `Service "{name}" has duplicate required service at index {i}` + ) + ) + end + end + serviceConfig.RequiredServices = uniqueRequiredServices + end + + return serviceConfig :: ServiceConfig +end + +local function validateRequiredServices(): { string } + local missingServices = {} + + for serviceName, service in pairs(services) do + local config: ServiceConfig = service[KEY_CONFIG] + if not config.RequiredServices then + continue + end - ```lua -- MyRegisteredService.lua - local MyRegisteredService = {} + for _, requiredService in ipairs(config.RequiredServices) do + local found = false + for _, existingService in pairs(services) do + if requiredService == existingService then + found = true + break + end + end - function MyRegisteredService:RoamStart() - print("MyRegisteredService started!") + if not found then + table.insert(missingServices, `Required service for '{serviceName}' not found`) + end + end end - function MyRegisteredService:RoamInit() - print("MyRegisteredService initialized!") + return missingServices +end + +-------------------------------------------------------------------------------- +--// Service Management //-- +-------------------------------------------------------------------------------- + +local function createServiceInitPromise(service: Service, config: ServiceConfig): Promise? + local initMethodName = config.InitMethodName or DEFAULT_INIT_METHOD + local initMethod = service[initMethodName] + + if type(initMethod) ~= "function" then + return Promise.resolve(0) end - ---------------------------------------------------------------- + return Promise.new(function(resolve, reject) + if Roam.Debug then + print(`[{RUN_CONTEXT}] Initializing {config.Name}`) + end - local Roam = require(Packages.Roam) - Roam.registerService(MyRegisteredService, "MyRegisteredService") + local startTime = os.clock() + local initFunction = service[initMethodName] - return MyRegisteredService - ``` -]=] -function Roam.registerService(service: Service, serviceConfig: (ServiceConfig | string)?): Service - assert(not started, "Cannot register Services after Roam has been started") - assert(type(service) == "table", `Service must be a table; got {type(service)}`) + -- Prevent re-initialization + service[initMethodName] = function() + error(`{config.Name} | Cannot call Init method after service has been initialized`) + end - if typeof(serviceConfig) == "string" then - serviceConfig = { Name = serviceConfig } - elseif not serviceConfig then - serviceConfig = {} :: any + local elapsed + local initThread = task.spawn(function() + debug.setmemorycategory(config.Name .. "_Init") + initFunction(service) + elapsed = os.clock() - startTime + end) + + while coroutine.status(initThread) ~= "dead" do + task.wait() + end + + if not elapsed then + reject(`Failed to initialize {config.Name}`) + return + end + + if Roam.Debug then + print(`[{RUN_CONTEXT}] Initialized {config.Name} in {string.format("%.3f", elapsed)}s`) + end + + resolve(elapsed) + end):timeout(config.InitTimeout or INIT_TIMEOUT_SECONDS, `Service {config.Name} took too long to initialize`) +end + +local function startService(service: Service, config: ServiceConfig): () + local startMethodName = config.StartMethodName or DEFAULT_START_METHOD + local startMethod = service[startMethodName] + + if type(startMethod) ~= "function" then + return end - assert(typeof(serviceConfig) == "table", `ServiceConfig must be a table; got {typeof(serviceConfig)}`) - --serviceConfig.ENV = getfenv(2) -- This is no longer supported + task.spawn(function() + if Roam.Debug then + print(`[{RUN_CONTEXT}] Starting {config.Name}`) + end + + debug.setmemorycategory(config.Name .. "_Start") + local startFunction = service[startMethodName] - local Name = serviceConfig.Name or service.Name - if not Name then - error("No name provided for service") - -- Name = serviceConfig.ENV.script.Name - -- warn(`No Service name was given; this is not recommended. Roam will attempt to continue by attempting to infer the ServiceName. [Inferred Service Name: "{Name}"]`) + -- Prevent re-starting + service[startMethodName] = function() + error(`{config.Name} | Cannot call Start method after service has been started`) + end + + startFunction(service) + end) +end + +-------------------------------------------------------------------------------- +--// Topological Sorting //-- +-------------------------------------------------------------------------------- + +local function createAdjacencyList(): { [Service]: { Service } } + local adjacencyList = {} + for _, service in pairs(services) do + adjacencyList[service] = service[KEY_CONFIG].RequiredServices or {} end + return adjacencyList +end - assert( - not serviceConfig or type(serviceConfig) == "table", - `ServiceConfig must be a table; got {type(serviceConfig)}` - ) - assert(type(Name) == "string", `Service.Name must be a string; got {type(Name)}`) - assert(#Name > 0, "Service.Name must be a non-empty string") - assert(not DoesServiceExist(Name), `Service "{Name}" already exists`) +local function topologicalSort(adjacencyList: { [Service]: { Service } }): { Service } + local visited = {} + local stack = {} + + local function visit(service: Service) + if visited[service] then + return + end - service[KEY_CONFIG] = table.freeze(Reconcile(serviceConfig :: any, { - Name = Name, - })) - services[Name] = service + visited[service] = true - return service + for _, dependency in ipairs(adjacencyList[service] or {}) do + visit(dependency) + end + + table.insert(stack, service) + end + + for service in pairs(adjacencyList) do + visit(service) + end + + return stack +end + +local function getSortedServices(): { Service } + local adjacencyList = createAdjacencyList() + return topologicalSort(adjacencyList) end +-------------------------------------------------------------------------------- +--// Main Roam Module //-- +-------------------------------------------------------------------------------- + --[=[ - @deprecated 0.1.5 + @within Roam + @deprecated 0.1.6 @private - Creates a Service/Table with Roam to be Initialized and Started when Roam starts. - Cannot be called after Roam has been started. + @prop Services {[string]: Service} + A table of Services. Only properly accessible after Roam has been started. +]=] +Roam.Services = services - This is an alternative method to setting up services over using `registerService`. +Roam.ServiceNameMatch = serviceNameMatch +--[=[ + @within Roam + @tag debug + @private + @prop Debug boolean + Whether or not to print debug messages. Default is false. +]=] +Roam.Debug = false -- Whether or not to print debug messages + +--[=[ + @within Roam + @private + @prop DEFAULT_SRC_NAME string + The default name of the source folder where your modules are located. Default is "src". + This is only used by the generic Bootstrappers provided with Roam. +]=] +Roam.DEFAULT_SRC_NAME = "src" + +--[=[ + @within Roam + @prop Bootstrappers {Server: (script: Script) -> Promise, Client: (script: Script) -> Promise} + A table of generic bootstrappers for Roam that you can use to quickly setup new projects. ```lua - local Roam = require(ReplicatedStorage.Roam) + Roam.Bootstrappers.Server(script) + :andThenCall(print, "Roam Server Bootstrapped!") + ``` +]=] +type BootStrapper = (script: Script, config: { [string]: any }?) -> Promise +Roam.Bootstrappers = { -- Generic Bootstrappers for Roam / Orion + Server = require(script:FindFirstChild("Bootstrappers"):FindFirstChild("ServerBootstrapper")) :: BootStrapper, + Client = require(script:FindFirstChild("Bootstrappers"):FindFirstChild("ClientBootstrapper")) :: BootStrapper, +} - local MyService = Roam.createService { Name = "MyService" } - - function MyService:DoSomething() - print("yeee haw!") +--[=[ + Registers a Service/Table with Roam to be Initialized and Started when Roam starts. + Cannot be called after Roam has been started. + + ```lua -- MyService.lua + local MyService = {} + + function MyService:RoamInit() + print("MyService initialized!") end - -- Default StartMethodName is "RoamStart" (Can be overriden in service creation config) function MyService:RoamStart() print("MyService started!") - self:DoSomething() end - -- Default InitMethodName is "RoamInit" (Can be overriden in service creation config) - function MyService:RoamInit() - print("MyService initialized!") - end + ---------------------------------------------------------------- - return MyService + Roam.registerService(MyService, "MyService") ``` ]=] -function Roam.createService(serviceDef: ServiceConfig): Service - assert(not started, "Cannot create Services after Roam has been started") - assert(type(serviceDef) == "table", `Service must be a table; got {type(serviceDef)}`) - - local Name = serviceDef.Name - assert(type(Name) == "string", `Service.Name must be a string; got {type(Name)}`) - assert(#Name > 0, "Service.Name must be a non-empty string") - assert(not DoesServiceExist(Name), `Service "{Name}" already exists`) - - local service: Service = serviceDef - service[KEY_CONFIG] = table.freeze({ - Name = Name, - StartMethodName = serviceDef.StartMethodName, - InitMethodName = serviceDef.InitMethodName, - --ENV = getfenv(2), - }) +function Roam.registerService(service: Service, serviceConfig: (ServiceConfig | string)?): Service + if started then + error(formatError("Registration Error", "Cannot register services after Roam has started")) + end - -- Register Service to Roam - services[Name] = service + if type(service) ~= "table" then + error(formatError("Invalid Service", `Service must be a table, got {type(service)}`)) + end + + local config = validateServiceConfig(serviceConfig, service.Name) + + service[KEY_CONFIG] = config + services[config.Name] = service + serviceStates[service] = "REGISTERED" -- Track initial state return service end --[=[ - @param postInitPreStart (() -> (Promise?))? - @return Promise + @within Roam Starts Roam. Should only be called once. Calling multiple times will result in a promise rejection. - - Optional argument `postInitPreStart` is a function that is called - after all services have been initialized, but before they are started. + + Optional config argument provides lifecycle hooks. ```lua - Roam.start() + Roam.start({ + GlobalPreInit = function() + print("=== Initialization Phase Starting ===") + end, + PreInit = function(service) + print("Initializing:", Roam.getServiceName(service)) + end, + PostInit = function(service) + print("āœ“ Initialized:", Roam.getServiceName(service)) + end, + GlobalPostInit = function() + print("=== All Services Initialized ===") + end, + }) :andThenCall(print, "Roam started!") :catch(warn) ``` @@ -349,161 +534,174 @@ end Be sure that all services have been created _before_ calling `Start`. Services cannot be added later. ::: - - :::tip Bootstrapping - You can use the [Roam.Bootstrappers](Roam#Bootstrappers) table/methods to quickly bootstrap Roam in your project. - This is reccomended as it will provide a consistent starting point for your projects. - ::: ]=] -function Roam.start(postInitPreStart: (() -> Promise?)?): Promise +function Roam.start(config: StartConfig?): Promise if started then return Promise.reject("Roam already started") end - assert( - not postInitPreStart or type(postInitPreStart) == "function", - `postInitPreStart must be a function or nil; got {type(postInitPreStart)}` - ) - - started = true - --Roam.Started = started - - local topologicallySortedServices: {Service} - do -- fetch topologically sorted services - local function sortUtil(service, adjacencyList, visited, stack) - visited[service] = true - for _, neighbor in pairs(adjacencyList[service] or {}) do - if not visited[neighbor] then - sortUtil(neighbor, adjacencyList, visited, stack) - end - end - table.insert(stack, service) + -- Validate and extract lifecycle hooks from config + local function validateHook(hookName: string): ((...any) -> any)? + if not config then + return nil end - local function topologicalSort(adjacencyList) - local stack, visited = {}, {} - for service in pairs(adjacencyList) do - if not visited[service] then - sortUtil(service, adjacencyList, visited, stack) - end - end - return stack + local hookValue = (config :: any)[hookName] + if hookValue ~= nil and type(hookValue) ~= "function" then + error(formatError("Invalid Parameter", `{hookName} must be a function or nil, got {type(hookValue)}`)) end + return hookValue + end - -- Generate Adjacency List of Required Services - local adjacencyList = {} - for _, service in pairs(services) do - adjacencyList[service] = service[KEY_CONFIG].RequiredServices or {} - - -- Fetches services dynamically is no longer supported due to de-optimization - -- for _, envProp in pairs(service[KEY_CONFIG].ENV) do - -- if typeof(envProp) == "table" and envProp[KEY_CONFIG] then - -- if not table.find(adjacencyList[service], envProp) then - -- table.insert(adjacencyList[service], envProp) - -- end - -- end - -- end + -- Helper to call a lifecycle hook and handle Promise results + local function callLifecycleHook(hook: ((service: Service?) -> Promise?)?, service: Service?, async: boolean?) + if not hook then + return end - topologicallySortedServices = topologicalSort(adjacencyList) + if async then + task.defer(hook, service) + else + local result = hook(service) + -- If the hook returns a Promise, await it + if result and typeof(result) == "table" and result.await and type(result.await) == "function" then + (result :: any):await() + end + end end + -- Extract and validate all lifecycle hooks + local globalPreInit = validateHook("GlobalPreInit") + local preInit = validateHook("PreInit") + local postInit = validateHook("PostInit") + local globalPostInit = validateHook("GlobalPostInit") + local globalPreStart = validateHook("GlobalPreStart") + local preStart = validateHook("PreStart") + local postStart = validateHook("PostStart") + local globalPostStart = validateHook("GlobalPostStart") - return Promise.new(function(resolve) - table.freeze(services) + started = true - -- Init: - local totalInitTime = 0 - local promisesInitServices = {} - local servicesThatFailedToInit = {} - - for _, service in topologicallySortedServices do - local ServiceConfig = service[KEY_CONFIG] - local InitMethodName = ServiceConfig.InitMethodName or Roam_INIT_METHOD_NAME - if type(service[InitMethodName]) == "function" then - table.insert( - promisesInitServices, - Promise.new(function(resolveService, reject) - if Roam.Debug then - print(`[{RunContext}] Initializing {ServiceConfig.Name}`) - end - local t = os.clock() - - local serviceInitFn = service[InitMethodName] - service[InitMethodName] = function() - error(`{ServiceConfig.Name} | Cannot call Init method after service has been initialized`) - end - - local successfullyInitialized = false - local initThread = task.spawn(function() - debug.setmemorycategory(ServiceConfig.Name) - serviceInitFn(service) - successfullyInitialized = true - end) - while coroutine.status(initThread) ~= "dead" do - task.wait() -- Yield until the coroutine is dead - end - if not successfullyInitialized then - table.insert(servicesThatFailedToInit, ServiceConfig.Name) - reject(`Failed to initialize {ServiceConfig.Name}`) - return - end - - t = os.clock() - t - totalInitTime += t - if Roam.Debug then - print(`[ROAM {RunContext}] Initialized {ServiceConfig.Name} in {t} seconds.`) - end - resolveService() - end):timeout(120, `Service {ServiceConfig.Name} took too long to initialize.`) - ) - end - end + -- Validate all required services are registered + local missingServices = validateRequiredServices() + if #missingServices > 0 then + error(formatError("Missing Dependencies", table.concat(missingServices, "; "))) + end + local sortedServices = getSortedServices() + + return Promise.new(function(resolve, reject) + local startTime = os.clock() + table.freeze(services) Roam.Services = services - local InitProm = Promise.all(promisesInitServices) - :tap(function() - if Roam.Debug then - print(`[{RunContext}] ROAM Initialized all services in {totalInitTime} seconds.`) + -- GlobalPreInit hook + callLifecycleHook(globalPreInit) + + -- Track completion promises for each service (for dependency blocking) + local rejectedInitializations: { string } = {} + local serviceCompletionPromises: { [Service]: Promise } = {} + local initPromises: { Promise } = {} + + -- Initialize services with dependency-aware parallelization + for _, service in ipairs(sortedServices) do + local serviceConfig = service[KEY_CONFIG] + + local dependencyPromises = {} + if serviceConfig.RequiredServices then + for _, requiredService in ipairs(serviceConfig.RequiredServices :: { Service }) do + local depPromise = serviceCompletionPromises[requiredService] + if depPromise then + table.insert(dependencyPromises, depPromise) + else + warn( + formatError( + "Missing Dependency Promise", + `Service "{serviceConfig.Name}" has a required service that lacks a completion promise` + ) + ) + end end - end) - :catch(function() - task.wait() - local err = `ROAM failed to initialize services: ` .. table.concat(servicesThatFailedToInit, ", ") + end + + serviceStates[service] = "AWAITING_DEPENDENCIES" + local errMsg: string = "\n - " .. serviceConfig.Name + + local servicePromise = Promise.all(dependencyPromises):andThen(function() + -- Dependencies are ready + return Promise.resolve() + :andThenCall(setServiceState, service, "PRE_INIT") + :andThenCall(callLifecycleHook, preInit, service) + :andThenCall(setServiceState, service, "ROAM_INIT") + :andThenCall(createServiceInitPromise, service, serviceConfig) + :andThenCall(setServiceState, service, "POST_INIT") + :andThenCall(callLifecycleHook, postInit, service) + :andThenCall(setServiceState, service, "INITIALIZED") + :catch(function(err) + table.insert(rejectedInitializations, errMsg) + serviceStates[service] = "FAILED" + return Promise.reject(err) + end) + end, function(err) + table.insert(rejectedInitializations, errMsg .. " (Dependency Failed) [" .. tostring(err) .. "]") + serviceStates[service] = "FAILED" return Promise.reject(err) end) - resolve(InitProm) - end) - :andThen(function() - if postInitPreStart then - return postInitPreStart() :: Promise? + -- Track this service's completion for dependent services + serviceCompletionPromises[service] = servicePromise + table.insert(initPromises, servicePromise) end - return nil - end) - :andThen(function() - -- Start: - for _, service in topologicallySortedServices do - local ServiceConfig = service[KEY_CONFIG] - local StartMethodName = ServiceConfig.StartMethodName or Roam_START_METHOD_NAME - if type(service[StartMethodName]) == "function" then - task.spawn(function() - if Roam.Debug then - print(`[{RunContext}] Starting {ServiceConfig.Name}`) - end - debug.setmemorycategory(ServiceConfig.Name) - local serviceStartFn = service[StartMethodName] - service[StartMethodName] = function() - error(`{ServiceConfig.Name} | Cannot call Start method after service has been initialized`) - end - serviceStartFn(service) - end) + + -- Wait for all services to complete initialization + local success: boolean = Promise.all(initPromises):await() + + if success then + -- GlobalPostInit hook + callLifecycleHook(globalPostInit) + local totalInitTime = os.clock() - startTime + if Roam.Debug then + print(`[{RUN_CONTEXT}] All services initialized in {string.format("%.3f", totalInitTime)}s`) + end + resolve(totalInitTime) + else + local errMsg = + formatError("Initialization Failed for the following services", table.concat(rejectedInitializations)) + reject(errMsg) + end + end):andThen(function() + --------------------------------------------------------------------- + -- START Phase (Fully Async - Does Not Block) -- + --------------------------------------------------------------------- + + -- GlobalPreStart hook + callLifecycleHook(globalPreStart, nil, true) + + -- Start services with interleaved hooks (respects dependency order) + for _, service in ipairs(sortedServices) do + if serviceStates[service] == "FAILED" then + continue -- skip starting failed services end + local serviceConfig = service[KEY_CONFIG] + -- PreStart hook + serviceStates[service] = "PRE_START" + callLifecycleHook(preStart, service, true) + + -- Service Start + serviceStates[service] = "ROAM_START" + startService(service, serviceConfig) + + -- PostStart hook + serviceStates[service] = "POST_START" + callLifecycleHook(postStart, service, true) + + serviceStates[service] = "STARTED" end + -- GlobalPostStart hook + callLifecycleHook(globalPostStart, nil, true) + startedComplete = true - --Roam.Ready = startedComplete onStartedComplete:Fire() task.defer(function() @@ -516,15 +714,15 @@ end @return Promise Returns a promise that is resolved once Roam has started. This is useful for any code that needs to tie into Roam services but is not the script - that called `Start`. + that called `start`. ```lua Roam.onStart():andThen(function() - local MyService = Roam.Services.MyService + local MyService = require(ReplicatedStorage.MyService) MyService:DoSomething() end):catch(warn) ``` ]=] -function Roam.onStart() +function Roam.onStart(): Promise if startedComplete then return Promise.resolve() else @@ -544,8 +742,10 @@ end way to quickly load all services that might be in a folder. Takes an optional predicate function to filter which modules are loaded. Services collected this way must not yield. - `DeepSearch` -> whether it checks descendants or just children + - `AllowYieldingRequires` -> whether to allow required modules to yield (default: false) - `RequirePredicate` -> a predicate function that determines whether a module should be required - - `IgnoreDescendantsPredicate` -> A Predicate for whether the Descendants of the Module should be Searched (Only matters if DeepSearch is true) + - `IgnoreDescendantsPredicate` -> A Predicate for whether the Descendants of an instance should be Searched (Only matters if DeepSearch is true) + - `StopOnFailedRequire` -> whether to stop requiring modules if one fails to require (default: false). Useful for debugging, helps to clear excessive noise from the output. ```lua local pred = function(obj: ModuleScript): boolean @@ -565,58 +765,278 @@ function Roam.requireModules( parents: Instance | { Instance }, config: { DeepSearch: boolean?, + AllowYieldingRequires: boolean?, + StopOnFailedRequire: boolean?, RequirePredicate: ((obj: ModuleScript) -> boolean)?, IgnoreDescendantsPredicate: ((obj: Instance) -> boolean)?, }? -): { Service } +): { + Success: boolean, + ModuleContent: { [ModuleScript]: any }, + FailedModuleRequires: { ModuleScript }, +} if typeof(parents) == "Instance" then parents = { parents } end - config = config or {} - assert(typeof(config) == "table", `config must be a table; got {typeof(config)}`) - local deepSearch = config.DeepSearch or false - local predicate = config.RequirePredicate - local ignoreDescendantsPredicate = config.IgnoreDescendantsPredicate + config = config or {} :: any + assert(typeof(config) == "table", formatError("Invalid Config", `Config must be a table, got {typeof(config)}`)) + + local deepSearch: boolean = config.DeepSearch or false + local requirePredicate: ((obj: ModuleScript) -> boolean)? = config.RequirePredicate + local ignoreDescendantsPredicate: ((obj: Instance) -> boolean)? = config.IgnoreDescendantsPredicate + local allowYieldingRequires: boolean? = config.AllowYieldingRequires + local stopOnFailedRequire: boolean? = config.StopOnFailedRequire - local addedServices = {} - local function SearchInstance(obj: Instance | {Instance}) + local successfullyRequiredModuleContent = {} + local failedModuleRequires = {} + + local function searchInstance(obj: Instance | { Instance }) if typeof(obj) == "table" then - for _, v in ipairs(obj) do - SearchInstance(v) + for _, child in ipairs(obj) do + local success = searchInstance(child) + if not success and stopOnFailedRequire then + return false + end end - return + return true end - assert(typeof(obj) == "Instance", "Expected Instance or table of Instances. Got:"..tostring(obj)) - if obj:IsA("ModuleScript") and (not predicate or predicate(obj)) then - local service = EnsureUnyieldingRequire(obj) - if table.find(addedServices, service) then - warn("Already added service '" .. service[KEY_CONFIG].Name .. "' | " .. obj:GetFullName()) - return + if typeof(obj) ~= "Instance" then + error(formatError("Invalid Object", `Expected Instance or table of Instances, got {typeof(obj)}`)) + end + + if obj:IsA("ModuleScript") and (not requirePredicate or requirePredicate(obj)) then + if allowYieldingRequires then + local success, moduleContent = pcall(function() + return require(obj) :: any + end) + if success then + successfullyRequiredModuleContent[obj] = moduleContent + return true + else + table.insert(failedModuleRequires, obj) + return false + end + end + + local moduleContent = ensureUnyieldingRequire(obj) + if moduleContent == FAILED_REQUIRE_YIELD then + table.insert(failedModuleRequires, obj) + return false end - table.insert(addedServices, service) + successfullyRequiredModuleContent[obj] = moduleContent end if deepSearch and (not ignoreDescendantsPredicate or not ignoreDescendantsPredicate(obj)) then - SearchInstance(obj:GetChildren()) + return searchInstance(obj:GetChildren()) end + return true end + + if typeof(parents) ~= "table" then + error(formatError("Invalid Parents", "Parents must be an Instance or table of Instances")) + end + + searchInstance(parents) + + return { + Success = #failedModuleRequires == 0, + ModuleContent = successfullyRequiredModuleContent, + FailedModuleRequires = failedModuleRequires, + } +end + +-------------------------------------------------------------------------------- +--// DEBUG //-- +-------------------------------------------------------------------------------- +--[=[ + @within Roam + @private + @tag debug + Prints the dependency graph of all registered services to the output. + Shows the full recursive dependency tree for each service with tree symbols. +]=] +function Roam.printDependencyGraph(): () + local function printDependencyTree( + service: Service, + prefix: string, + isLast: boolean, + visited: { [Service]: boolean } + ) + local config = service[KEY_CONFIG] + local serviceName = config.Name + + -- Choose the appropriate tree symbol + local connector = isLast and "└─ " or "ā”œā”€ " + + -- Check for circular dependencies + if visited[service] then + print(`{prefix}{connector}{serviceName} (circular reference)`) + return + end + + visited[service] = true + print(`{prefix}{connector}{serviceName}`) + + if config.RequiredServices then + local deps = config.RequiredServices + -- Sort dependencies alphabetically for consistent display + local sortedDeps: { Service } = {} + for _, dep in ipairs(deps) do + table.insert(sortedDeps, dep :: Service) + end + table.sort(sortedDeps, function(a: Service, b: Service) + return (a[KEY_CONFIG] :: ServiceConfig).Name < (b[KEY_CONFIG] :: ServiceConfig).Name + end) + + -- Extend the prefix for nested dependencies + local childPrefix = prefix .. (isLast and " " or "│ ") + + for i, dep in ipairs(sortedDeps) do + local isLastDep = (i == #sortedDeps) + printDependencyTree(dep, childPrefix, isLastDep, visited) + end + end + + visited[service] = nil -- Allow same service in different branches + end + + print("Service Dependency Graph:") + print( + "═══════════════════════════════════════" + ) + + -- Get all services and sort them alphabetically + local sortedServiceNames = {} + for serviceName in pairs(services) do + table.insert(sortedServiceNames, serviceName) + end + table.sort(sortedServiceNames) + + -- Print each service and its dependencies + for i, serviceName in ipairs(sortedServiceNames) do + local service = services[serviceName] + local config = service[KEY_CONFIG] + + print(`\n{serviceName}`) + if config.RequiredServices then + local deps = config.RequiredServices + -- Sort dependencies alphabetically + local sortedDeps: { Service } = {} + for _, dep in ipairs(deps) do + table.insert(sortedDeps, dep :: Service) + end + table.sort(sortedDeps, function(a: Service, b: Service) + return (a[KEY_CONFIG] :: ServiceConfig).Name < (b[KEY_CONFIG] :: ServiceConfig).Name + end) + + for j, dep in ipairs(sortedDeps) do + local isLastDep = (j == #sortedDeps) + printDependencyTree(dep, "", isLastDep, {}) + end + -- else + -- print("└─ (no dependencies)") + end + end + + print( + "\n═══════════════════════════════════════" + ) +end + +--[=[ + @within Roam + @private + @tag debug + Returns the current lifecycle state of a service. - assert(typeof(parents) == "table", "Parents must be an Instance or table of Instances") - for _, parent in ipairs(parents) do - SearchInstance(parent:GetChildren()) + @param serviceName string | Service -- Either the name of the service or the service table itself + @return ServiceState? -- The current state of the service, or nil if not found + + ```lua + local state = Roam.getServiceState("MyService") + if state == "STARTED" then + print("MyService is fully running!") + end + ``` +]=] +function Roam.getServiceState(serviceName: string | Service): ServiceState? + local service: Service? + + if type(serviceName) == "string" then + service = services[serviceName] + elseif type(serviceName) == "table" then + service = serviceName + else + error( + formatError( + "Invalid Argument", + `getServiceState expects a string or Service table, got {type(serviceName)}` + ) + ) + end + + if not service then + return nil end - return addedServices + return serviceStates[service] end --[=[ + @within Roam + @private + @tag debug Fetches the name of a registered Service. ]=] -function Roam.getNameFromService(service: Service): string - return service[KEY_CONFIG].Name +function Roam.getServiceName(service: Service): string + local config = service[KEY_CONFIG] + if not config then + error(formatError("Invalid Service", "Service is not registered with Roam")) + end + return config.Name +end +Roam.getNameFromService = Roam.getServiceName -- backwards compat + +-------------------------------------------------------------------------------- +--// DEPRECATED //-- +-------------------------------------------------------------------------------- +--[=[ + @deprecated 0.1.5 + @private + Creates a Service/Table with Roam to be Initialized and Started when Roam starts. + Cannot be called after Roam has been started. + + This is an alternative method to setting up services over using `registerService`. + + ```lua + local Roam = require(ReplicatedStorage.Roam) + + local MyService = Roam.createService { Name = "MyService" } + + function MyService:DoSomething() + print("yeee haw!") + end + + -- Default StartMethodName is "RoamStart" (Can be overriden in service creation config) + function MyService:RoamStart() + print("MyService started!") + self:DoSomething() + end + + -- Default InitMethodName is "RoamInit" (Can be overriden in service creation config) + function MyService:RoamInit() + print("MyService initialized!") + end + + return MyService + ``` +]=] +function Roam.createService(serviceDef: ServiceConfig): Service + warn("[Roam] createService is deprecated, use registerService instead") + return Roam.registerService(serviceDef, serviceDef) end --[=[ @@ -626,11 +1046,22 @@ end Cannot be called until Roam has been started. ]=] function Roam.getService(serviceName: string): Service + warn("[Roam] getService is deprecated. Prefer to use direct requires") + if not started then - warn("!Roam has not been started yet! Services are not safe to access before Roam has been started.\n"..debug.traceback()) + warn(formatError("Early Access", "Services accessed before Roam started\n" .. debug.traceback())) end - assert(type(serviceName) == "string", `ServiceName must be a string; got {type(serviceName)}`) - return assert(services[serviceName], `Could not find service "{serviceName}"`) :: Service + + if type(serviceName) ~= "string" then + error(formatError("Invalid Service Name", `ServiceName must be a string, got {type(serviceName)}`)) + end + + local service = services[serviceName] + if not service then + error(formatError("Service Not Found", `Could not find service "{serviceName}"`)) + end + + return service end return Roam diff --git a/lib/roam/src/init.spec.luau b/lib/roam/src/init.spec.luau index 6f65826..034721a 100644 --- a/lib/roam/src/init.spec.luau +++ b/lib/roam/src/init.spec.luau @@ -1,5 +1,6 @@ -- Authors: Logan Hunt (Raildex) -- July 17, 2024 +-- THIS FILE IS OUTDATED AND NO LONGER MAINTAINED. TESTS MAY NOT WORK AS INTENDED. type table = {[any]: any} diff --git a/lib/roam/src/types.luau b/lib/roam/src/types.luau new file mode 100644 index 0000000..20bff66 --- /dev/null +++ b/lib/roam/src/types.luau @@ -0,0 +1,117 @@ +--!strict +-- Logan Hunt (Raildex) +-- January 20, 2026 +-- Type definitions for Roam + +type table = { [any]: any } + +--[=[ + @within Roam + @type Service table + A service is a table that can be registered with Roam. +]=] +export type Service = table + +--[=[ + @within Roam + @interface ServiceConfig + @field Name string -- Name of the Service. Must be unique. Used when accessing via .getService + @field RequiredServices {Service}? -- The Services that this Service depends on. Roam will ensure that these Services are initialized before this Service. + @field StartMethodName string? -- Overrides default StartMethodName of "RoamStart" + @field InitMethodName string? -- Overrides default InitMethodName of "RoamInit" + + ```lua + local myOtherService = require(ReplicatedStorage.MyOtherService) + + ------------------------------------------------- + + local MyService = {} + + function MyService:CustomStartMethod() + print("MyService started!") + end + + ------------------------------------------------- + + Roam.registerService(MyService, { + Name = "MyService", + RequiredServices = {myOtherService}, + StartMethodName = "CustomStartMethod", + }) + + return MyService + ``` + + :::caution Deferring RequiredServices + Do NOT add services to the RequiredServices after you have created or registered the service. This will cause undefined behavior. + ::: +]=] +export type ServiceConfig = { + Name: string, -- Name of the Service. Must be unique. Used when accessing via .getService + RequiredServices: { Service }?, -- The Services that this Service depends on. Roam will ensure that these Services are initialized before this Service. + StartMethodName: string?, -- Overrides default StartMethodName of "RoamStart" + InitMethodName: string?, -- Overrides default InitMethodName of "RoamInit" + InitTimeout: number?, -- Overrides default init timeout in seconds (default: 120) + [any]: any, +} + +--[=[ + @within Roam + @private + @tag debug + @type ServiceState "REGISTERED" | "AWAITING_DEPENDENCIES" | "PRE_INIT" | "ROAM_INIT" | "POST_INIT" | "INITIALIZED" | "PRE_START" | "ROAM_START" | "POST_START" | "STARTED" | "FAILED" + + Represents the current lifecycle state of a service: + - `REGISTERED` - Service has been registered but not yet initialized + - `AWAITING_DEPENDENCIES` - Service is waiting for its RequiredServices to finish initializing + - `PRE_INIT` - Calling PreInit with service + - `ROAM_INIT` - Calling Service:RoamInit method + - `POST_INIT` - Calling PostInit with service + - `INITIALIZED` - Service has completed all initialization steps + - `PRE_START` - Calling PreStart with service + - `ROAM_START` - Calling Service:RoamStart method + - `POST_START` - Calling PostStart with service + - `STARTED` - Service is actively running and has completed its startup routine + - `FAILED` - Service initialization failed +]=] +export type ServiceState = + "REGISTERED" + | "AWAITING_DEPENDENCIES" + | "PRE_INIT" + | "ROAM_INIT" + | "POST_INIT" + | "INITIALIZED" + | "PRE_START" + | "ROAM_START" + | "POST_START" + | "STARTED" + | "FAILED" + +--[=[ + @within Roam + @interface StartConfig + .GlobalPreInit (() -> ())? -- Called once before ANY service initializes + .PreInit ((service: Service) -> ())? -- Called before each service's RoamInit + .PostInit ((service: Service) -> ())? -- Called after each service's RoamInit + .GlobalPostInit (() -> ())? -- Called once after ALL services finish initializing + .GlobalPreStart (() -> ())? -- Called once before ANY service starts. (Async) + .PreStart ((service: Service) -> ())? -- Called before each service's RoamStart (Async) + .PostStart ((service: Service) -> ())? -- Called after each service's RoamStart (Async) + .GlobalPostStart (() -> ())? -- Called once after ALL services start (Async) + + Yielding in `Init` lifecycle hooks will prevent Roam from progressing to the next step. + + `Start` lifecycle hooks are fully asynchronous and do not block progression at any point. +]=] +export type StartConfig = { + GlobalPreInit: (() -> any)?, + PreInit: ((service: Service) -> any)?, + PostInit: ((service: Service) -> any)?, + GlobalPostInit: (() -> any)?, + GlobalPreStart: (() -> ())?, + PreStart: ((service: Service) -> ())?, + PostStart: ((service: Service) -> ())?, + GlobalPostStart: (() -> ())?, +} + +return {} diff --git a/lib/roam/wally.toml b/lib/roam/wally.toml index 6808aeb..8856e7e 100644 --- a/lib/roam/wally.toml +++ b/lib/roam/wally.toml @@ -2,7 +2,7 @@ name = "raild3x/roam" description = "Roam is a service initialization framework for Roblox." authors = ["Logan Hunt (Raildex)"] -version = "0.1.6" +version = "0.2.0" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..2343da5 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,12 @@ +syntax = "All" +column_width = 120 +line_endings = "Unix" +indent_type = "Tabs" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "NoSingleTable" +collapse_simple_statement = "Never" +space_after_function_names = "Never" + +[sort_requires] +enabled = false \ No newline at end of file