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
- Per Attribut deklarierte Commands. Ein Command ist eine statische Methode mit einem Attribut, das Namen, Verwendung, Aliase und Berechtigung trägt. Beim Laden findet das Framework sie per Reflection, prüft jede Signatur und weist fehlerhafte Deklarationen sowie Namens- oder Alias-Kollisionen mit einem Log-Eintrag ab, statt zu scheitern, wenn ein Spieler den Command eintippt.
- Standardmäßig nur für Admins. Ein neuer Command ist nur für Admins nutzbar, sofern er sich nicht ausdrücklich davon ausnimmt, sodass ein vergessenes Flag kein Admin-Werkzeug für alle Spieler öffnen kann.
- Ein Parser, der Anführungszeichen versteht, damit Argumente wie Zauber- und Itemnamen mit Leerzeichen funktionieren.
- Ein Event-Bus mit Prioritäten, damit Module Laden, Verbinden und ähnliche Events abonnieren können, ohne voneinander zu wissen.
- Timer, die vom Update-Loop des Spiels selbst angetrieben werden, mit pro Timer isolierten Fehlern, damit ein fehlschlagender Callback die anderen nicht stoppen kann.
// 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)));
}In einem Spiel arbeiten, das dafür nicht gebaut wurde
- Sofortige Respawns. Die Respawn-Routine des Spiels lässt sich aus Mod-Code nicht aufrufen, also findet die Mod die passende Überladung per Reflection in der IL2CPP-Runtime und ruft sie direkt auf.
- Arena-Regeln als System-Patches. Bauen, Item-Drops und Todesbehandlung werden über Harmony auf der Ebene der ECS-Systeme abgefangen, sodass die Regeln gelten, egal wie ein Spieler sie auslöst.
- Ausrüstung auf Abruf. Statt eine Liste aller Juwelen und Zauber-Modifikatoren von Hand zu pflegen, extrahiert die Mod sie aus den eigenen Daten des Spiels in eine Konfigurationsdatei. Ein Command baut dann ein Juwel mit bis zu drei validierten Modifikatoren, sodass Spieler genau den gewünschten Build bekommen und nichts, was das Spiel nicht erlauben würde.
- Low-Level-Workarounds, wo die IL2CPP-Bindings nicht ausreichten, darunter der Umgang mit einer nativen nullable Struct per unsafe Code.
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.