Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 122 additions & 58 deletions modules/client/commands/init.luau
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
--!strict

local Players = game:GetService("Players")
local TextChatService = game:GetService("TextChatService")
local UserInputService = game:GetService("UserInputService")
Expand Down Expand Up @@ -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)
Expand All @@ -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 <BREAK> 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

Expand Down
2 changes: 1 addition & 1 deletion modules/server/commands/get.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions modules/server/network.luau
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,17 @@ 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

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
Expand Down
122 changes: 122 additions & 0 deletions modules/shared/command_rework/client_registry.luau
Original file line number Diff line number Diff line change
@@ -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<T>(dispatchContext: impl.DispatchContext<T>): 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<T> = {
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)
Loading