using System.Globalization; using System.Net; using System.Text; using System.Text.Json; using Ehwrj.Core.Services; namespace Ehwrj.Tools.LocalApiStub; internal static class Program { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static async Task Main(string[] args) { var options = StubOptions.Parse(args); if (options.ShowHelp) { StubOptions.PrintHelp(); return 0; } if (!LoopbackGuard.IsLoopbackHost(options.Host)) { Console.Error.WriteLine("Only loopback hosts are allowed for the local API stub."); return 1; } using var listener = new HttpListener(); var prefix = $"http://{options.Host}:{options.Port}/"; listener.Prefixes.Add(prefix); using var shutdown = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdown.Cancel(); listener.Stop(); }; try { listener.Start(); } catch (HttpListenerException ex) { Console.Error.WriteLine($"Failed to listen on {prefix}: {ex.Message}"); return 1; } Console.WriteLine($"Ehwrj local API stub listening on {prefix}"); if (options.FixtureDirectory is not null) { Console.WriteLine($"Fixture replay: {Path.GetFullPath(options.FixtureDirectory)}"); } Console.WriteLine("Endpoints: /map_info.json /map_obj.json /map.img /state /hudmsg /gamechat"); Console.WriteLine("Press Ctrl+C to stop."); var startedAt = DateTimeOffset.UtcNow; while (!shutdown.IsCancellationRequested) { HttpListenerContext context; try { context = await listener.GetContextAsync().WaitAsync(shutdown.Token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (HttpListenerException) when (shutdown.IsCancellationRequested) { break; } _ = Task.Run(() => HandleAsync(context, startedAt, options), shutdown.Token); } return 0; } private static async Task HandleAsync(HttpListenerContext context, DateTimeOffset startedAt, StubOptions options) { try { var path = context.Request.Url?.AbsolutePath ?? "/"; if (await TryWriteFixtureAsync(context, path, options.FixtureDirectory).ConfigureAwait(false)) { return; } switch (path) { case "/map_info.json": await WriteJsonAsync(context, CreateMapInfo()).ConfigureAwait(false); break; case "/map_obj.json": await WriteJsonAsync(context, CreateMapObjects((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false); break; case "/map.img": await WriteBytesAsync(context, "image/bmp", MapImageGenerator.CreateBmp(512, 512)).ConfigureAwait(false); break; case "/state": await WriteJsonAsync(context, CreateState((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false); break; case "/hudmsg": await WriteJsonAsync(context, CreateHudMessages((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false); break; case "/gamechat": await WriteJsonAsync(context, CreateGameChat((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false); break; default: await WriteTextAsync(context, "Ehwrj local API stub\n").ConfigureAwait(false); break; } } catch (Exception ex) { context.Response.StatusCode = 500; await WriteTextAsync(context, ex.Message).ConfigureAwait(false); } finally { context.Response.Close(); } } private static object CreateMapInfo() { return new { grid_size = new[] { 1.0, 1.0 }, grid_steps = new[] { 0.1, 0.1 }, min_x = 0.0, max_x = 1.0, min_y = 0.0, max_y = 1.0 }; } private static object[] CreateMapObjects(double seconds) { var phase = seconds * 0.18; var playerX = 0.5 + Math.Cos(phase) * 0.08; var playerY = 0.5 + Math.Sin(phase) * 0.08; return new object[] { new { id = "player", name = "Player", type = "player_aircraft", team = "player", icon = "fighter", x = Round(playerX), y = Round(playerY), dx = Round(Math.Cos(phase)), dy = Round(Math.Sin(phase)) }, new { id = "bandit-1", name = "Bandit 1", type = "aircraft", team = "enemy", icon = "fighter", x = Round(0.5 + Math.Cos(phase + 1.8) * 0.22), y = Round(0.5 + Math.Sin(phase + 1.8) * 0.18) }, new { id = "bandit-2", name = "Bandit 2", type = "aircraft", team = "enemy", icon = "bomber", x = Round(0.5 + Math.Cos(phase * 0.7 + 3.2) * 0.34), y = Round(0.5 + Math.Sin(phase * 0.7 + 3.2) * 0.25) }, new { id = "objective-a", name = "A", type = "capture_zone", team = "ally", icon = "zone", x = 0.25, y = 0.28 }, new { id = "objective-b", name = "B", type = "bombing_point", team = "enemy", icon = "zone", x = 0.72, y = 0.68 } }; } private static object CreateState(double seconds) { return new Dictionary { ["TAS, km/h"] = Math.Round(720 + Math.Sin(seconds * 0.25) * 80, 1), ["IAS, km/h"] = Math.Round(650 + Math.Sin(seconds * 0.22) * 65, 1), ["Vy, m/s"] = Math.Round(Math.Sin(seconds * 0.34) * 38, 2), ["H, m"] = Math.Round(2200 + Math.Sin(seconds * 0.11) * 420, 1) }; } private static object CreateHudMessages(double seconds) { var wave = (int)(seconds / 12) % 4; return new { events = new object[] { new { text = wave == 0 ? "Enemy aircraft spotted" : "Maintain formation", enemy = wave == 0 }, new { text = "Objective B under attack", enemy = true } } }; } private static object CreateGameChat(double seconds) { var wave = (int)(seconds / 15) % 3; return new { items = new object[] { new { text = wave == 0 ? "Cover the bomber route" : "Regroup near A", enemy = false } } }; } private static double Round(double value) { return Math.Round(Math.Clamp(value, 0.02, 0.98), 5); } private static Task WriteJsonAsync(HttpListenerContext context, object value) { var json = JsonSerializer.Serialize(value, JsonOptions); return WriteBytesAsync(context, "application/json", Encoding.UTF8.GetBytes(json)); } private static Task WriteTextAsync(HttpListenerContext context, string text) { return WriteBytesAsync(context, "text/plain; charset=utf-8", Encoding.UTF8.GetBytes(text)); } private static async Task WriteBytesAsync(HttpListenerContext context, string contentType, byte[] bytes) { context.Response.ContentType = contentType; context.Response.ContentLength64 = bytes.Length; await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); } private static async Task TryWriteFixtureAsync(HttpListenerContext context, string path, string? fixtureDirectory) { if (fixtureDirectory is null) { return false; } var fileName = path switch { "/map_info.json" => "map_info.json", "/map_obj.json" => "map_obj.json", "/map.img" => "map.img", "/state" => "state.json", "/hudmsg" => "hudmsg.json", "/gamechat" => "gamechat.json", _ => null }; if (fileName is null) { return false; } var filePath = Path.Combine(fixtureDirectory, fileName); if (!File.Exists(filePath)) { return false; } var bytes = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); var contentType = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "application/json" : "image/bmp"; await WriteBytesAsync(context, contentType, bytes).ConfigureAwait(false); return true; } } internal sealed record StubOptions(string Host, int Port, string? FixtureDirectory, bool ShowHelp) { public static StubOptions Parse(IReadOnlyList args) { var host = "127.0.0.1"; var port = 8111; string? fixtureDirectory = null; for (var i = 0; i < args.Count; i++) { switch (args[i]) { case "-h": case "--help": return new StubOptions(host, port, fixtureDirectory, true); case "--host" when i + 1 < args.Count: host = args[++i]; break; case "--port" when i + 1 < args.Count && int.TryParse(args[++i], NumberStyles.None, CultureInfo.InvariantCulture, out var parsed): port = parsed; break; case "--fixture-dir" when i + 1 < args.Count: fixtureDirectory = args[++i]; break; default: Console.Error.WriteLine($"Ignoring unknown argument: {args[i]}"); break; } } return new StubOptions(host, port, fixtureDirectory, false); } public static void PrintHelp() { Console.WriteLine(""" Usage: dotnet run --project tools/Ehwrj.Tools.LocalApiStub -- [--host 127.0.0.1] [--port 8111] [--fixture-dir captures/name] Serves deterministic War Thunder-like local map, telemetry, and message endpoints for UI development. When --fixture-dir is supplied, files captured by Ehwrj.Tools.Capture are replayed first. """); } } internal static class MapImageGenerator { public static byte[] CreateBmp(int width, int height) { var stride = ((width * 3 + 3) / 4) * 4; var pixelBytes = stride * height; var fileSize = 54 + pixelBytes; var bytes = new byte[fileSize]; bytes[0] = (byte)'B'; bytes[1] = (byte)'M'; WriteInt32(bytes, 2, fileSize); WriteInt32(bytes, 10, 54); WriteInt32(bytes, 14, 40); WriteInt32(bytes, 18, width); WriteInt32(bytes, 22, height); WriteInt16(bytes, 26, 1); WriteInt16(bytes, 28, 24); WriteInt32(bytes, 34, pixelBytes); for (var y = 0; y < height; y++) { var sourceY = height - 1 - y; for (var x = 0; x < width; x++) { var nx = x / (double)Math.Max(1, width - 1); var ny = sourceY / (double)Math.Max(1, height - 1); var grid = x % 64 == 0 || sourceY % 64 == 0; var water = Math.Sin(nx * 8.0) + Math.Cos(ny * 7.0) > 1.15; var r = water ? (byte)28 : (byte)(42 + ny * 34); var g = water ? (byte)78 : (byte)(74 + nx * 46); var b = water ? (byte)98 : (byte)(58 + (1 - ny) * 34); if (grid) { r = (byte)Math.Min(255, r + 38); g = (byte)Math.Min(255, g + 38); b = (byte)Math.Min(255, b + 38); } var offset = 54 + y * stride + x * 3; bytes[offset] = b; bytes[offset + 1] = g; bytes[offset + 2] = r; } } return bytes; } private static void WriteInt16(byte[] bytes, int offset, short value) { bytes[offset] = (byte)value; bytes[offset + 1] = (byte)(value >> 8); } private static void WriteInt32(byte[] bytes, int offset, int value) { bytes[offset] = (byte)value; bytes[offset + 1] = (byte)(value >> 8); bytes[offset + 2] = (byte)(value >> 16); bytes[offset + 3] = (byte)(value >> 24); } }