using System.Net.Http.Json; namespace HSchool.AppHost.Tests; /// /// The main menu's HTTP surface: list, create, delete. Tests share one AppHost, so each of them /// starts from an empty list rather than assuming one. /// [Collection(AppHostCollection.Name)] public class SchoolApiTests(AppHostFixture fixture) { private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc); [Fact] public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var state = await GetSchoolsAsync(client); Assert.Equal(6, state.MaxSchools); Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate); // Without the "Z" the browser would read the start date in its own time zone and the // creation form would offer the wrong hour. Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind); Assert.Equal(1d, state.GameMinutesPerRealSecond); Assert.Equal(5, state.SchoolWeekDays); Assert.Empty(state.Schools); } [Fact] public async Task CreateSchool_StartsAtTheRequestedDateAndRunning() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var created = await CreateAsync(client, "Гимназия у моря", ExpectedDefaultStart); Assert.Equal("Гимназия у моря", created.Name); Assert.Equal(ExpectedDefaultStart, created.GameTime); // Schools live from the moment they exist, whether or not anybody is inside. Assert.True(created.Running); var state = await GetSchoolsAsync(client); Assert.Contains(state.Schools, school => school.Id == created.Id); } [Fact] public async Task CreateSchool_BeyondTheLimit_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var state = await GetSchoolsAsync(client); for (var i = 0; i < state.MaxSchools; i++) { await CreateAsync(client, $"Школа {i + 1}", ExpectedDefaultStart); } using var response = await PostAsync(client, "Лишняя", ExpectedDefaultStart); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal("school-limit-reached", await ProblemCodeAsync(response)); Assert.Equal(state.MaxSchools, (await GetSchoolsAsync(client)).Schools.Count); } [Theory] [InlineData("")] [InlineData(" ")] public async Task CreateSchool_WithABlankName_IsRejected(string name) { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await PostAsync(client, name, ExpectedDefaultStart); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("invalid-name", await ProblemCodeAsync(response)); } [Fact] public async Task CreateSchool_WithAnImpossibleStartDate_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await PostAsync(client, "Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("invalid-start-date", await ProblemCodeAsync(response)); } [Fact] public async Task DeleteSchool_RemovesItAndFreesASlot() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var created = await CreateAsync(client, "На удаление", ExpectedDefaultStart); using var response = await client.DeleteAsync($"/api/schools/{created.Id}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); Assert.DoesNotContain((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id); } [Fact] public async Task DeleteSchool_ThatDoesNotExist_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); await LoginAsync(client); using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task RandomName_IsUsableAsIs() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var suggestion = await client.GetFromJsonAsync( "/api/schools/random-name", TestContext.Current.CancellationToken); Assert.NotNull(suggestion); Assert.False(string.IsNullOrWhiteSpace(suggestion.Name)); var created = await CreateAsync(client, suggestion.Name, ExpectedDefaultStart); Assert.Equal(suggestion.Name, created.Name); } [Fact] public async Task Mods_ListCoreAsRequired() { using var client = fixture.App.CreateHttpClient("server"); var response = await client.GetFromJsonAsync("/api/mods", TestContext.Current.CancellationToken); Assert.NotNull(response); var core = Assert.Single(response.Mods, pack => pack.Id == "core"); Assert.True(core.Required); Assert.Equal("Базовая игра", core.Label); Assert.False(string.IsNullOrWhiteSpace(core.Version)); Assert.Empty(core.Requires); } [Fact] public async Task Mods_LabelsCoreInTheRequestedLanguage() { using var client = fixture.App.CreateHttpClient("server"); var ru = await client.GetFromJsonAsync("/api/mods?lang=ru", TestContext.Current.CancellationToken); var en = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken); Assert.NotNull(ru); Assert.NotNull(en); Assert.Equal("Базовая игра", Assert.Single(ru.Mods, pack => pack.Id == "core").Label); Assert.Equal("Core", Assert.Single(en.Mods, pack => pack.Id == "core").Label); } [Fact] public async Task Mods_PackWithoutManifest_UsesIdAsLabel() { using var client = fixture.App.CreateHttpClient("server"); var root = await ModsDirectoryAsync(client); using (new TempPack(root, "t22plain")) { var response = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken); Assert.NotNull(response); var pack = Assert.Single(response.Mods, candidate => candidate.Id == "t22plain"); Assert.False(pack.Required); Assert.Equal("t22plain", pack.Label); Assert.Equal(string.Empty, pack.Version); Assert.Empty(pack.Requires); } } [Fact] public async Task CreateSchool_ReturnsResolvedPackOrder() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var created = await CreateAsync(client, "С ванилью", ExpectedDefaultStart); Assert.Equal(["core"], created.ModIds); } [Fact] public async Task CreateSchool_WithUnselectedDependency_NamesTheMissingPack() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var root = await ModsDirectoryAsync(client); using (new TempPack(root, "t22need", """{ "version": "1", "requires": ["t22ghost"] }""")) { using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Без базы", startDate = ExpectedDefaultStart, modIds = new[] { "t22need" }, }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.Equal("missing-mod", problem?.Code); Assert.Equal("t22ghost", problem?.Missing); } } [Fact] public async Task CreateSchool_LoadsRequiredPackBeforeDependentEvenIfListedAfter() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var root = await ModsDirectoryAsync(client); using (new TempPack(root, "t22base", """{ "version": "1", "requires": [] }""")) using (new TempPack(root, "t22addon", """{ "version": "1", "requires": ["t22base"] }""")) { using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Сначала зависимость", startDate = ExpectedDefaultStart, modIds = new[] { "t22addon", "t22base" }, }, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.Equal(["core", "t22base", "t22addon"], created?.ModIds); } } [Fact] public async Task CreateSchool_WithCyclicMods_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var root = await ModsDirectoryAsync(client); using (new TempPack(root, "t22left", """{ "requires": ["t22right"] }""")) using (new TempPack(root, "t22right", """{ "requires": ["t22left"] }""")) { using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Цикл", startDate = ExpectedDefaultStart, modIds = new[] { "t22left", "t22right" }, }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("mod-cycle", await ProblemCodeAsync(response)); } } [Fact] public async Task Catalog_LabelsDefsInTheRequestedLanguage() { using var client = fixture.App.CreateHttpClient("server"); var ru = await client.GetFromJsonAsync("/api/catalog?lang=ru", TestContext.Current.CancellationToken); var en = await client.GetFromJsonAsync("/api/catalog?lang=en", TestContext.Current.CancellationToken); Assert.NotNull(ru); Assert.NotNull(en); Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label); Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label); Assert.Equal("yard", ru.DefaultMap.Territory?.Id); Assert.Equal("Россия", Assert.Single(ru.Countries, country => country.DefName == "Russia").Label); Assert.Equal("Russia", Assert.Single(en.Countries, country => country.DefName == "Russia").Label); var russia = Assert.Single(ru.Countries, country => country.DefName == "Russia"); Assert.Equal( ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], russia.NativeLanguages.Select(language => language.DefName).ToArray()); Assert.Equal("Белорусский", Assert.Single(russia.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label); Assert.Equal(["TemperateContinental", "ContinentalCold"], russia.ClimatePresets); Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label); Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label); var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom"); Assert.True(classroom.Homeroom); Assert.Equal("StudentDesk", classroom.SeatThing); Assert.Equal(16, classroom.DefaultSeats); Assert.Empty(classroom.Slots); Assert.Empty(classroom.Positions); Assert.NotNull(ru.DayFrame); Assert.Equal("08:30", ru.DayFrame.FirstLesson); Assert.Equal(7, ru.DayFrame.LessonCount); Assert.Equal(3, ru.DayFrame.LongBreakAfter); Assert.Equal("Учебный день", ru.DayFrame.Label); Assert.NotNull(en.DayFrame); Assert.Equal("School day", en.DayFrame.Label); Assert.Equal("GymHall", Assert.Single(ru.Subjects, subject => subject.DefName == "PhysicalEducation").Room); Assert.Null(Assert.Single(ru.Subjects, subject => subject.DefName == "Mathematics").Room); Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak"); Assert.Contains(ru.Things, thing => thing.DefName == "Chair"); Assert.DoesNotContain(ru.Things, thing => thing.DefName == "TShirt"); Assert.DoesNotContain(ru.Things, thing => thing.DefName == "Textbook"); } [Fact] public async Task Catalog_WithAnUnknownMod_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); using var response = await client.GetAsync("/api/catalog?mods=no-such-mod", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("unknown-mod", await ProblemCodeAsync(response)); } /// /// A pack id is a folder name that came from a browser. Anything outside the safe alphabet is /// refused as unknown before it can be joined onto a path — on both endpoints that take one. /// [Fact] public async Task ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var catalog = await client.GetAsync("/api/catalog?mods=..%2F..%2Fsaves", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, catalog.StatusCode); Assert.Equal("unknown-mod", await ProblemCodeAsync(catalog)); using var create = await client.PostAsJsonAsync( "/api/schools", new { name = "Побег из mods", startDate = ExpectedDefaultStart, modIds = new[] { "../../saves" }, }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode); Assert.Equal("unknown-mod", await ProblemCodeAsync(create)); } [Fact] public async Task CreateSchool_WithABrokenMap_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Дырявая", startDate = ExpectedDefaultStart, map = new { territory = new { id = "yard", def = "SchoolYard" }, buildings = Array.Empty(), floors = Array.Empty(), rooms = Array.Empty(), links = Array.Empty(), }, }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("invalid-map", await ProblemCodeAsync(response)); } [Fact] public async Task CreateSchool_WithAnUnknownMod_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Чужой мод", startDate = ExpectedDefaultStart, modIds = new[] { "no-such-mod" }, }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("unknown-mod", await ProblemCodeAsync(response)); } [Fact] public async Task CreateSchool_WithAnUnknownCountry_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Чужая страна", startDate = ExpectedDefaultStart, countryId = "NoSuchCountry", }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("unknown-country", await ProblemCodeAsync(response)); } [Fact] public async Task CreateSchool_WithRussiaAndNoLanguage_PicksTheSameNativeAsTheSeed() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); const int seed = 42; var first = await CreateWithCountryAsync(client, "Страна А", ExpectedDefaultStart, "Russia", seed); var second = await CreateWithCountryAsync(client, "Страна Б", ExpectedDefaultStart, "Russia", seed); var firstSave = await ReadSchoolSaveAsync(client, first.Id); var secondSave = await ReadSchoolSaveAsync(client, second.Id); Assert.Equal("Russia", firstSave.CountryId); Assert.Equal(firstSave.NativeLanguage, secondSave.NativeLanguage); Assert.Contains(firstSave.NativeLanguage, new[] { "RussianLanguage", "BelarusianLanguage", "UkrainianLanguage" }); } [Fact] public async Task CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); const int seed = 20260818; var first = await CreateWithCountryAsync(client, "Климат А", ExpectedDefaultStart, "Russia", seed); var second = await CreateWithCountryAsync(client, "Климат Б", ExpectedDefaultStart, "Russia", seed); var firstSave = await ReadSchoolSaveAsync(client, first.Id); var secondSave = await ReadSchoolSaveAsync(client, second.Id); Assert.Equal(firstSave.ClimatePresetId, secondSave.ClimatePresetId); Assert.Contains(firstSave.ClimatePresetId, new[] { "TemperateContinental", "ContinentalCold" }); } [Fact] public async Task CreateSchool_WithAnUnknownNativeLanguage_IsRejected() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); using var response = await client.PostAsJsonAsync( "/api/schools", new { name = "Чужой язык", startDate = ExpectedDefaultStart, countryId = "Russia", nativeLanguage = "Klingon", }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("unknown-native-language", await ProblemCodeAsync(response)); } [Fact] public async Task CreateSchool_WithACustomConnectedMap_Succeeds() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var created = await CreateWithMapAsync(client, "Своя карта", ExpectedDefaultStart, SimpleCustomMap); Assert.Equal("Своя карта", created.Name); Assert.Contains((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id); } [Fact] public async Task Status_ReportsTheLoopAndTheLimit() { using var client = fixture.App.CreateHttpClient("server"); await LoginAsync(client); var status = await client.GetFromJsonAsync("/api/status", TestContext.Current.CancellationToken); Assert.NotNull(status); Assert.Equal(20, status.TickRate); Assert.Equal(6, status.MaxSchools); Assert.True(status.Tick > 0, "The loop should have ticked by now."); } [Fact] public async Task ReloadFromDisk_RestoresCreatedSchools() { using var client = fixture.App.CreateHttpClient("server"); await ResetAsync(client); var created = await CreateAsync(client, "После перезагрузки", ExpectedDefaultStart); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); var restored = (await GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id); Assert.Equal(created.Name, restored.Name); Assert.True(restored.Running); } internal const string TestPassword = "test-alpha"; internal const string TestUserName = "TestPlayer"; internal static async Task LoginAsync(HttpClient client, string userName = TestUserName) { using var current = await client.GetAsync("/api/session", TestContext.Current.CancellationToken); if (current.IsSuccessStatusCode) { return; } _ = await LoginAndGetCookieAsync(client, userName); } internal static async Task LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName) { using var first = await client.PostAsJsonAsync( "/api/session", new { password = TestPassword, userName }, TestContext.Current.CancellationToken); if (first.IsSuccessStatusCode) { return ExtractSessionCookie(first); } if (first.StatusCode == HttpStatusCode.Conflict) { userName = $"T-{Guid.NewGuid():N}"[..10]; using var retry = await client.PostAsJsonAsync( "/api/session", new { password = TestPassword, userName }, TestContext.Current.CancellationToken); retry.EnsureSuccessStatusCode(); return ExtractSessionCookie(retry); } first.EnsureSuccessStatusCode(); return ExtractSessionCookie(first); } internal static string ExtractSessionCookie(HttpResponseMessage response) { if (!response.Headers.TryGetValues("Set-Cookie", out var values)) { throw new InvalidOperationException("Login did not return a session cookie."); } foreach (var value in values) { if (!value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase)) { continue; } var end = value.IndexOf(';'); return end >= 0 ? value[..end] : value; } throw new InvalidOperationException("Login did not return a session cookie."); } internal static async Task ResetAsync(HttpClient client) { await LoginAsync(client); var state = await GetSchoolsAsync(client); foreach (var school in state.Schools) { using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); } } internal static async Task GetSchoolsAsync(HttpClient client) { var state = await client.GetFromJsonAsync("/api/schools", TestContext.Current.CancellationToken); Assert.NotNull(state); return state; } internal static async Task CreateAsync( HttpClient client, string name, DateTime startDate, int? seed = null) { using var response = await PostAsync(client, name, startDate, seed); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); return created; } internal static async Task CreateWithMapAsync(HttpClient client, string name, DateTime startDate, object map) { using var response = await client.PostAsJsonAsync( "/api/schools", new { name, startDate, map }, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); return created; } private static async Task CreateWithCountryAsync( HttpClient client, string name, DateTime startDate, string countryId, int seed) { using var response = await client.PostAsJsonAsync( "/api/schools", new { name, startDate, countryId, seed }, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); return created; } private static async Task ReadSchoolSaveAsync(HttpClient client, int id) { var directory = await SavesDirectoryAsync(client); var json = await File.ReadAllTextAsync(Path.Combine(directory, $"{id}.json"), TestContext.Current.CancellationToken); var save = System.Text.Json.JsonSerializer.Deserialize( json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.NotNull(save); return save; } private static async Task SavesDirectoryAsync(HttpClient client) { var payload = await client.GetFromJsonAsync( "/api/dev/saves-directory", TestContext.Current.CancellationToken); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload.Path)); return payload.Path; } private sealed record SchoolSaveFile( string? CountryId, string? ClimatePresetId, string? NativeLanguage, SwarmUiSaveFile? PortraitSettings); private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList? Presets); private sealed record SwarmUiPresetSave(string? Id, string? Model); internal static readonly object SimpleCustomMap = new { territory = new { id = "yard", def = "SchoolYard" }, buildings = new[] { new { id = "main", def = "MainBuilding" } }, floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } }, rooms = new[] { new { id = "office", def = "PrincipalsOffice", building = "main", floor = "floor-1", slots = Array.Empty() }, }, links = new[] { new { a = "yard", b = "office" } }, }; private static Task PostAsync( HttpClient client, string name, DateTime startDate, int? seed = null) => client.PostAsJsonAsync( "/api/schools", seed is null ? (object)new { name, startDate } : new { name, startDate, seed }, TestContext.Current.CancellationToken); internal static async Task ProblemCodeAsync(HttpResponseMessage response) { var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); return problem?.Code; } internal sealed record SchoolResponse( int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed, IReadOnlyList? ModIds = null); internal sealed record SchoolsResponse( int MaxSchools, DateTime DefaultStartDate, double GameMinutesPerRealSecond, int SchoolWeekDays, IReadOnlyList Schools); private sealed record RandomNameResponse(string Name); private sealed record ProblemResponse(string? Code, string? Missing = null); private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections); private sealed record ModsResponse(IReadOnlyList Mods); private sealed record ModInfoResponse( string Id, bool Required, string Label, string Version, IReadOnlyList Requires); private sealed record PathResponse(string Path); private static async Task ModsDirectoryAsync(HttpClient client) { var payload = await client.GetFromJsonAsync( "/api/dev/mods-directory", TestContext.Current.CancellationToken); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload.Path)); return payload.Path; } private sealed class TempPack : IDisposable { public TempPack(string modsRoot, string id, string? packJsonc = null) { Path = System.IO.Path.Combine(modsRoot, id); if (Directory.Exists(Path)) { Directory.Delete(Path, recursive: true); } Directory.CreateDirectory(Path); if (packJsonc is not null) { File.WriteAllText(System.IO.Path.Combine(Path, "pack.jsonc"), packJsonc); } } public string Path { get; } public void Dispose() { if (Directory.Exists(Path)) { Directory.Delete(Path, recursive: true); } } } private sealed record CatalogResponse( IReadOnlyList Territories, IReadOnlyList Buildings, IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, IReadOnlyList Countries, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, MapLayoutResponse DefaultMap); private sealed record DefInfoResponse(string DefName, string Label); private sealed record CountryInfoResponse( string DefName, string Label, IReadOnlyList NativeLanguages, IReadOnlyList ClimatePresets); private sealed record RoomInfoResponse( string DefName, string Label, bool Homeroom, string? SeatThing, int DefaultSeats, IReadOnlyList Slots, IReadOnlyList Positions); private sealed record RoomSlotResponse(string Key, string Thing); private sealed record SubjectInfoResponse(string DefName, string Label, string? Room); private sealed record DayFrameResponse( string DefName, string Label, string FirstLesson, int LessonCount, int LessonMinutes, int BreakMinutes, int LongBreakAfter, int LongBreakMinutes); private sealed record HolidayInfoResponse(string DefName, string Label); private sealed record MapLayoutResponse(TerritoryResponse? Territory); private sealed record TerritoryResponse(string Id, string Def); }