Writing some tests, namely the fake game server
This commit is contained in:
1 parent
4f168a6061
commit
30a6fac573
3 files changed
+321
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user