using System.Net.WebSockets; using HSchool.Server; using HSchool.Server.Api; using HSchool.Server.Changelog; using HSchool.Server.Game; using HSchool.Server.Net; using HSchool.Server.Session; using HSchool.Simulation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); builder.Services.AddDataProtection(); builder.Services .AddOptions() .Bind(builder.Configuration.GetSection(HSchoolOptions.SectionName)) .Validate(options => !string.IsNullOrWhiteSpace(options.AlphaPassword), "HSchool:AlphaPassword must be set.") .Validate(options => options.SessionCookieDays is > 0 and <= 365, "HSchool:SessionCookieDays must be between 1 and 365.") .ValidateOnStart(); builder.Services .AddOptions() .Bind(builder.Configuration.GetSection(SimulationOptions.SectionName)) .Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.") .Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.") .Validate(options => options.MaxSchoolsTotal is > 0 and <= 255, "Simulation:MaxSchoolsTotal must be between 1 and 255.") .Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.") .Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.") .Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.") .Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.") .Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.") .Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.") .Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.") .Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.") .ValidateOnStart(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSwarmUi(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var store = new SwarmUiSettingsStore( sp.GetRequiredService(), sp.GetRequiredService().CreateLogger()); store.Load(); return store; }); builder.Services.AddSingleton(); builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); var app = builder.Build(); app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), }); app.UseMiddleware(); app.MapSessionEndpoints(); app.MapChangelogEndpoints(); app.MapSchoolEndpoints(); app.MapSettingsEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); app.MapGet("/api/status", async (GameLoopService loop, ClientRegistry clients, IOptions swarm, SwarmUiHealthService swarmHealth, CancellationToken cancellationToken) => { var state = loop.SchoolsState; var configured = !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl); var connected = configured ? await swarmHealth.GetConnectedAsync(cancellationToken) : null; return new GameStatusResponse( loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count, configured, connected); }) .WithName("GetGameStatus"); if (app.Configuration.GetValue("HSchool:AllowSaveReload", false)) { app.MapPost("/api/dev/reload-schools", async (GameLoopService loop, CancellationToken cancellationToken) => { await loop.ReloadFromDiskAsync(cancellationToken); return Results.NoContent(); }) .WithName("ReloadSchoolsFromDisk"); app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath })) .WithName("GetSavesDirectory"); app.MapGet("/api/dev/mods-directory", (ModContent mods) => Results.Json(new { path = mods.Root })) .WithName("GetModsDirectory"); app.MapDevEndpoints(); } // The realtime channel: one binary frame per protocol message, see docs/protocol.md. app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler, SessionService sessions) => { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade."); return; } if (!sessions.TryGetUserName(context, out var userName)) { using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { try { await socket.CloseAsync( WebSocketCloseStatus.PolicyViolation, "Session required.", context.RequestAborted); } catch (WebSocketException) { // The peer may already be gone. } } return; } using WebSocket connected = await context.WebSockets.AcceptWebSocketAsync(); await handler.HandleAsync(connected, userName, context.RequestAborted); }); app.MapDefaultEndpoints(); // In a published container the built client lands in wwwroot next to the server. // Local Aspire has no such folder — Vite serves the UI, and UseFileServer would warn. if (ClientStaticFiles.CanServe(app.Environment.WebRootPath)) { app.UseFileServer(); } app.Run(); /// Loop health for dashboards and integration tests. internal sealed record GameStatusResponse( uint Tick, int TickRate, int Schools, int MaxSchools, int Connections, bool SwarmUiConfigured, bool? SwarmUiConnected); /// Exposed so WebApplicationFactory-style tests can reference the entry point. public partial class Program;