diff --git a/modules/client/commands/init.luau b/modules/client/commands/init.luau index 0e9693d..ac0270c 100644 --- a/modules/client/commands/init.luau +++ b/modules/client/commands/init.luau @@ -1,3 +1,5 @@ +--!strict + local Players = game:GetService("Players") local TextChatService = game:GetService("TextChatService") local UserInputService = game:GetService("UserInputService") @@ -66,9 +68,9 @@ function Commands:Validate(command, context, arguments) return command.validate(context, arguments) end -function Commands:Run(command, context, arguments) +function Commands:Run(command, context, arguments): (boolean, string?) if not command.callback then - return true + return true, nil end local success, result, message = pcall(command.callback, context, arguments) @@ -87,81 +89,143 @@ function Commands:ProcessServer(data, input, inputContext) return Network:InvokeServer("processCommand", data, input, inputContext) end -function Commands:ProcessClient(input, inputContext) - -- Check if the input matches a command string, if not then we can ignore it. - if not string.match(input, "^.+/") then - return ProcessResult.Fail, "Not a valid command string." +type CommandExecutionResult = { + message: string?, + name: string, + intent: string, +} & ({ + result: "fail" | "error", + data: nil, +} | { + result: "success", + data: unknown, +}) + +-- Please note we must process `g/no. sr c fl fc day d/10 rd/10` +-- but we must not process `g c x/...` +function Commands:ProcessClient(input: string, inputContext): { CommandExecutionResult } + local namespaceString, remainderOfString = string.match(input, "^(%a+)/(.+)$") + + if not namespaceString or not remainderOfString then + return { { + message = "Not a valid command string.", + result = "fail", + } :: CommandExecutionResult } end - local arguments = string.split(input, "/") - local prefix = table.remove(arguments, 1) - if not prefix then - return ProcessResult.Fail, "No prefix was found." - end + local results: { CommandExecutionResult } = {} + local namespace = Commands.namespaceLookup[string.lower(namespaceString)] - -- Remove last argument if empty. - if #arguments[#arguments] == 0 then - table.remove(arguments, #arguments) - end + -- HACK: treat x/ c/ l/ etc. as one fragment + local possibleCommandFragments: { string } = if namespace + then string.split(remainderOfString, " ") + else { namespaceString .. "/" .. remainderOfString } - -- Find namespace, and get the command. - local commandName - local namespace = Commands.namespaceLookup[string.lower(prefix)] if not namespace then - -- No namespace with prefix found, default namespace, command should be the 1st split string (command/arguments). - commandName = prefix namespace = Commands.namespaces.default - elseif arguments[1] and #arguments[1] > 0 then -- Check if there is a 2nd split string, otherwise no command was given. - -- Not default namespace, command should be the 2nd split string (prefix/command/arguments). - commandName = table.remove(arguments, 1) - else - return ProcessResult.Fail, "No command was given." end - local command = namespace.commandLookup[string.lower(commandName)] - if not command then - return ProcessResult.Fail, `Command "{commandName}" was not found.` - end + for _, maybeACommandString: string in possibleCommandFragments do + local arguments = string.split(maybeACommandString, "/") + local commandName = table.remove(arguments, 1) - local context = Commands:CreateContext(command, input, inputContext) - local valid, message = Commands:Validate(command, context, arguments) - if not valid then - return ProcessResult.Error, message - end + if not commandName then + continue + end - local success, commandMessage = Commands:Run(command, context, arguments) - if not success then - return ProcessResult.Error, commandMessage + -- intent is the version of the command the server can understand + -- e.g g/d/10 rd/10 -> g/d/10 g/rd/10 + local intent = if namespace == Commands.namespaces.default + then maybeACommandString + else namespaceString .. "/" .. maybeACommandString + + -- Remove last argument if it exists and if it is empty. + if arguments[#arguments] and #arguments[#arguments] == 0 then + table.remove(arguments, #arguments) + end + + local command = namespace.commandLookup[string.lower(commandName)] + if not command then + table.insert( + results, + { + name = commandName, + result = "fail", + message = `Command "{commandName}" was not found.`, + intent = intent, + } :: CommandExecutionResult + ) + continue + end + + local context = Commands:CreateContext(command, input, inputContext) + local valid, message = Commands:Validate(command, context, arguments) + if not valid then + table.insert( + results, + { name = commandName, result = "error", message = message, intent = intent } :: CommandExecutionResult + ) + continue + end + + local success, commandMessage = Commands:Run(command, context, arguments) + if not success then + table.insert( + results, + { name = commandName, result = "error", message = commandMessage, intent = intent } :: CommandExecutionResult + ) + continue + end + + table.insert( + results, + { + name = commandName, + result = "success" :: "success", + message = commandMessage, + data = context.Data, + intent = intent, + } :: CommandExecutionResult + ) end - return ProcessResult.Success, commandMessage, context.Data + return results end function Commands:Process(input, inputContext) - local clientResult, clientMessage, contextData = Commands:ProcessClient(input, inputContext) - if clientResult ~= ProcessResult.Success then - -- Only output fail messages if the inputContext wasn't the chat (because you can also send chat messages in chat, duh), - -- Error messages should always be displayed. - if - clientResult == ProcessResult.Error - or (clientResult == ProcessResult.Fail and inputContext ~= InputContext.Chat) - then - Output:append(Output.MessageType.Error, clientMessage or "Unexpected error occured while running command.") + local clientResults = Commands:ProcessClient(input, inputContext) + + for _, clientCommandProcessResult in clientResults do + local clientResult, clientMessage, contextData = + clientCommandProcessResult.result, clientCommandProcessResult.message, clientCommandProcessResult.data + + if clientResult ~= "success" then + -- Only output fail messages if the inputContext wasn't the chat (because you can also send chat messages in chat, duh), + -- Error messages should always be displayed. + if clientResult == "error" or (clientResult == "fail" and inputContext ~= InputContext.Chat) then + Output:append( + Output.MessageType.Error, + clientMessage or "Unexpected error occured while running command." + ) + end + elseif clientMessage then + Output:append(Output.MessageType.Success, clientMessage) end - return - elseif clientMessage then - Output:append(Output.MessageType.Success, clientMessage) - end + if not clientCommandProcessResult.intent then + continue + end - local serverResult, serverMessage = Commands:ProcessServer(contextData, input, inputContext) - if serverResult ~= ProcessResult.Success then - -- We don't check if the ProcessResult was a Fail or Error here, because it should always be an Error. - Output:append(Output.MessageType.Error, serverMessage or "Unexpected error occured while running command.") + local serverResult, serverMessage = + Commands:ProcessServer(contextData, clientCommandProcessResult.intent, inputContext) - return - elseif serverMessage then - Output:append(Output.MessageType.Success, serverMessage) + -- server still uses ProcessResult enum and Old System + if serverResult ~= ProcessResult.Success then + -- We don't check if the ProcessResult was a Fail or Error here, because it should always be an Error. + Output:append(Output.MessageType.Error, serverMessage or "Unexpected error occured while running command.") + elseif serverMessage then + Output:append(Output.MessageType.Success, serverMessage) + end end end diff --git a/modules/server/commands/get.luau b/modules/server/commands/get.luau index ee4b879..10d60f9 100644 --- a/modules/server/commands/get.luau +++ b/modules/server/commands/get.luau @@ -115,7 +115,7 @@ return function(Commands) local player = context.Player -- Check if the client sent a character location. - if type(context.Data.Location) == "CFrame" then + if typeof(context.Data.Location) == "CFrame" then player.CharacterAdded:Once(function(newCharacter) while not newCharacter.Parent do newCharacter.AncestryChanged:Wait() diff --git a/modules/server/network.luau b/modules/server/network.luau index 60c6db9..8d189d3 100644 --- a/modules/server/network.luau +++ b/modules/server/network.luau @@ -51,8 +51,8 @@ end) local Network = {} Network.attributeName, Network.attributeValue = string.sub(tostring(math.random()), 3), math.random() -local registeredEvents: { [string]: (...unknown) -> nil } = {} -function Network:RegisterEvent(name: string, callback: (...unknown) -> nil) +local registeredEvents: { [string]: (Player, ...unknown) -> nil } = {} +function Network:RegisterEvent(name: string, callback: (Player, ...unknown) -> nil) if registeredEvents[name] then Log.warn(`Network event "{name}" was overwritten.`) end @@ -60,8 +60,8 @@ function Network:RegisterEvent(name: string, callback: (...unknown) -> nil) registeredEvents[name] = callback end -local registeredFunctions: { [string]: (...unknown) -> ...unknown } = {} -function Network:RegisterFunction(name: string, callback: (...unknown) -> ...unknown) +local registeredFunctions: { [string]: (Player, ...unknown) -> ...unknown } = {} +function Network:RegisterFunction(name: string, callback: (Player, ...unknown) -> ...unknown) if registeredFunctions[name] then Log.warn(`Network function "{name}" was overwritten.`) end diff --git a/modules/shared/command_rework/client_registry.luau b/modules/shared/command_rework/client_registry.luau new file mode 100644 index 0000000..5c2dc4c --- /dev/null +++ b/modules/shared/command_rework/client_registry.luau @@ -0,0 +1,122 @@ +--!strict +local impl = require("./") +local Result = require("./result") +local Network = require("@client/network") + +local RunService = game:GetService("RunService") +assert(RunService:IsClient(), "client registry required on server") + +local Registry = { + prefixLookup = {}, +} + +type ClientNamespace = impl.Namespace & { read environment: "client" } + +function Registry.add(namespace: impl.Namespace) + if namespace.environment ~= "client" then + error("Environment mismatch when adding namespace (Registry.add)", 0) + end + + for _, prefix in namespace.prefixes do + assert(Registry.prefixLookup[prefix] == nil, "Overwriting prefix_lookup may lead to bugs") + Registry.prefixLookup[prefix] = namespace + end +end + +function Registry.help(namespace: ClientNamespace): impl.HelpResults? + return namespace.help_callback(function(): impl.HelpResults? + return Network:InvokeServer("forwardCommandHelp", namespace.prefixes[1]) + end) +end + +-- example output: +-- {get,g}: +-- <\t>- {get,g}/{help}: Displays help results. +-- <\t>- {get,g}/{dummy,d,r6dummy}: Spawns an r6 dummy. (takes number?) +-- ... +function Registry.getFormattedHelpText(): string + -- deduplicate namespaces + local namespaces: { [ClientNamespace]: true } = {} + + for _, namespace in Registry.prefixLookup do + namespaces[namespace] = true + end + + local text = "" + + for namespace in namespaces do + local prefixesConcatPretty = "{" .. table.concat(namespace.prefixes, ",") .. "}" + + local results = Registry.help(namespace) + if results then + text ..= `{prefixesConcatPretty}:\n` + for key, result in results do + -- FIXME: Should we be using HelpResults->aliases or Namespace->aliases? + -- FIXME: It forces us to have to deduplicate aliases (key is an alias) + local aliases = { + [key] = true, + } + + for _, alias in result.aliases do + aliases[alias] = true + end + + local deduplicatedAliases: { string } = {} + + for alias in aliases do + table.insert(deduplicatedAliases, alias) + end + + text ..= (`\t- {"{" .. table.concat(deduplicatedAliases, ",") .. "}"}: {result.description}` .. if #result.arguments + > 0 + then ` (takes {table.concat(result.arguments, ", ")})` + else "") .. "\n" + end + else + text ..= `{prefixesConcatPretty}: no results found\n` + end + end + + return "text" +end + +function Registry.dispatch(dispatchContext: impl.DispatchContext): impl.CommandResult + local prefix, rest = string.match(dispatchContext.input, "^(%a+)/(.+)$") + if not prefix then + return Result.err({ + type = "generic", + message = "No prefix specified.", + }) :: impl.CommandResult + end + + local lookup = Registry.prefixLookup[prefix] + if not lookup then + -- TODO: Should we call forward implicitly? + return Result.err({ + type = "generic", + message = "Failed finding namespace with prefix.", + }) :: impl.CommandResult + end + + local input = rest or "" + + local commandContext: impl.CommandContext = { + player = dispatchContext.player, + context = dispatchContext.context, + input = input, + data = dispatchContext.data, + } + + return lookup.run_callback(commandContext, function(data: T): impl.CommandResult + return assert( + Network:InvokeServer("forwardCommand", { + context = dispatchContext.context, + input = input, + data = data, + }), + "server returned nil when forwarding command" + ) + end) +end + +return table.freeze(Registry) diff --git a/modules/shared/command_rework/init.luau b/modules/shared/command_rework/init.luau new file mode 100644 index 0000000..9311e47 --- /dev/null +++ b/modules/shared/command_rework/init.luau @@ -0,0 +1,66 @@ +--!strict + +local Result = require("./result") + +--[[ +Parse: +Error occured whilst parsing command: {message} + +Invoke: +Runtime error occured in command: {message} + +Generic: +Generic error occured in command: {message} + +Validation: +Validation error occured in command: {message} +]] +export type Error = { type: "parse" | "invoke" | "generic" | "validation", message: string } + +export type HelpResults = { [string]: { read description: string, read aliases: { string }, read arguments: { string } } } + +export type Namespace = { + read prefixes: { string }, + read run_callback: ( + ctx: CommandContext, + forward: ((data: T) -> CommandResult)? --[[ !! ONLY USABLE ON CLIENT !! ]] + ) -> CommandResult, --[[ string is the successful output text ]] + + --[[ + sb/permBan (args: Player) (aliases: sb/pb): Ban a player forever + + if a help_callback is NOT defined: + player: g/help + terminal: + g: no help callback implemented + sb: no help callback implemented + c: no help callback implemented + ]] + -- forward is ONLY usable on the client + read help_callback: (forward: (() -> HelpResults?)?) -> HelpResults?, + read environment: "server" | "client", +} + +export type RunContext = "chat" | "command_bar" + +export type CommandContext = { + player: Player, + context: RunContext, + -- Excludes the beginning prefix (player: "g/sr" -> command receives "sr", and everything after g/) + input: string, + + data: Data, +} + +export type DispatchContext = { + player: Player, + context: RunContext, + -- Raw input + input: string, + + data: Data, +} + +export type CommandResult = Result.Result + +return {} diff --git a/modules/shared/command_rework/namespaces/default/client.luau b/modules/shared/command_rework/namespaces/default/client.luau new file mode 100644 index 0000000..7d12eb5 --- /dev/null +++ b/modules/shared/command_rework/namespaces/default/client.luau @@ -0,0 +1,36 @@ +--!strict + +local impl = require("../../") +local Result = require("../../result") + +type Result = Result.Result + +local function shadow( + prefixes: { string }, + help: { read aliases: { string }, read arguments: { string }, read description: string } +): impl.Namespace + return table.freeze({ + prefixes = prefixes, + run_callback = function( + context: impl.CommandContext, + forward: (T) -> impl.CommandResult + ): impl.CommandResult + return forward(nil) + end, + + help_callback = function(): impl.HelpResults? + return table.freeze({ [prefixes[1]] = help }) + end, + } :: impl.Namespace) +end + +return table.freeze({ + shadow( + { "c", "run" }, + table.freeze({ + description = "Executes code on the server.", + aliases = { "run/" }, + arguments = { "code (string)" }, + }) + ), +}) diff --git a/modules/shared/command_rework/namespaces/default/server.luau b/modules/shared/command_rework/namespaces/default/server.luau new file mode 100644 index 0000000..a95d9af --- /dev/null +++ b/modules/shared/command_rework/namespaces/default/server.luau @@ -0,0 +1,30 @@ +--!strict + +local impl = require("../../") +local Result = require("../../result") + +type Result = Result.Result + +return { + table.freeze({ + prefixes = { "c", "run" }, + + run_callback = function(context: impl.CommandContext, forward: nil): impl.CommandResult + -- We get everything after c/; so this isnt a big problem to implement + + return Result.ok("Not implemented yet") + end, + + help_callback = function(): impl.HelpResults? + return { + c = table.freeze({ + aliases = { "run/" }, + arguments = { "code (string)" }, + description = "Executes code on the server.", + }), + } + end, + + environment = "server" :: "server", + }) :: impl.Namespace, +} diff --git a/modules/shared/command_rework/namespaces/get/client.luau b/modules/shared/command_rework/namespaces/get/client.luau new file mode 100644 index 0000000..d883ade --- /dev/null +++ b/modules/shared/command_rework/namespaces/get/client.luau @@ -0,0 +1,25 @@ +--!strict + +local impl = require("../../") +local Result = require("../../result") + +type Result = Result.Result + +return { + table.freeze({ + prefixes = { "get", "g" }, + + run_callback = function( + context: impl.CommandContext, + forward: (T) -> impl.CommandResult + ): impl.CommandResult + return forward(nil) + end, + + help_callback = function(forward: () -> impl.HelpResults?): impl.HelpResults? + return forward() + end, + + environment = "client" :: "client", + }) :: impl.Namespace, +} diff --git a/modules/shared/command_rework/namespaces/get/server.luau b/modules/shared/command_rework/namespaces/get/server.luau new file mode 100644 index 0000000..6ce2bc1 --- /dev/null +++ b/modules/shared/command_rework/namespaces/get/server.luau @@ -0,0 +1,275 @@ +--!strict + +-- local impl = require("../../") +local Result = require("../../result") + +type Result = Result.Result + +local World = require("@server/world") +local ScriptManager = require("@server/scriptManager") +local Output = require("@server/output") +local Functions = require("@shared/functions") +local Assets = require("@shared/assets") + +local Players = game:GetService("Players") +local TeleportService = game:GetService("TeleportService") + +local healthScript = Assets:get("Health") +local serverAnimateScripts = { + [Enum.HumanoidRigType.R6] = Assets:get("R6Animate"), + [Enum.HumanoidRigType.R15] = Assets:get("R15Animate"), +} + +local function createRig(description: HumanoidDescription, rigType: Enum.HumanoidRigType) + local rig = Players:CreateHumanoidModelFromDescription(description, rigType) + healthScript:Clone().Parent = rig + + return rig +end + +local function setCharacterRigType(player: Player, rigType: Enum.HumanoidRigType) + local character = player.Character + if not character then + return "You don't have a character." + end + + local humanoid = character:FindFirstChildOfClass("Humanoid") + if not humanoid then + return "You don't have a humanoid." + end + + local rig = createRig(humanoid:GetAppliedDescription(), rigType) + rig.Name = player.Name + player.Character = rig + + rig.Parent = workspace + rig:PivotTo(character:GetPivot()) + + character:Destroy() + + return `Got {rigType.Name} character.` +end + +local dummyHumanoidDescription = Instance.new("HumanoidDescription") +dummyHumanoidDescription.HeadColor = Color3.fromRGB(253, 234, 141) +dummyHumanoidDescription.LeftArmColor = Color3.fromRGB(253, 234, 141) +dummyHumanoidDescription.LeftLegColor = Color3.fromRGB(13, 105, 172) +dummyHumanoidDescription.RightArmColor = Color3.fromRGB(253, 234, 141) +dummyHumanoidDescription.RightLegColor = Color3.fromRGB(13, 105, 172) +dummyHumanoidDescription.TorsoColor = Color3.fromRGB(40, 127, 71) + +local function createDummy(player: Player, rigType: Enum.HumanoidRigType) + local dummy = createRig(dummyHumanoidDescription, rigType) + dummy.Name = "Dummy" + (dummy:WaitForChild("Humanoid") :: Humanoid).DisplayName = `{Functions.formatPlayerName(player)}'s dummy` + + local animate = serverAnimateScripts[rigType]:Clone() + animate.Name = "Animate" + + dummy:WaitForChild("Animate"):Destroy() + animate.Parent = dummy + + dummy.Parent = workspace + assert(dummy.PrimaryPart, "dummy primary part is nil???"):SetNetworkOwner(nil) + + local character = player.Character + if character then + dummy:PivotTo(character:GetPivot()) + end + + return `Got {rigType.Name} dummy.` +end + +return require("../../serverDefineSimple")(function(define, defineValidator) + --:base + define("base", function() + World:AddBase() + + return "Got base." + end) + + --:noBase + defineValidator("noBase", function() + return World.Base ~= nil, "No base currently exists." + end) + + define("noBase", function() + World:RemoveBase() + + return "Got no base." + end) + + --:respawn + define("respawn", function(context) + context.Player:LoadCharacter() + + return "Got respawn." + end) + + --:refresh + define("refresh", function(context) + local player = context.Player + + -- Check if the client sent a character location. + if type(context.Data) == "table" and typeof(context.Data.Location) == "CFrame" then + player.CharacterAdded:Once(function(newCharacter) + while not newCharacter.Parent do + newCharacter.AncestryChanged:Wait() + end + + task.wait() + + -- Get the latest location from the old character and teleport the new on there. + newCharacter:PivotTo(context.Data.Location) + end) + end + + player:LoadCharacter() + + return "Got refresh." + end) + + --:rejoin + defineValidator("rejoin", function() + return Functions.getServerType() ~= "VIPServer", "You can't use rejoin command in VIP servers." + end) + + define("rejoin", function(context) + local teleportOptions = Instance.new("TeleportOptions") + teleportOptions.ServerInstanceId = game.JobId + + Output:appendTo(context.Player, Output.MessageType.Success, "Rejoining...") + TeleportService:TeleportAsync(game.PlaceId, { context.Player }, teleportOptions) + + return nil + end) + + --:nil + defineValidator("nil", function(context) + return context.Player.Character ~= nil, "You already don't have a character." + end) + + define("nil", function(context) + context.Player.Character = nil + + return "Got nil." + end) + + --:clear + define("clear", function() + World:Clear() + + return "Got clear." + end) + + --:fixLighting + do + local Terrain: Terrain = workspace:WaitForChild("Terrain") :: Terrain + local Lighting = game:GetService("Lighting") + + define("fixLighting", function() + Lighting:ClearAllChildren() + + while true do + local Clouds = Terrain:FindFirstChildWhichIsA("Clouds") + if not Clouds then + break + end + + Clouds:Destroy() + end + + Lighting.Ambient = Color3.fromRGB(127, 127, 127) + Lighting.Brightness = 1 + Lighting.ColorShift_Bottom = Color3.fromRGB(0, 0, 0) + Lighting.ColorShift_Top = Color3.fromRGB(0, 0, 0) + Lighting.EnvironmentDiffuseScale = 0 + Lighting.EnvironmentSpecularScale = 0 + Lighting.GlobalShadows = true + Lighting.OutdoorAmbient = Color3.fromRGB(127, 127, 127) + Lighting.ShadowSoftness = 0.5 + Lighting.ExposureCompensation = 0 + Lighting.FogColor = Color3.fromRGB(191, 191, 191) + Lighting.FogEnd = 100000 + Lighting.FogStart = 0 + + -- day time + Lighting.ClockTime = 14.5 + + return "Fixed lighting." + end) + end + + --:noGui + define("noGui", function(context) + local playerGui = context.Player:FindFirstChildOfClass("PlayerGui") + if not playerGui then + return nil + end + + playerGui:ClearAllChildren() + + return nil + end) + + --:rig6 + define("rig6", function(context) + return setCharacterRigType(context.Player, Enum.HumanoidRigType.R6) + end) + + --:rig15 + define("rig15", function(context) + return setCharacterRigType(context.Player, Enum.HumanoidRigType.R15) + end) + + --:rig6dummy + define("rig6dummy", function(context) + return createDummy(context.Player, Enum.HumanoidRigType.R6) + end) + + --:rig15dummy + define("rig15dummy", function(context) + return createDummy(context.Player, Enum.HumanoidRigType.R15) + end) + + --:noScripts + define( + "noScripts", + function(context, arguments): string? + if not arguments[1] or string.lower(arguments[1]) ~= "all" then + ScriptManager:StopScripts(context.Player) + return "Got no scripts." + end + + ScriptManager:StopScripts() + Output:appendToAll( + Output.MessageType.Success, + `{Functions.formatPlayerName(context.Player)} got no scripts.` + ) + + return nil + end :: any + ) + + --:noServerScripts + define("noServerScripts", function(context, arguments): string? + if not arguments[1] or string.lower(arguments[1]) ~= "all" then + ScriptManager:StopServerScripts(context.Player) + return "Got no server scripts." + end + + ScriptManager:StopServerScripts() + Output:appendToAll( + Output.MessageType.Success, + `{Functions.formatPlayerName(context.Player)} got no server scripts.` + ) + + return nil + end) + + --:noLocalScripts + define("noLocalScripts", function(context) + ScriptManager:StopLocalScripts(context.Player) + return "Got no local scripts." + end) +end, require("@shared/commands/namespaces/get.toml")) diff --git a/modules/shared/command_rework/result.luau b/modules/shared/command_rework/result.luau new file mode 100644 index 0000000..f0c3d20 --- /dev/null +++ b/modules/shared/command_rework/result.luau @@ -0,0 +1,56 @@ +export type Result = { + inner: T, + ok: true, +} | { + inner: E, + ok: false, +} +-- export type Result = { +-- value: nil, +-- error: E, +-- } & { +-- value: T, +-- error: nil, +-- } + +local function unwrap(result: Result): T + if result.ok then + return result.inner + else + return error(result.inner, 2) + end +end + +local function unwrap_err(result: Result): E + if result.ok then + return error("Called unwrap_err on a Ok Result", 2) + end + + return result.inner +end + +local function is_ok(result: Result) + return result.ok +end + +local function ok(value: T): Result + return { + inner = value, + ok = true, + } +end + +local function err(error: E): Result + return { + inner = error, + ok = false, + } +end + +return table.freeze({ + unwrap = unwrap, + unwrap_err = unwrap_err, + is_ok = is_ok, + ok = ok, + err = err, +}) diff --git a/modules/shared/command_rework/serverDefineSimple.luau b/modules/shared/command_rework/serverDefineSimple.luau new file mode 100644 index 0000000..884192d --- /dev/null +++ b/modules/shared/command_rework/serverDefineSimple.luau @@ -0,0 +1,157 @@ +--!strict + +local impl = require("./") +local Result = require("result") +local Output = require("@server/output") + +type OldCtx = { + Player: Player, + Data: unknown, +} + +local function defineSimple( + initCallback: ( + define: (name: string, fn: (ctx: OldCtx, { string }) -> string?) -> (), + defineValidator: (name: string, fn: (ctx: OldCtx, { string }) -> (boolean, string)) -> () + ) -> (), + + tomlInit: { + [string]: { + aliases: { string }, + description: string, + arguments: { string }?, + }, + + ["_prefixes"]: { string }, + } +): impl.Namespace + local lookupTable: { + [string]: { + fn: (OldCtx, { string }) -> string?, + validate: (OldCtx, { string }) -> (boolean, string), + generatedHelpText: string?, + }, + } = + {} + + local function define(name: string, fn: (ctx: OldCtx, { string }) -> string?) + local t: any = lookupTable[name] + if t then + t.run = fn + else + lookupTable[name] = { + run = fn, + } :: any + end + end + + local function defineValidator(name: string, fn: (ctx: OldCtx, { string }) -> (boolean, string)) + local t = lookupTable[name] + if t then + t.validate = fn + else + lookupTable[name] = { + validate = fn, + } :: any + end + end + + local function handleFragment(context: impl.CommandContext, fragment: string): impl.CommandResult + local arguments = string.split(fragment, "/") + local name = table.remove(arguments, 1) + local commandEntry = lookupTable[name or ""] + + if not commandEntry then + return Result.err({ + message = "Failed finding command.", + type = "parse", + }) :: impl.CommandResult + end + + local success, error = true, nil + local ctx: OldCtx = { + Player = context.player, + Data = context.data, + } + + if commandEntry.validate then + success, error = commandEntry.validate(ctx, arguments) + end + + if success then + return Result.ok(commandEntry.fn(ctx, arguments)) :: impl.CommandResult + else + return Result.err({ + message = error, + type = "validation", + } :: impl.Error) :: impl.CommandResult + end + end + + initCallback(define, defineValidator) + + local prefixes: { string } = tomlInit._prefixes + local helpLookupTable: impl.HelpResults = {} + + do + for key, value in tomlInit do + if key == "_prefixes" then + continue + end + + -- only register key (main alias) to avoid clogging help entries + helpLookupTable[key] = table.freeze({ + description = value.description, + aliases = value.aliases, + arguments = value.arguments or { "unknown" }, + }) + + for _, alias in value.aliases do + -- FIXME: hacky alias init. we shouldn't do this in production! + lookupTable[alias] = lookupTable[key] + end + end + end + + table.freeze(helpLookupTable) + + return table.freeze({ + prefixes = prefixes, + + run_callback = function(context: impl.CommandContext, forward: nil): impl.CommandResult + local fragments: { string } = string.split(context.input, " ") + local results = {} + + for _, fragment in fragments do + table.insert(results, handleFragment(context, fragment)) + end + + for _, result in results do + -- manually output because we have alot of commands + if Result.is_ok(result) then + local message = Result.unwrap(result) + if message ~= nil then + Output:appendTo(context.player, Output.MessageType.Success, message) + end + else + Output:appendTo( + context.player, + Output.MessageType.Error, + (Result.unwrap_err(result) :: impl.Error).message + ) + end + end + + -- nil return -> do not create an output line for us + return Result.ok(nil) :: impl.CommandResult + end, + + help_callback = function(): impl.HelpResults? + return helpLookupTable + end, + + environment = "server" :: "server", + }) :: impl.Namespace +end + +return defineSimple diff --git a/modules/shared/command_rework/server_registry.luau b/modules/shared/command_rework/server_registry.luau new file mode 100644 index 0000000..239e962 --- /dev/null +++ b/modules/shared/command_rework/server_registry.luau @@ -0,0 +1,93 @@ +--!strict +local impl = require("./") +local Result = require("./result") +local Network = require("@server/network") + +local RunService = game:GetService("RunService") +assert(RunService:IsServer(), "server registry required on client") + +local Registry = { + prefixLookup = {}, +} + +function Registry.add(namespace: impl.Namespace) + if namespace.environment ~= "server" then + error("Environment mismatch when adding namespace (Registry.add)", 0) + end + + for _, prefix in namespace.prefixes do + assert(Registry.prefixLookup[prefix] == nil, "Overwriting prefix_lookup may lead to bugs") + Registry.prefixLookup[prefix] = namespace + end +end + +function Registry.dispatch(dispatchContext: impl.DispatchContext): impl.CommandResult + local prefix, rest = string.match(dispatchContext.input, "^(%a+)/(.+)$") + if not prefix then + return Result.err({ + type = "generic", + message = "No prefix specified.", + }) :: impl.CommandResult + end + + local lookup = Registry.prefixLookup[prefix] + if not lookup then + return Result.err({ + type = "generic", + message = "Failed finding namespace with prefix.", + }) :: impl.CommandResult + end + + return lookup.run_callback({ + player = dispatchContext.player, + context = dispatchContext.context, + input = rest or "", + data = dispatchContext.data, + }, nil) +end + +Network:RegisterFunction("forwardCommand", function(player: Player, ctx: any): impl.CommandResult? + if typeof(ctx) ~= "table" then + return nil + end + + if typeof(ctx.context) ~= "string" or not (ctx.context == "chat" or ctx.content == "command_bar") then + return nil + end + + if typeof(ctx.input) ~= "string" then + return nil + end + + local sanitizedDispatchContext: impl.DispatchContext = { + player = player, + context = ctx.context :: any, + input = ctx.input, + data = ctx.data, + } + + return Registry.dispatch(sanitizedDispatchContext) +end) + +type ForwardHelpResult = Result.Result + +Network:RegisterFunction("forwardCommandHelp", function(player: Player, prefix: unknown): ForwardHelpResult + if type(prefix) ~= "string" then + return Result.err({ + type = "validation", + message = "Prefix is not a string.", + }) :: ForwardHelpResult + end + + local lookup = Registry.prefixLookup[prefix] + if not lookup then + return Result.err({ + type = "validation", + message = "Failed finding namespace with prefix.", + }) :: ForwardHelpResult + end + + return Result.ok(lookup.help_callback(nil)) :: ForwardHelpResult +end) + +return table.freeze(Registry) diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..db7d6fe --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/nodemon.json", + "ext": "luau,lua,yaml,json", + "exec": "lune run build dev yes", + "ignore": ["build", "out"] +}