From 30a6fac57352353b2c18887f1ea7c9c8a4ec27e8 Mon Sep 17 00:00:00 2001 From: chris bell Date: Sat, 24 Oct 2026 14:48:02 -0500 Subject: [PATCH] Writing some tests, namely the fake game server --- tests/FakeGameServer/Options.cs | 86 +++++++++++++ tests/FakeGameServer/Output.cs | 30 +++++ tests/FakeGameServer/Program.cs | 206 +++++++++++++++++++++++++++++++- 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 tests/FakeGameServer/Options.cs create mode 100644 tests/FakeGameServer/Output.cs diff --git a/tests/FakeGameServer/Options.cs b/tests/FakeGameServer/Options.cs new file mode 100644 index 0000000..f362afe --- /dev/null +++ b/tests/FakeGameServer/Options.cs @@ -0,0 +1,86 @@ + +using System.Globalization; + +namespace FakeGameServer; + +internal enum FakeMode +{ + Normal, + NoReady, + FailOnStart, + CrashAfterReady, + IgnoreStop, + IgnoreAll, +} + +internal sealed record FakeSettings( + FakeMode Mode, + int StartupMs, + int StopMs, + int CrashAfterMs, + int CrashExitCode, + bool StdinEofStopsServer) +{ + public static FakeSettings FromEnvironment() + { + var mode = Enum.TryParse(Env("FAKE_MODE"), true, out var parsed) ? parsed : FakeMode.Normal; + return new FakeSettings( + mode, + EnvInt("FAKE_STARTUP_MS", 500), + EnvInt("FAKE_STOP_MS", 200), + EnvInt("FAKE_CRASH_AFTER_MS", 300), + EnvInt("FAKE_CRASH_EXIT_CODE", 0), + string.Equals(Env("FAKE_STDIN_EOF"), "stop", StringComparison.OrdinalIgnoreCase)); + } + + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); + + private static int EnvInt(string name, int fallback) => + int.TryParse(Env(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : fallback; +} + +internal sealed record CommandLine(string? DataPath, int? Port, string? SetConfig) +{ + // Flags that take a value. Like the real game, the next token is taken as the value + // when there is no "=", even if that token is another flag. + private static readonly HashSet ValueOptions = new(StringComparer.OrdinalIgnoreCase) + { + "dataPath", "port", "setconfig", "withconfig", "logPath", "ip", "maxclients", + }; + + public static CommandLine Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var body = arg[2..]; + var eq = body.IndexOf('=', StringComparison.Ordinal); + if (eq >= 0) + { + values[body[..eq]] = body[(eq + 1)..]; + } + else if (ValueOptions.Contains(body) && i + 1 < args.Length) + { + values[body] = args[++i]; + } + else + { + values[body] = null; + } + } + + values.TryGetValue("dataPath", out var dataPath); + values.TryGetValue("setconfig", out var setConfig); + values.TryGetValue("port", out var portText); + int? port = int.TryParse(portText, NumberStyles.None, CultureInfo.InvariantCulture, out var n) ? n : null; + return new CommandLine(dataPath, port, setConfig); + } +} diff --git a/tests/FakeGameServer/Output.cs b/tests/FakeGameServer/Output.cs new file mode 100644 index 0000000..c5ea177 --- /dev/null +++ b/tests/FakeGameServer/Output.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Text.Json; + +namespace FakeGameServer; + +internal static class FakeOutput +{ + public static void Log(string category, string message) => + Console.Out.WriteLine($"{Stamp()} [{category}] {message}"); + + // Mimics the real game's crash handling: print the error, write a crash log under + // /Logs, and exit with a code that is 0 unless a test asks otherwise. + public static int Crash(string dataPath, int exitCode, string message) + { + var text = $"{Stamp()}: Critical error occurred{Environment.NewLine}{message}"; + Console.Out.WriteLine(text); + var logDir = Path.Combine(dataPath, "Logs"); + Directory.CreateDirectory(logDir); + File.WriteAllText(Path.Combine(logDir, "server-crash.log"), text + Environment.NewLine); + return exitCode; + } + + private static string Stamp() => + DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture); +} + +internal static class JsonOptions +{ + public static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; +} diff --git a/tests/FakeGameServer/Program.cs b/tests/FakeGameServer/Program.cs index 1bc52a6..e336a07 100644 --- a/tests/FakeGameServer/Program.cs +++ b/tests/FakeGameServer/Program.cs @@ -1 +1,205 @@ -Console.WriteLine("Hello, World!"); +// A stand-in for VintagestoryServer, used by tests. It copies the behavior measured +// on 1.22.7 (see the design doc) and can be told to misbehave through environment +// variables: +// FAKE_MODE Normal | NoReady | FailOnStart | CrashAfterReady | IgnoreStop | IgnoreAll +// FAKE_STARTUP_MS delay before the ready line (default 500) +// FAKE_STOP_MS time the shutdown sequence takes (default 200) +// FAKE_CRASH_AFTER_MS delay between ready and the crash in CrashAfterReady (default 300) +// FAKE_CRASH_EXIT_CODE exit code used when crashing (default 0, like the real game) +// FAKE_STDIN_EOF "stop" makes the server stop when stdin closes (default: ignore it) +// +// Deliberate differences from the real game: +// - --dataPath is required, so a test can never touch a default location. +// - Only strict JSON is accepted by --setconfig, not the relaxed syntax. +// - If a value-taking flag swallows --dataPath, this exits with 2 where the real +// game would fall back to its default path. + +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using FakeGameServer; +using static FakeGameServer.FakeOutput; + +const int SetConfigSuccessExitCode = 200; + +var cli = CommandLine.Parse(args); +if (cli.DataPath is null) +{ + await Console.Error.WriteLineAsync("fake: --dataPath is required, refusing to use a default location"); + return 2; +} + +var fake = FakeSettings.FromEnvironment(); +var dataPath = Path.GetFullPath(cli.DataPath); +Directory.CreateDirectory(dataPath); + +Log("Server Notification", "Server logger started."); +Log("Server Notification", "Game Version: v0.0.0 (Fake)"); + +var configPath = Path.Combine(dataPath, "serverconfig.json"); +JsonObject config; +if (File.Exists(configPath)) +{ + config = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject() ?? new JsonObject(); +} +else +{ + Log("Server Notification", "serverconfig.json not found, creating new one"); + config = new JsonObject + { + ["ServerName"] = "Vintage Story Server", + ["Port"] = 42420, + ["MaxClients"] = 16, + }; +} + +if (cli.SetConfig is not null) +{ + JsonObject patch; + try + { + patch = JsonNode.Parse(cli.SetConfig)?.AsObject() ?? throw new JsonException("null value"); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + // Unverified: whether the real game leaves a config file behind after this failure. + // The fake does not. + var first = cli.SetConfig.TrimStart('-').FirstOrDefault(); + return Crash( + dataPath, + fake.CrashExitCode, + "Newtonsoft.Json.JsonReaderException: Unexpected character encountered while " + + $"parsing value: {first}. Path '', line 0, position 0."); + } + + foreach (var (key, value) in patch.ToList()) + { + config[key] = value?.DeepClone(); + Log("Server Notification", $"Ok, value {value} set for {key}."); + } + + File.WriteAllText(configPath, config.ToJsonString(JsonOptions.Indented)); + return SetConfigSuccessExitCode; +} + +File.WriteAllText(configPath, config.ToJsonString(JsonOptions.Indented)); +var port = cli.Port ?? config["Port"]?.GetValue() ?? 42420; + +using var shutdown = new CancellationTokenSource(); +var gate = new object(); +string? stopReason = null; +var stopViaConsole = false; + +void RequestStop(string reason, bool viaConsole) +{ + lock (gate) + { + if (stopReason is not null) + { + return; + } + + stopReason = reason; + stopViaConsole = viaConsole; + } + + shutdown.Cancel(); +} + +void OnSignal(PosixSignalContext context) +{ + context.Cancel = true; + if (fake.Mode != FakeMode.IgnoreAll) + { + RequestStop("Shutdown via signal", false); + } +} + +using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal); +using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal); + +_ = Task.Run(async () => +{ + string? line; + while ((line = await Console.In.ReadLineAsync()) is not null) + { + line = line.Trim(); + if (line.Length == 0) + { + continue; + } + + Log("Server Notification", $"Handling Console Command {line}"); + var ignoresStop = fake.Mode is FakeMode.IgnoreStop or FakeMode.IgnoreAll; + if (line == "/stop" && !ignoresStop) + { + RequestStop("Shutdown via server command", true); + } + } + + if (fake.StdinEofStopsServer) + { + RequestStop("stdin closed", false); + } +}); + +Log("Server Event", "Launching server..."); +Log("Server Notification", "Entering runphase Configuration"); +Log("Server Notification", "Loading savegame"); + +try +{ + await Task.Delay(fake.StartupMs, shutdown.Token); +} +catch (OperationCanceledException) +{ + // A stop request during startup is handled below. +} + +if (!shutdown.IsCancellationRequested) +{ + if (fake.Mode == FakeMode.FailOnStart) + { + return Crash(dataPath, fake.CrashExitCode, $"Fake failure: could not bind port {port}"); + } + + if (fake.Mode != FakeMode.NoReady) + { + Log("Server Event", $"Dedicated Server now running on Port {port} and all ips!"); + } + + var wait = fake.Mode == FakeMode.CrashAfterReady ? fake.CrashAfterMs : Timeout.Infinite; + try + { + await Task.Delay(wait, shutdown.Token); + } + catch (OperationCanceledException) + { + // Stop requested. + } + + if (!shutdown.IsCancellationRequested) + { + return Crash(dataPath, fake.CrashExitCode, "Fake failure: simulated crash while running"); + } +} + +string reason; +bool viaConsole; +lock (gate) +{ + reason = stopReason ?? "unknown"; + viaConsole = stopViaConsole; +} + +Log("Server Notification", $"Server stop requested, begin shutdown sequence. Stop reason: {reason}"); +Log("Server Notification", "Entering runphase Shutdown"); +await Task.Delay(fake.StopMs); +Log("Server Event", "World saved!"); +Log("Server Event", "Stopped the server!"); +if (viaConsole) +{ + Log("Server Notification", "Shut down command executed"); +} + +return 0;