Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3e7146c
No longer keeping hard references to Assembly's instead just purely t…
Odjit Jul 18, 2025
6e3fe65
As we now support command overloading and choosing which command to e…
Odjit Jul 19, 2025
0272bb2
Adding the ability to handle the remainder of a command input with a …
Odjit Jul 19, 2025
987255e
Command history won't store anymore duplicate sets of command/args
Odjit Sep 14, 2025
650805f
Fixing a few compile warnings
Odjit Sep 14, 2025
7a0ee8d
Command History persists across server restarts. Loaded at first com…
Odjit Sep 14, 2025
487e87f
Moved Command History code into its own static class
Odjit Sep 14, 2025
c4bbc1e
Changed to storing history based on the context's name instead of pla…
Odjit Sep 15, 2025
0939845
Realized the context wasn't being updated on the history commands whe…
Odjit Sep 15, 2025
0feff69
Start of version checking installed plugins
Odjit Sep 23, 2025
f0e425b
When admins auth do a version check and let them know what needs upda…
Odjit Oct 20, 2025
10691f0
Fixing line endings
Odjit Oct 20, 2025
93ca6a5
Adds .version command to VCF for allowing anyone to see what plugins …
Odjit Oct 21, 2025
144f8ea
* Version command admin only now
Odjit Nov 6, 2025
83970b0
Should only be checking the name not dependencies for the package for…
Odjit Jan 13, 2026
623419f
Deciding to remove Thunderstore Version checking of Plugins and only …
Odjit Mar 25, 2026
628c858
Fixing _remainder to work properly with command groups and commands t…
Odjit Mar 25, 2026
c603235
SImplifying VersionChecker removing unnecessary code that we don't ha…
Odjit Mar 25, 2026
26e008b
Fixing line endings
Odjit Mar 25, 2026
ccd6b0c
Remove Thunderstore version checking documentation and fix .version d…
Odjit Mar 25, 2026
414c020
Fix command history replay using stale context instead of current con…
Odjit Mar 25, 2026
001a98a
Remove UnityMainThreadDispatcher as it is no longer needed
Odjit Mar 25, 2026
36d0bcf
Input parsing now uses CommandRegistry.ParseInput and fixed _remainde…
Odjit Apr 11, 2026
c73976c
Moving saving command history to be async to not halt the chat proces…
Odjit Apr 12, 2026
abffa43
Changing _remainder to use a Remainder parameter which if used has to…
Odjit Apr 12, 2026
289b266
Chat messages had a potential to be processed on the client out of or…
Odjit Apr 12, 2026
f4f427c
Updated README.md with remainder parameter information
Odjit Apr 13, 2026
fca8433
💚 Unit test fix for non-determism in parallel and across platform
decaprime Apr 13, 2026
52fedb7
Command history wasn't marked as loaded if the history file was missi…
Odjit Apr 13, 2026
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
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ public class AdminCommands
```
- Executes with: `.admin ban PlayerName`

## Remainder Parameters
For commands that take free-form text (a message, a reason, a timezone ID), mark the final `string` parameter with `[Remainder]` to capture everything the user typed after the preceding arguments, with spacing and quotes preserved.

```csharp
[Command("say")]
public void Say(ICommandContext ctx, string channel, [Remainder] string message)
=> ctx.Reply($"[{channel}] {message}");
```

- `.say global hello everyone` → `channel = "global"`, `message = "hello everyone"`
- `.say global "hello, friends!"` → `message = "\"hello, friends!\""` (quotes kept as typed)

**Rules:**
- Must be the **last** parameter. Violations are rejected at registration time.
- Must be of type `string`.
- Captures the rest of the raw input verbatim, including quotes, whitespace, and special characters.
- Can be optional with a default value: `[Remainder] string reason = null`.
- Parameters before the remainder are parsed normally: type conversion, defaults, and custom converters all still apply.
- Composes with overloading: the framework picks a remainder variant when enough preceding arguments are present.

Auto-generated `.help` output renders a remainder parameter as `<name...>`, distinct from `(name)` for required and `[name=default]` for optional, so users get a visual cue for free, unless you supply a manual `usage:` string on `[Command]`.

## Command Overloading
You can now create multiple commands with the same name but different parameter types:

Expand Down Expand Up @@ -192,6 +214,26 @@ Command not found: .tleport
Did you mean: .teleport, .teleport-home, .tp
```

