Writing some tests, namely the fake game server

This commit is contained in:
chrisbell committed 2026-10-24 14:48:02 -05:00
1 parent 4f168a6061
commit 30a6fac573
3 files changed
+321 -1

No files matched your search

+86
View File
@@ -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<FakeMode>(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<string> ValueOptions = new(StringComparer.OrdinalIgnoreCase)
{
"dataPath", "port", "setconfig", "withconfig", "logPath", "ip", "maxclients",
};
public static CommandLine Parse(string[] args)
{
var values = new Dictionary<string, string?>(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);
}
}
+30
View File
@@ -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
// <dataPath>/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 };
}
+205 -1
View File
@@ -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<int>() ?? 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;