Local Aspire has no published client folder; the middleware warned on every boot. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.9 KiB
C#
182 lines
6.9 KiB
C#
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<HSchoolOptions>()
|
|
.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<SimulationOptions>()
|
|
.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<GameCommandQueue>();
|
|
builder.Services.AddSingleton<ClientRegistry>();
|
|
builder.Services.AddSingleton<UserStore>();
|
|
builder.Services.AddSingleton<SessionService>();
|
|
builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded());
|
|
builder.Services.AddSingleton<GameMetrics>();
|
|
builder.Services.AddSingleton<SchoolStore>();
|
|
builder.Services.AddSingleton<ModContent>();
|
|
builder.Services.AddSingleton<GameSocketHandler>();
|
|
builder.Services.AddSingleton<GameLoopService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
|
builder.Services.AddSwarmUi(builder.Configuration);
|
|
builder.Services.AddSingleton<SwarmUiHealthService>();
|
|
builder.Services.AddSingleton<SwarmUiSettingsStore>(sp =>
|
|
{
|
|
var store = new SwarmUiSettingsStore(
|
|
sp.GetRequiredService<IHostEnvironment>(),
|
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<SwarmUiSettingsStore>());
|
|
store.Load();
|
|
return store;
|
|
});
|
|
builder.Services.AddSingleton<PortraitService>();
|
|
|
|
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<SessionAuthMiddleware>();
|
|
|
|
app.MapSessionEndpoints();
|
|
app.MapChangelogEndpoints();
|
|
app.MapSchoolEndpoints();
|
|
app.MapSettingsEndpoints();
|
|
app.MapTimetableEndpoints();
|
|
app.MapModEndpoints();
|
|
|
|
app.MapGet("/api/status", async (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> 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();
|
|
|
|
/// <summary>Loop health for dashboards and integration tests.</summary>
|
|
internal sealed record GameStatusResponse(
|
|
uint Tick,
|
|
int TickRate,
|
|
int Schools,
|
|
int MaxSchools,
|
|
int Connections,
|
|
bool SwarmUiConfigured,
|
|
bool? SwarmUiConnected);
|
|
|
|
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
|
public partial class Program;
|