using HSchool.Content; using HSchool.Server.Game; namespace HSchool.Server.Api; /// /// Catalog for the create dialog. Loading defs here is safe: Content is immutable and this never /// touches a live School or its worker. /// internal static class ModEndpoints { public static void MapModEndpoints(this IEndpointRouteBuilder builder) { builder.MapGet("/api/mods", (string? lang, ModContent mods) => { var locale = string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru"; return new ModsResponse(mods.ListPacks(locale).Select(pack => new ModInfoResponse( pack.Id, pack.Required, pack.Label, pack.Version, pack.Requires)).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."); } IReadOnlyList packIds; try { packIds = content.ResolveSelectedPacks(extras); } catch (PackDependencyException ex) { return PackProblem(ex); } catch (ContentLoadException ex) { return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message); } 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 ParseModIds(string? mods) { if (string.IsNullOrWhiteSpace(mods)) { return []; } return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private static IResult PackProblem(PackDependencyException ex) => Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status400BadRequest, title: ex.Code, extensions: new Dictionary { ["code"] = ex.Code, ["missing"] = ex.MissingPackId, }); private static IResult Problem(int statusCode, string code, string detail) => Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary { ["code"] = code, }); } internal sealed record ModsResponse(IReadOnlyList Mods); internal sealed record ModInfoResponse( string Id, bool Required, string Label, string Version, IReadOnlyList Requires); internal sealed record CatalogResponse( IReadOnlyList Territories, IReadOnlyList Buildings, IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, IReadOnlyList NameSets, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, 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), PlaceableThings(catalog, locale), PlaceableNameSets(catalog, locale), PlaceableSubjects(catalog, locale), MapDayFrame(catalog, locale), PlaceableHolidays(catalog, locale), map); private static IReadOnlyList Placeable(IEnumerable 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 PlaceableNameSets(DefCatalog catalog, string locale) => catalog.NameSets.Values .Where(def => !def.Abstract) .OrderBy(def => def.DefName, StringComparer.Ordinal) .Select(def => new NameSetInfoResponse( def.DefName, catalog.Label(locale, def), def.Spoken .Select(skill => new DefInfoResponse( skill, catalog.Skills.TryGetValue(skill, out var language) ? catalog.Label(locale, language) : skill)) .ToArray())) .ToArray(); private static IReadOnlyList PlaceableThings(DefCatalog catalog, string locale) => catalog.Things.Values .Where(def => !def.Abstract) .OrderBy(def => def.DefName, StringComparer.Ordinal) .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def), def.PupilSlots)) .ToArray(); private static IReadOnlyList 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.Homeroom, def.SeatThing, def.DefaultSeats, def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(), def.Positions.ToArray())) .ToArray(); private static IReadOnlyList PlaceableSubjects(DefCatalog catalog, string locale) => catalog.Subjects.Values .Where(def => !def.Abstract) .OrderBy(def => def.DefName, StringComparer.Ordinal) .Select(def => new SubjectInfoResponse( def.DefName, catalog.Label(locale, def), def.Grades.Min, def.Grades.Max, def.HoursPerWeek, def.Skills.Select(share => new SubjectSkillInfo(share.Skill, share.Share)).ToArray(), def.Room)) .ToArray(); private static DayFrameResponse? MapDayFrame(DefCatalog catalog, string locale) { var frame = catalog.DayFrame; return frame is null ? null : new DayFrameResponse( frame.DefName, catalog.Label(locale, frame), frame.FirstLesson, frame.LessonCount, frame.LessonMinutes, frame.BreakMinutes, frame.LongBreakAfter, frame.LongBreakMinutes); } private static IReadOnlyList PlaceableHolidays(DefCatalog catalog, string locale) => catalog.Holidays.Values .Where(def => !def.Abstract) .OrderBy(def => def.DefName, StringComparer.Ordinal) .Select(def => new HolidayInfoResponse( def.DefName, catalog.Label(locale, def), def.Start, def.End)) .ToArray(); } internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0); internal sealed record NameSetInfoResponse( string DefName, string Label, IReadOnlyList NativeLanguages); internal sealed record RoomInfoResponse( string DefName, string Label, bool Homeroom, string? SeatThing, int DefaultSeats, IReadOnlyList Slots, IReadOnlyList Positions); internal sealed record RoomSlotInfo(string Key, string Thing, int Count); internal sealed record SubjectSkillInfo(string Skill, float Share); internal sealed record SubjectInfoResponse( string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek, IReadOnlyList Skills, string? Room); internal sealed record DayFrameResponse( string DefName, string Label, string FirstLesson, int LessonCount, int LessonMinutes, int BreakMinutes, int LongBreakAfter, int LongBreakMinutes); internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End);