### Free-form Text Arguments
Some commands accept free-form text as their final argument, so you can type multi-word values (a message, a reason, a path) **without wrapping them in quotes**. For example:
```
.announce Server restart in 5 minutes
```
`Server restart in 5 minutes` is captured as a single argument. If you do type quotes, they're preserved as part of the text.

You can spot these commands in `.help <command>`: each parameter is shown in a distinct shape so you can tell at a glance which one eats the rest of the line.
- `(name)`: a **required** argument (single word or quoted).
- `[name=default]`: an **optional** argument with a default value.
- `<name...>`: a **remainder** argument that captures everything you type after the preceding arguments as this one value, spaces and all.

So a help line like:
```
.announce (channel) <message...>
```
means `channel` is a single required word and `message...` eats everything else you type.

Note: if a command's author supplied a custom usage string, `.help` shows that text verbatim instead of the shapes above, so the `<name...>` marker is only guaranteed for commands using the default help rendering.

### Plugin-Specific Commands
Players can execute commands from specific plugins to avoid conflicts:
```
Expand All @@ -211,9 +253,14 @@ When your input could match multiple command variations, you'll see a list of op

## Universal Configuration Management
Built-in commands for managing BepInEx configurations across all plugins:
- `.config dump <plugin>` - View plugin configuration
- `.config set <plugin> <section> <key> <value>` - Modify settings
- `.config dump <plugin>` - View plugin configuration (admin only)
- `.config set <plugin> <section> <key> <value>` - Modify settings (admin only)

## Plugin Version Management
VCF includes tools to help you track and manage plugin versions on your server.

### Commands:
- `.version` - Lists all installed plugins and their current versions (admin only)


## Help
Expand Down
2 changes: 1 addition & 1 deletion VCF.Core/Basics/BepInExConfigCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public void DumpConfig(ICommandContext ctx, string pluginGuid, string section, s

ctx.SysReply($"Set {def.Key} = {convertedValue}");
}
catch (Exception e)
catch (Exception)
{
throw ctx.Error($"Can not convert {value} to {entry.SettingType}");
}
Expand Down
19 changes: 11 additions & 8 deletions VCF.Core/Basics/HelpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string
// If search is specified first look for matching assembly, then matching command
if (!string.IsNullOrEmpty(search))
{
var foundAssembly = CommandRegistry.AssemblyCommandMap.FirstOrDefault(x => x.Key.GetName().Name.StartsWith(search, StringComparison.OrdinalIgnoreCase));
var foundAssembly = CommandRegistry.AssemblyCommandMap.FirstOrDefault(x => x.Key.StartsWith(search, StringComparison.OrdinalIgnoreCase));
if (foundAssembly.Value != null)
{
StringBuilder sb = new();
Expand Down Expand Up @@ -67,7 +67,7 @@ public static void HelpCommand(ICommandContext ctx, string search = null, string
sb.AppendLine($"Use {B(".help <plugin>").Color(Color.Gold)} for commands in that plugin");
// List all plugins they have a command they can execute for
foreach (var assemblyName in CommandRegistry.AssemblyCommandMap.Where(x => x.Value.Keys.Any(c => CommandRegistry.CanCommandExecute(ctx, c)))
.Select(x => x.Key.GetName().Name)
.Select(x => x.Key)
.OrderBy(x => x))
{
sb.AppendLine($"{assemblyName.Color(Color.Lilac)}");
Expand Down Expand Up @@ -125,9 +125,9 @@ public static void HelpAllCommand(ICommandContext ctx, string filter = null)
ctx.SysPaginatedReply(sb);
}

static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair<Assembly, Dictionary<CommandMetadata, List<string>>> assembly, StringBuilder sb, string filter = null)
static void PrintAssemblyHelp(ICommandContext ctx, KeyValuePair<string, Dictionary<CommandMetadata, List<string>>> assembly, StringBuilder sb, string filter = null)
{
var name = assembly.Key.GetName().Name;
var name = assembly.Key;
name = _trailingLongDashRegex.Replace(name, "");

sb.AppendLine($"Commands from {name.Medium().Color(Color.Primary)}:".Underline());
Expand Down Expand Up @@ -160,11 +160,14 @@ internal static string GetOrGenerateUsage(CommandMetadata command)
var usageText = command.Attribute.Usage;
if (string.IsNullOrWhiteSpace(usageText))
{
var usages = command.Parameters.Select(
p => !p.HasDefaultValue
var usages = command.Parameters.Select(p =>
{
if (CommandRegistry.IsRemainderParameter(p))
return $"<{p.Name}...>".Color(Color.LightGrey);
return !p.HasDefaultValue
? $"({p.Name})".Color(Color.LightGrey) // todo could compress this for the cases with no defaulting
: $"[{p.Name}={p.DefaultValue}]".Color(Color.Green)
);
: $"[{p.Name}={p.DefaultValue}]".Color(Color.Green);
});

usageText = string.Join(" ", usages);
}
Expand Down
16 changes: 16 additions & 0 deletions VCF.Core/Basics/VersionCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Unity.Entities;
using VampireCommandFramework.Common;

namespace VampireCommandFramework.Basics;

internal static class VersionCommands
{
[Command("version", description: "Lists all installed plugins and their versions", adminOnly: true)]
public static void VersionCommand(ICommandContext ctx)
{
// Get the user entity if this is a ChatCommandContext
var userEntity = ctx is ChatCommandContext chatCtx ? chatCtx.Event.SenderUserEntity : default;

VersionChecker.ListAllPluginVersions(userEntity);
}
}
75 changes: 36 additions & 39 deletions VCF.Core/Breadstone/ChatHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,55 +17,52 @@ public static class ChatMessageSystem_Patch
{
public static void Prefix(ChatMessageSystem __instance)
{
if (__instance.__query_661171423_0 != null)
NativeArray<Entity> entities = __instance.__query_661171423_0.ToEntityArray(Allocator.Temp);
foreach (var entity in entities)
{
NativeArray<Entity> entities = __instance.__query_661171423_0.ToEntityArray(Allocator.Temp);
foreach (var entity in entities)
{
var fromData = __instance.EntityManager.GetComponentData<FromCharacter>(entity);
var userData = __instance.EntityManager.GetComponentData<User>(fromData.User);
var chatEventData = __instance.EntityManager.GetComponentData<ChatMessageEvent>(entity);
var fromData = __instance.EntityManager.GetComponentData<FromCharacter>(entity);
var userData = __instance.EntityManager.GetComponentData<User>(fromData.User);
var chatEventData = __instance.EntityManager.GetComponentData<ChatMessageEvent>(entity);

var messageText = chatEventData.MessageText.ToString();
var messageText = chatEventData.MessageText.ToString();

if (!messageText.StartsWith(".") || messageText.StartsWith("..")) continue;
if (!messageText.StartsWith(".") || messageText.StartsWith("..")) continue;

VChatEvent ev = new VChatEvent(fromData.User, fromData.Character, messageText, chatEventData.MessageType, userData);
var ctx = new ChatCommandContext(ev);
VChatEvent ev = new VChatEvent(fromData.User, fromData.Character, messageText, chatEventData.MessageType, userData);
var ctx = new ChatCommandContext(ev);

CommandResult result;
try
{
result = CommandRegistry.Handle(ctx, messageText);
}
catch (Exception e)
{
Log.Error($"Error while handling chat message {e}");
continue;
}
CommandResult result;
try
{
result = CommandRegistry.Handle(ctx, messageText);
}
catch (Exception e)
{
Log.Error($"Error while handling chat message {e}");
continue;
}

// Legacy .help pass through support
if (result == CommandResult.Success && messageText.StartsWith(".help-legacy", System.StringComparison.InvariantCulture))
{
chatEventData.MessageText = messageText.Replace("-legacy", string.Empty);
__instance.EntityManager.SetComponentData(entity, chatEventData);
continue;
}
else if (result == CommandResult.Unmatched)
{
var sb = new StringBuilder();
// Legacy .help pass through support
if (result == CommandResult.Success && messageText.StartsWith(".help-legacy", System.StringComparison.InvariantCulture))
{
chatEventData.MessageText = messageText.Replace("-legacy", string.Empty);
__instance.EntityManager.SetComponentData(entity, chatEventData);
continue;
}
else if (result == CommandResult.Unmatched)
{
var sb = new StringBuilder();

sb.AppendLine($"Command not found: {messageText.Color(Color.Command)}");
sb.AppendLine($"Command not found: {messageText.Color(Color.Command)}");

var closeMatches = CommandRegistry.FindCloseMatches(ctx, messageText).ToArray();
if (closeMatches.Length > 0)
{
sb.AppendLine($"Did you mean: {string.Join(", ", closeMatches.Select(c => c.Color(Color.Command)))}");
}
ctx.SysReply(sb.ToString());
var closeMatches = CommandRegistry.FindCloseMatches(ctx, messageText).ToArray();
if (closeMatches.Length > 0)
{
sb.AppendLine($"Did you mean: {string.Join(", ", closeMatches.Select(c => c.Color(Color.Command)))}");
}
VWorld.Server.EntityManager.DestroyEntity(entity);
ctx.SysReply(sb.ToString());
}
VWorld.Server.EntityManager.DestroyEntity(entity);
}
}
}
105 changes: 105 additions & 0 deletions VCF.Core/Common/VersionChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using BepInEx.Unity.IL2CPP;
using ProjectM;
using ProjectM.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using Unity.Collections;
using Unity.Entities;
using VampireCommandFramework.Breadstone;

namespace VampireCommandFramework.Common;


internal static class VersionChecker
{
public static void ListAllPluginVersions(Entity userEntity = default)
{
try
{
// Get all loaded plugins
var installedPlugins = GetInstalledPlugins();

if (installedPlugins.Count == 0)
{
LogInfoAndSendMessageToClient(userEntity, "No plugins found.");
return;
}

LogInfoAndSendMessageToClient(userEntity, $"Installed Plugins ({installedPlugins.Count}):");

// Sort plugins by name for easier reading
foreach (var plugin in installedPlugins.OrderBy(p => p.Name))
{
var pluginMessage = $"{plugin.Name.Color(Color.Command)}: {plugin.Version.Color(Color.Green)}";
var formattedMessage = $"[vcf] ".Color(Color.Primary) + pluginMessage;
SendMessageToClient(userEntity, formattedMessage);
}
}
catch (Exception ex)
{
Log.Error($"Error listing plugin versions: {ex.Message}");
}
}

static void SendMessageToClient(Entity userEntity, string message)
{
if (userEntity == default) return;

try
{
if (VWorld.Server?.EntityManager == null) return;
if (!VWorld.Server.EntityManager.Exists(userEntity)) return;
if (!VWorld.Server.EntityManager.HasComponent<User>(userEntity)) return;

var user = VWorld.Server.EntityManager.GetComponentData<User>(userEntity);
if (!user.IsConnected) return;

var msg = new FixedString512Bytes(message);
ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, user, ref msg);
}
catch (Exception ex)
{
Log.Debug($"Could not send message to client (user may have disconnected): {ex.Message}");
}
}

static void LogInfoAndSendMessageToClient(Entity userEntity, string message)
{
Log.Info(message);
SendMessageToClient(userEntity, message);
}

/// Gets information about all installed BepInEx plugins
private static List<InstalledPluginInfo> GetInstalledPlugins()
{
var plugins = new List<InstalledPluginInfo>();

foreach (var pluginKvp in IL2CPPChainloader.Instance.Plugins)
Comment thread
decaprime marked this conversation as resolved.
{
var pluginInfo = pluginKvp.Value;
if (pluginInfo?.Metadata != null)
{
plugins.Add(new InstalledPluginInfo
{
GUID = pluginInfo.Metadata.GUID,
Name = pluginInfo.Metadata.Name,
Version = pluginInfo.Metadata.Version.ToString()
});
}
}

return plugins;
}


/// Information about an installed plugin
private class InstalledPluginInfo
{
public string GUID { get; set; }
public string Name { get; set; }
public string Version { get; set; }
}


}
9 changes: 3 additions & 6 deletions VCF.Core/Framework/ChatCommandContext.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using Engine.Console;
using ProjectM;
using ProjectM.Network;
using ProjectM.Network;
using System;
using Unity.Collections;
using VampireCommandFramework.Breadstone;
using VampireCommandFramework.Framework;

namespace VampireCommandFramework;

Expand Down Expand Up @@ -49,8 +47,7 @@ public void Reply(string v)
if (v.Length > maxMessageLength)
v = v[..maxMessageLength];

FixedString512Bytes unityMessage = v;
ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, User, ref unityMessage);
ChatMessageQueue.Send(User, v);
}

// todo: expand this, just throw from here as void and build a handler that can message user/log.
Expand Down
Loading
Loading