using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace StS2Sim;
///
/// Embedded HTTP + WebSocket server for the standalone UI.
///
/// Concerns kept here: HTTP routing, static-file serving, WebSocket lifecycle,
/// and broadcast fan-out. Sim orchestration lives in ;
/// deck I/O lives in + .
///
internal sealed class SimServer
{
public int Port { get; }
public string WebRoot { get; }
private readonly HttpListener _listener = new();
private readonly List _sockets = new();
private readonly object _socketsLock = new();
private CancellationTokenSource? _currentJobCts;
private sealed class SocketState
{
public required WebSocket Ws { get; init; }
// Bounded queue: drops oldest events if the consumer (browser) can't
// keep up. Keeps the server fast even when the UI is slow.
public readonly Channel Outbox =
Channel.CreateBounded(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
}
public SimServer(int port, string webRoot)
{
Port = port;
WebRoot = webRoot;
// Bind both IPv4 loopback and IPv6 loopback explicitly. Firefox often
// resolves "localhost" to ::1 first; if we only bound IPv4, the browser
// gets a connection refusal even though curl (which prefers IPv4) works.
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
_listener.Prefixes.Add($"http://[::1]:{port}/");
_listener.Prefixes.Add($"http://localhost:{port}/");
}
public void Start()
{
try
{
_listener.Start();
}
catch (HttpListenerException ex)
{
Console.Error.WriteLine($"\n ERROR: could not bind port {Port}: {ex.Message}");
Console.Error.WriteLine($" Likely something else is using the port, or this user lacks URL ACL permission.");
Console.Error.WriteLine($" Try: STS2SIM_PORT=52325 dotnet run (or close whatever is on 52324)");
throw;
}
Console.WriteLine($"\n StS2Sim server listening at:");
foreach (var prefix in _listener.Prefixes)
Console.WriteLine($" {prefix}");
_ = AcceptLoop();
}
private async Task AcceptLoop()
{
while (_listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch (ObjectDisposedException) { break; }
catch (HttpListenerException) { break; }
_ = HandleAsync(ctx);
}
}
private async Task HandleAsync(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath ?? "/";
switch (path)
{
case "/api/deck":
await ServeDeck(ctx);
break;
case "/api/sim/start":
await ServeSimStart(ctx);
break;
case "/api/sim/stop":
_currentJobCts?.Cancel();
Send(ctx, 200, "application/json", "{\"ok\":true}");
break;
case "/ws":
await HandleWebSocket(ctx);
break;
default:
await ServeStatic(ctx, path);
break;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SimServer] {ctx.Request.Url}: {ex}");
try { Send(ctx, 500, "text/plain", ex.Message); } catch { /* response may be gone */ }
}
}
// ─── Static files ───────────────────────────────────────────────────────
private async Task ServeStatic(HttpListenerContext ctx, string path)
{
if (path == "/") path = "/index.html";
var file = Path.Combine(WebRoot, path.TrimStart('/'));
if (!File.Exists(file))
{
Send(ctx, 404, "text/plain", "Not found: " + path);
return;
}
var bytes = await File.ReadAllBytesAsync(file);
var mime = path.EndsWith(".html") ? "text/html"
: path.EndsWith(".js") ? "application/javascript"
: path.EndsWith(".css") ? "text/css"
: "application/octet-stream";
ctx.Response.ContentType = mime;
ctx.Response.ContentLength64 = bytes.Length;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
// ─── /api/deck ─────────────────────────────────────────────────────────
private async Task ServeDeck(HttpListenerContext ctx)
{
var deck = SaveFileReader.ReadFreshest();
if (deck == null)
{
Send(ctx, 404, "application/json", "{\"error\":\"no save file found\"}");
return;
}
// Group cards by (id, upgradeLevel) so upgraded copies show separately.
var grouped = deck.Cards
.GroupBy(c => (c.Id, c.UpgradeLevel))
.Select(g => new
{
id = g.Key.Id,
upgrade = g.Key.UpgradeLevel,
name = CardLabels.Format(g.Key.Id, g.Key.UpgradeLevel),
count = g.Count(),
})
.OrderByDescending(x => x.count).ThenBy(x => x.id).ThenBy(x => x.upgrade)
.ToList();
var payload = new
{
sourcePath = deck.SourcePath,
modified = deck.Modified.ToString("yyyy-MM-dd HH:mm:ss"),
character = deck.CharacterId,
characterPretty = CardLabels.PrettyName(deck.CharacterId),
currentHp = deck.CurrentHp,
maxHp = deck.MaxHp,
gold = deck.Gold,
deckSize = deck.Cards.Count,
cardsGrouped = grouped,
cardsRaw = deck.Cards.Select(c => c.Id).ToList(),
relics = deck.Relics.Select(r => new { id = r, name = CardLabels.PrettyName(r) }).ToList(),
};
await SendJson(ctx, 200, payload);
}
// ─── /api/sim/start ────────────────────────────────────────────────────
private async Task ServeSimStart(HttpListenerContext ctx)
{
var body = await new StreamReader(ctx.Request.InputStream).ReadToEndAsync();
var req = JsonSerializer.Deserialize(body, JsonOpts) ?? new StartReq();
var deck = SaveFileReader.ReadFreshest();
if (deck == null)
{
Send(ctx, 404, "application/json", "{\"error\":\"no save file found\"}");
return;
}
// Resolve card IDs to C# Types and pair with upgrade level from save.
List deckEntries;
try
{
deckEntries = deck.Cards
.Select(c =>
{
var t = CardIdResolver.Resolve(c.Id) ?? throw new ArgumentException($"Unknown card id '{c.Id}'");
return new Harness.DeckEntry(t, c.UpgradeLevel);
})
.ToList();
}
catch (Exception ex)
{
Send(ctx, 400, "application/json", JsonSerializer.Serialize(new { error = ex.Message }));
return;
}
// Sim runs on a background task; we ack immediately and stream progress on /ws.
_currentJobCts?.Cancel();
_currentJobCts = new CancellationTokenSource();
var ct = _currentJobCts.Token;
Send(ctx, 200, "application/json", "{\"ok\":true,\"started\":true}");
var job = new SimJob
{
Deck = deckEntries,
Relics = deck.Relics.Where(r => !string.IsNullOrEmpty(r)).ToList(),
CharacterId = deck.CharacterId,
BroadcastEvent = BroadcastEvent,
Seeds = req.Seeds,
K = req.K,
Turns = req.Turns,
Epsilon = req.Epsilon,
};
_ = Task.Run(() => job.Run(ct), ct);
}
// ─── WebSocket ─────────────────────────────────────────────────────────
private async Task HandleWebSocket(HttpListenerContext ctx)
{
if (!ctx.Request.IsWebSocketRequest)
{
Send(ctx, 400, "text/plain", "expected WebSocket");
return;
}
var wsCtx = await ctx.AcceptWebSocketAsync(null);
var ws = wsCtx.WebSocket;
var state = new SocketState { Ws = ws };
lock (_socketsLock) _sockets.Add(state);
// Per-socket writer pump: serializes SendAsync calls so we never call
// SendAsync concurrently on the same connection (which corrupts WS
// framing) and drops oldest events if the browser can't keep up.
var writer = Task.Run(async () =>
{
try
{
await foreach (var bytes in state.Outbox.Reader.ReadAllAsync())
{
if (ws.State != WebSocketState.Open) break;
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, default);
}
}
catch { /* connection drop */ }
});
try
{
var buffer = new byte[1024];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer, default);
if (result.MessageType == WebSocketMessageType.Close) break;
// We don't currently expect client messages.
}
}
catch { /* connection drop */ }
finally
{
state.Outbox.Writer.TryComplete();
lock (_socketsLock) _sockets.Remove(state);
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", default); } catch { }
ws.Dispose();
}
}
private Task BroadcastEvent(object payload)
{
var json = JsonSerializer.Serialize(payload, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
List snapshot;
lock (_socketsLock) snapshot = _sockets.ToList();
foreach (var state in snapshot)
{
if (state.Ws.State != WebSocketState.Open) continue;
// TryWrite never blocks; under DropOldest we evict the oldest queued
// payload if the browser is too slow.
state.Outbox.Writer.TryWrite(bytes);
}
return Task.CompletedTask;
}
// ─── helpers ───────────────────────────────────────────────────────────
private static void Send(HttpListenerContext ctx, int status, string contentType, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = status;
ctx.Response.ContentType = contentType;
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes);
ctx.Response.Close();
}
private static async Task SendJson(HttpListenerContext ctx, int status, object payload)
{
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload, JsonOpts));
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private sealed class StartReq
{
public int Seeds { get; set; } = 200;
public int K { get; set; } = 30;
public int Turns { get; set; } = 5;
public double Epsilon { get; set; } = 0.30;
}
}