Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+128
View File
@@ -0,0 +1,128 @@
using HSchool.Content;
using HSchool.Server.Game;
namespace HSchool.Server.Api;
/// <summary>
/// Catalog for the create dialog. Loading defs here is safe: Content is immutable and this never
/// touches a live <c>School</c> or its worker.
/// </summary>
internal static class ModEndpoints
{
public static void MapModEndpoints(this IEndpointRouteBuilder builder)
{
builder.MapGet("/api/mods", (ModContent mods) =>
new ModsResponse(mods.ListPacks().Select(pack => new ModInfoResponse(pack.Id, pack.Required)).ToArray()))
.WithName("GetMods");
builder.MapGet("/api/catalog", (string? lang, string? mods, ModContent content) =>
{
var extras = ParseModIds(mods);
foreach (var packId in extras)
{
if (!ModContent.IsSafePackId(packId) || !content.PackExists(packId))
{
return Problem(StatusCodes.Status400BadRequest, "unknown-mod", $"Unknown mod '{packId}'.");
}
}
if (!content.PackExists(CatalogLoader.CorePackId))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The core pack is missing.");
}
var packIds = content.NormalizePackIds(extras);
DefCatalog catalog;
MapLayout map;
try
{
catalog = content.LoadCatalog(packIds);
map = content.LoadMap(packIds, saved: null);
MapValidator.Validate(map, catalog);
}
catch (SchoolContentUnavailableException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message);
}
catch (ContentLoadException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message);
}
catch (MapValidationException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-map", ex.Message);
}
var locale = string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
return Results.Ok(CatalogResponse.From(catalog, map, locale));
})
.WithName("GetCatalog");
}
private static IReadOnlyList<string> ParseModIds(string? mods)
{
if (string.IsNullOrWhiteSpace(mods))
{
return [];
}
return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
internal sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
internal sealed record ModInfoResponse(string Id, bool Required);
internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
MapLayout DefaultMap)
{
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
new(
Placeable(catalog.Territories.Values, catalog, locale),
Placeable(catalog.Buildings.Values, catalog, locale),
Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale),
Placeable(catalog.Things.Values, catalog, locale),
map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
where T : Def =>
defs
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
.ToArray();
private static IReadOnlyList<RoomInfoResponse> PlaceableRooms(DefCatalog catalog, string locale) =>
catalog.Rooms.Values
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new RoomInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing)).ToArray(),
def.Positions.ToArray()))
.ToArray();
}
internal sealed record DefInfoResponse(string DefName, string Label);
internal sealed record RoomInfoResponse(
string DefName,
string Label,
IReadOnlyList<RoomSlotInfo> Slots,
IReadOnlyList<string> Positions);
internal sealed record RoomSlotInfo(string Key, string Thing);
+10 -1
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.Server.Game;
using HSchool.Simulation;
@@ -47,6 +48,8 @@ internal static class SchoolEndpoints
var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
@@ -62,6 +65,12 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
SchoolCreationError.InvalidMap =>
Problem(StatusCodes.Status400BadRequest, "invalid-map", "The map is not a connected yard-and-rooms graph."),
SchoolCreationError.UnknownMod =>
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
_ => Results.Problem("Unknown error."),
};
})
@@ -98,7 +107,7 @@ internal static class SchoolEndpoints
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate);
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList<string>? ModIds, MapLayout? Map);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
+3
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.Simulation;
namespace HSchool.Server.Game;
@@ -11,6 +12,8 @@ internal abstract record GameCommand
internal sealed record CreateSchool(
string Name,
DateTime StartDate,
IReadOnlyList<string>? ExtraModIds,
MapLayout? Map,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+33 -1
View File
@@ -169,10 +169,27 @@ internal sealed class GameLoopService(
return;
}
var extras = command.ExtraModIds ?? [];
foreach (var packId in extras)
{
if (!ModContent.IsSafePackId(packId) || !mods.PackExists(packId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownMod));
return;
}
}
var packIds = mods.NormalizePackIds(extras);
if (!mods.PackExists(CatalogLoader.CorePackId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map);
Track(worker);
worker.Start();
@@ -180,6 +197,13 @@ internal sealed class GameLoopService(
{
await worker.Started.ConfigureAwait(false);
}
catch (SchoolContentUnavailableException ex)
{
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
command.Result.TrySetResult(new SchoolCreationOutcome(null, ContentError(ex)));
return;
}
catch
{
Untrack(id);
@@ -427,6 +451,14 @@ internal sealed class GameLoopService(
client.TrySend(frame.AsMemory(0, length));
}
private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) =>
ex.InnerException switch
{
MapValidationException => SchoolCreationError.InvalidMap,
ContentLoadException => SchoolCreationError.InvalidCatalog,
_ => SchoolCreationError.InvalidCatalog,
};
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
+48
View File
@@ -29,6 +29,50 @@ internal sealed class ModContent
public bool PackExists(string packId) => Directory.Exists(PackPath(packId));
/// <summary>
/// Pack folder names are identifiers, not paths. Anything that could escape <see cref="Root"/>
/// is rejected before it reaches the disk.
/// </summary>
public static bool IsSafePackId(string packId)
{
if (string.IsNullOrWhiteSpace(packId) || packId.Length > 64)
{
return false;
}
foreach (var ch in packId)
{
if (!char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')
{
return false;
}
}
return true;
}
public IReadOnlyList<ModPackInfo> ListPacks()
{
var packs = new List<ModPackInfo> { new(CatalogLoader.CorePackId, Required: true) };
if (!Directory.Exists(Root))
{
return packs;
}
foreach (var directory in Directory.GetDirectories(Root).OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
var id = Path.GetFileName(directory);
if (id.Equals(CatalogLoader.CorePackId, StringComparison.OrdinalIgnoreCase) || !IsSafePackId(id))
{
continue;
}
packs.Add(new ModPackInfo(id, Required: false));
}
return packs;
}
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
@@ -90,5 +134,9 @@ internal sealed class ModContent
return map;
}
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds) => LoadCatalog(packIds, _logger);
private string PackPath(string packId) => Path.Combine(Root, packId);
}
internal sealed record ModPackInfo(string Id, bool Required);
+28
View File
@@ -276,6 +276,7 @@ internal sealed class SchoolWorker
{
case WorkerCommand.Open open:
open.Client.OpenSchoolId = _id;
SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school);
break;
@@ -364,6 +365,33 @@ internal sealed class SchoolWorker
}
}
private void SendMapSnapshot(GameClient client, School school)
{
if (school.Catalog is null || school.Map is null)
{
return;
}
var locale = ProtocolConstants.CatalogLocale(client.Locale);
var view = MapView.Build(school.Catalog, school.Map, locale);
var nodes = new MapSnapshotNode[view.Count];
for (var i = 0; i < view.Count; i++)
{
var node = view[i];
nodes[i] = new MapSnapshotNode(
(byte)node.Kind,
node.Id,
node.ParentId,
node.Name,
node.Items,
node.Positions);
}
var frame = new byte[ProtocolConstants.MaxMessageSize];
var length = ProtocolCodec.WriteMapSnapshot(frame, new ServerMapSnapshotMessage(school.Id, nodes));
client.TrySendReliable(frame.AsMemory(0, length));
}
private static void BroadcastClockTo(GameClient client, School school)
{
var frame = new byte[ProtocolCodec.MaxFrameSize];
+103 -11
View File
@@ -4,9 +4,9 @@ using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client can never
/// stall a school worker; when the outbox overflows the oldest frame is dropped, which is right
/// for a clock that is resent 20 times a second.
/// One connected browser. Clock frames go through a 32-slot outbox that drops the oldest under
/// pressure — a stale clock is worthless. One-shot frames (the map snapshot) use a separate
/// reliable channel so they cannot be crowded out by ticks.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -20,8 +20,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
SingleWriter = false,
});
private readonly Channel<ReadOnlyMemory<byte>> _reliable =
Channel.CreateUnbounded<ReadOnlyMemory<byte>>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
private bool _ready;
private int _openSchoolId;
private int _locale;
public uint PlayerId { get; } = playerId;
@@ -33,6 +41,16 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// Hello locale byte. Workers read this when labelling a map snapshot; unknown values are
/// treated as Russian by <see cref="HSchool.Protocol.ProtocolConstants.CatalogLocale"/>.
/// </summary>
public byte Locale
{
get => (byte)Volatile.Read(ref _locale);
set => Volatile.Write(ref _locale, value);
}
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor
/// on open/close, read by the connection thread on disconnect.
@@ -50,23 +68,97 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
/// <summary>Queues a clock frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
/// <summary>Queues a frame that must arrive; never dropped for a newer clock.</summary>
public bool TrySendReliable(ReadOnlyMemory<byte> frame) => _reliable.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or both channels complete.</summary>
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
{
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
var reliable = _reliable.Reader;
var outbox = _outbox.Reader;
while (Socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
if (Socket.State != WebSocketState.Open)
if (reliable.TryRead(out var reliableFrame))
{
break;
if (!await SendAsync(reliableFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
if (outbox.TryRead(out var clockFrame))
{
if (!await SendAsync(clockFrame, cancellationToken).ConfigureAwait(false))
{
return;
}
continue;
}
var waitReliable = reliable.WaitToReadAsync(cancellationToken).AsTask();
var waitOutbox = outbox.WaitToReadAsync(cancellationToken).AsTask();
Task<bool> finished;
try
{
finished = await Task.WhenAny(waitReliable, waitOutbox).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
bool hasData;
try
{
hasData = await finished.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (hasData)
{
continue;
}
var other = ReferenceEquals(finished, waitReliable) ? waitOutbox : waitReliable;
try
{
if (!await other.ConfigureAwait(false))
{
return;
}
}
catch (OperationCanceledException)
{
return;
}
}
}
public void CompleteOutbox() => _outbox.Writer.TryComplete();
public void CompleteOutbox()
{
_reliable.Writer.TryComplete();
_outbox.Writer.TryComplete();
}
private async Task<bool> SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
{
if (Socket.State != WebSocketState.Open)
{
return false;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
return true;
}
}
@@ -56,6 +56,8 @@ internal sealed class GameSocketHandler(
return;
}
client.Locale = hello.Locale;
await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
+1
View File
@@ -49,6 +49,7 @@ app.UseWebSockets(new WebSocketOptions
});
app.MapSchoolEndpoints();
app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
{