Alle Projekte

Offen veröffentlicht

Dojo Arena: eine V-Rising-Server-Mod

Eine PvP-Arena-Mod für V Rising, auf der etwa sechs Monate lang ein öffentlicher Server lief. Ich habe die Codebasis neu geschrieben und ihr Command-Framework gebaut, und zwar innerhalb eines Unity-ECS-Spiels über IL2CPP. Als der Server schloss, haben wir die Mod offen veröffentlicht, damit andere Server sie nutzen können.

Rolle
Die Codebasis neu geschrieben, das Command-Framework entworfen
Stack
C#, .NET 6, BepInEx (IL2CPP), Harmony, Unity ECS
Kontext
Öffentlicher PvP-Arena-Server, gemeinsam mit einem weiteren Entwickler entwickelt
Status
Offen veröffentlicht, für andere Server

Das Problem

V Rising ist ein Survival-Spiel. Ein Arena-Server braucht das Gegenteil: keinen Loot-Verlust, kein Basenbauen, sofortige Respawns und Ausrüstung auf Abruf, damit jeder Kampf unter gleichen Bedingungen beginnt. Nichts davon ist fürs Modding vorgesehen. Das Spiel wird über IL2CPP zu nativem Code kompiliert und baut auf Unitys Entity Component System auf, also hieß jede Änderung, im dekompilierten Spiel das richtige System zu finden und sich dort einzuhängen.

Das Framework darunter

// Commands are plain static methods. The attribute is the whole registration.
[Command("j", "<spell name> [mods: 132|1,3,2|13,2|?]", "Spawns a spell-enhancing jewel with the specified mods.", AdminOnly = false, Aliases = new[] { "jewel" })]
public static string JewelCommand(Player player, List<string> args) { /* ... */ }

[Command("kick", "<partOfName>", "Kick the specified player out of the server.", AdminOnly = true)]
public static string KickCommand(Player player, string fullArgText) { /* ... */ }


public class CommandAttribute : Attribute
{
    public string Name = "";
    public string Usage = "";
    public string Description = "";
    public string[] Aliases = null;
    public bool AdminOnly = true;   // admin-only unless a command opts out
    public bool Hidden = false;
    public CommandParameters Parameters = CommandParameters.Invalid;
    public MethodInfo Method;
}

// Signatures are checked once at load, so a badly declared command is
// rejected with a log entry instead of failing when a player uses it.
public static bool ValidateCommandMethod(CommandAttribute attribute)
{
    var paramList = attribute.Method.GetParameters();

    switch (paramList.Length)
    {
        case 0:
            attribute.Parameters = CommandParameters.None;
            return true;

        case 1:
            if (paramList[0].ParameterType == typeof(Player))
            {
                attribute.Parameters = CommandParameters.Player;
                return true;
            }
            return false;

        case 2:
            if (paramList[0].ParameterType != typeof(Player)) return false;
            if (paramList[1].ParameterType == typeof(List<string>))
            {
                attribute.Parameters = CommandParameters.PlayerAndArgs;
                return true;
            }
            else if (paramList[1].ParameterType == typeof(string))
            {
                attribute.Parameters = CommandParameters.PlayerAndFullArgText;
                return true;
            }
            return false;
        default:
            return false;
    }
}

[EventSubscriber(Events.Load)]
public static void RegisterCommands()
{
    if (!G.MethodsWithAttributes.ContainsKey(typeof(CommandAttribute))) return;
    foreach ((var commandMethod, var attribute) in G.MethodsWithAttributes[typeof(CommandAttribute)])
    {
        var commandAttribute = (CommandAttribute)attribute;
        commandAttribute.Method = commandMethod;
        commandAttribute.Name = commandAttribute.Name.ToLower().Replace(" ", "");
        var name = commandAttribute.Name;

        if (string.IsNullOrWhiteSpace(name)) continue;
        if (!ValidateCommandMethod(commandAttribute))
        {
            Output.LogError("Command-ValidationFailed", $"Command {name} has failed method validation and may be inappropriately defined.", $"MethodName = {commandMethod.Name}");
            continue;
        }

        if (Lookup.ContainsKey(name))
        {
            Output.LogError("Command-NameTaken", $"The command name '{name}' was already taken in lookup by a pre-existing command!",
                $"Method={commandAttribute.Method.Name}, Usage={commandAttribute.Usage}, Desc={commandAttribute.Description}");
            continue;
        }

        Lookup.Add(name, commandAttribute);
        SortedList.Add(commandAttribute);

        if (commandAttribute.Aliases != null)
        {
            foreach (var alias in commandAttribute.Aliases)
            {
                if (Lookup.ContainsKey(alias))
                {
                    Output.LogError("Command-AliasTaken", $"Command '{name}'s alternative alias '{alias}' in lookup by a pre-existing command!",
                        $"Method={commandAttribute.Method.Name}, Usage={commandAttribute.Usage}, Desc={commandAttribute.Description}");
                    continue;
                }

                Lookup.Add(alias, commandAttribute);
            }
        }
    }

    SortedList.Sort((x, y) => (x.Name.CompareTo(y.Name)));
}
Aus dem veröffentlichten Code, gekürzt auf den Registrierungspfad.

In einem Spiel arbeiten, das dafür nicht gebaut wurde

Ergebnis

Die Mod betrieb etwa sechs Monate lang einen öffentlichen Arena-Server. Als der Server schloss, haben wir sie offen veröffentlicht, damit andere Communities damit eigene Arenen betreiben können.

Wenn ich es noch einmal bauen würde

Ich würde Command-Argumente an typisierte Parameter binden, statt rohe Strings durchzureichen, damit nicht jeder Command seine Eingabe selbst neu parst, und ich würde Tests rund um den Parser und die System-Patches ergänzen. Ein Spiel-Update kann die Systeme, von denen eine Mod abhängt, unbemerkt verändern, und Tests sind der günstigste Weg, das vor den Spielern herauszufinden.