using System.Net.WebSockets; using HSchool.Server.Api; using HSchool.Server.Game; using HSchool.Server.Net; using HSchool.Simulation; using Microsoft.Extensions.Configuration; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); 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.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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); 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.MapSchoolEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => { var state = loop.SchoolsState; return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count); }) .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) => { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade."); return; } using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); await handler.HandleAsync(socket, context.RequestAborted); }); app.MapDefaultEndpoints(); // In a published container the built client lands in wwwroot next to the server. 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); /// Exposed so WebApplicationFactory-style tests can reference the entry point. public partial class Program;