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);