diff --git a/AGENTS.md b/AGENTS.md
index 5dcd2db..3755ed9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -170,3 +170,10 @@ say so explicitly in the change description.
- **A large one-shot `Tick(week)` jumps the clock, then applies all those minutes at the new
time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode`
is randomized — commute slack goes through `Seed.Mix`, never that.
+- **The school seed used to be its id.** A test that needs a specific family shape is green on
+ one machine and red on a clean clone. Assert a property, not the first pupil of school 1.
+- **Host tests share one server and one save folder.** Start each test by clearing the list
+ through the API (`SchoolApiTests.ResetAsync`).
+- **Presence frames do not arrive at all while a school is paused.** They are broadcast only
+ after a tick step, and a paused clock produces none. Opening still sends one snapshot; a test
+ that waits for presence on pause will hang.
diff --git a/docs/phases/27-serviceability.md b/docs/phases/27-serviceability.md
index 3133f9c..ebf7d14 100644
--- a/docs/phases/27-serviceability.md
+++ b/docs/phases/27-serviceability.md
@@ -14,25 +14,25 @@
## Задачи
-- [ ] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с
+- [x] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с
пропавшей папкой мода
-- [ ] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием
-- [ ] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем
+- [x] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием
+- [x] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем
же переключателем, что и `reload-schools`, и никогда в проде по умолчанию
-- [ ] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника
-- [ ] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы
+- [x] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника
+- [x] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы
был её id; тесты хоста делят один сервер и одну папку сейвов, поэтому начинают с очистки;
на паузе кадры присутствия не приходят вовсе
-- [ ] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает
+- [x] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает
запрет параллельности, потому что нативная память Arch этого не любит
-- [ ] `docs/protocol.md` описывает дамп в разделе дев-ручек
+- [x] `docs/protocol.md` описывает дамп в разделе дев-ручек
## Тесты, без которых фаза не закрыта
-- [ ] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым
-- [ ] Сейв текущего формата грузится как раньше
-- [ ] Дамп отдаёт людей, их узлы и текущее расписание для живой школы
-- [ ] Дамп неизвестной школы — `404`
+- [x] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым
+- [x] Сейв текущего формата грузится как раньше
+- [x] Дамп отдаёт людей, их узлы и текущее расписание для живой школы
+- [x] Дамп неизвестной школы — `404`
## Критерий готовности
diff --git a/docs/protocol.md b/docs/protocol.md
index 9603020..e2f70d2 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -443,6 +443,57 @@ rebuilds the rest around it. Same success payload as GET timetable.
Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
`404` `unknown-lesson`.
+## Dev endpoints
+
+These exist only when `HSchool:AllowSaveReload` is true (headless AppHost tests). They are never
+mapped in production by default. Same switch as `POST /api/dev/reload-schools`.
+
+### `GET /api/dev/schools/{id}/dump`
+
+Roster, live presence, live needs and the timetable as one JSON. The roster and lesson table come
+from the published snapshots; nodes and needs go through that school's mailbox — HTTP does not
+read the `World`. Unknown `{id}` is `404` `unknown-school`.
+
+```json
+{
+ "id": 1,
+ "name": "Гимназия №14",
+ "gameTime": "2012-04-03T10:20:00Z",
+ "running": true,
+ "people": [
+ {
+ "id": "f3.p1",
+ "fullName": "Иванова Ольга Михайловна",
+ "nodeId": "classroom-101",
+ "needs": { "Hunger": 0.92, "Toilet": 1, "Social": 0.8, "Sleep": 1 }
+ }
+ ],
+ "now": [
+ {
+ "classId": "c5A",
+ "subject": "Mathematics",
+ "teacherId": "f3.p1",
+ "roomId": "classroom-101",
+ "day": 1,
+ "period": 3
+ }
+ ],
+ "lessons": [
+ {
+ "classId": "c5A",
+ "subject": "Mathematics",
+ "teacherId": "f3.p1",
+ "roomId": "classroom-101",
+ "day": 1,
+ "period": 3
+ }
+ ]
+}
+```
+
+`nodeId` is null when the person is off campus. `now` is the lessons occurring at `gameTime`
+(empty on a break, night, weekend or holiday). `lessons` is the published table.
+
## WebSocket message ids
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
diff --git a/src/HSchool.Server/Api/DevEndpoints.cs b/src/HSchool.Server/Api/DevEndpoints.cs
new file mode 100644
index 0000000..dc95e8e
--- /dev/null
+++ b/src/HSchool.Server/Api/DevEndpoints.cs
@@ -0,0 +1,117 @@
+using HSchool.People;
+using HSchool.Schedule;
+using HSchool.Server.Game;
+using HSchool.Simulation;
+
+namespace HSchool.Server.Api;
+
+///
+/// Diagnostic HTTP that exists only when HSchool:AllowSaveReload is on. Never mapped in
+/// production by default.
+///
+internal static class DevEndpoints
+{
+ private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
+
+ public static void MapDevEndpoints(this IEndpointRouteBuilder builder)
+ {
+ builder.MapGet("/api/dev/schools/{id:int}/dump", async (
+ int id,
+ GameLoopService loop,
+ GameCommandQueue commands,
+ CancellationToken cancellationToken) =>
+ {
+ var published = loop.FindPeople(id);
+ if (published is null)
+ {
+ return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
+ }
+
+ var command = new GameCommand.DumpSchool(id, NewCompletion());
+ commands.Enqueue(command);
+ var live = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ if (live is null)
+ {
+ return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
+ }
+
+ return Results.Ok(MapDump(published, live));
+ })
+ .WithName("DumpSchool");
+ }
+
+ private static SchoolDumpResponse MapDump(PublishedSchoolPeople published, SchoolLiveDump live)
+ {
+ var byId = live.Presence.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
+ var people = published.Roster?.People
+ .OrderBy(person => person.Id, StringComparer.Ordinal)
+ .Select(person => Person(person, byId, live.Needs))
+ .ToArray() ?? [];
+
+ var lessons = (published.Timetable?.Lessons ?? [])
+ .Select(Lesson)
+ .ToArray();
+ var now = live.Now.Select(Lesson).ToArray();
+
+ return new SchoolDumpResponse(
+ published.School.Id,
+ published.School.Name,
+ live.GameTime,
+ live.Running,
+ people,
+ now,
+ lessons);
+ }
+
+ private static SchoolDumpPersonResponse Person(
+ Person person,
+ IReadOnlyDictionary presence,
+ IReadOnlyDictionary> needs)
+ {
+ string? nodeId = null;
+ if (presence.TryGetValue(person.Id, out var row))
+ {
+ nodeId = row.NodeId;
+ }
+
+ IReadOnlyDictionary values = person.Needs;
+ if (needs.TryGetValue(person.Id, out var live))
+ {
+ values = live;
+ }
+
+ return new SchoolDumpPersonResponse(person.Id, person.Name.Full, nodeId, values);
+ }
+
+ private static SchoolDumpLessonResponse Lesson(LessonPlacement lesson) =>
+ new(lesson.ClassId, lesson.Subject, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
+
+ private static TaskCompletionSource NewCompletion() =>
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ 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 SchoolDumpResponse(
+ int Id,
+ string Name,
+ DateTime GameTime,
+ bool Running,
+ IReadOnlyList People,
+ IReadOnlyList Now,
+ IReadOnlyList Lessons);
+
+internal sealed record SchoolDumpPersonResponse(
+ string Id,
+ string FullName,
+ string? NodeId,
+ IReadOnlyDictionary Needs);
+
+internal sealed record SchoolDumpLessonResponse(
+ string ClassId,
+ string Subject,
+ string TeacherId,
+ string RoomId,
+ int Day,
+ int Period);
diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs
index 9917e5e..6bea20d 100644
--- a/src/HSchool.Server/Game/GameCommand.cs
+++ b/src/HSchool.Server/Game/GameCommand.cs
@@ -48,6 +48,9 @@ internal abstract record GameCommand
///
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
+ /// Live presence and needs for a diagnostic dump. Completes on that school's worker.
+ internal sealed record DumpSchool(int SchoolId, TaskCompletionSource Result) : GameCommand;
+
/// One person's card, including live needs. Completes on that school's worker thread.
internal sealed record GetPerson(
int SchoolId,
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index a5e41bb..0df9c1d 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -179,6 +179,10 @@ internal sealed class GameLoopService(
HandleWorkerFailed(failed.SchoolId);
break;
+ case GameCommand.DumpSchool dump:
+ HandleDump(dump);
+ break;
+
case GameCommand.GetPerson getPerson:
HandleGetPerson(getPerson);
break;
@@ -220,6 +224,15 @@ internal sealed class GameLoopService(
}
}
+ private void HandleDump(GameCommand.DumpSchool command)
+ {
+ if (!_workers.TryGetValue(command.SchoolId, out var worker)
+ || !worker.Post(new WorkerCommand.Dump(command.Result)))
+ {
+ command.Result.TrySetResult(null);
+ }
+ }
+
private void HandleGetPerson(GameCommand.GetPerson command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
diff --git a/src/HSchool.Server/Game/SchoolDumpReader.cs b/src/HSchool.Server/Game/SchoolDumpReader.cs
new file mode 100644
index 0000000..0367a6f
--- /dev/null
+++ b/src/HSchool.Server/Game/SchoolDumpReader.cs
@@ -0,0 +1,45 @@
+using Arch.Core;
+using HSchool.Schedule;
+using HSchool.Simulation;
+
+namespace HSchool.Server.Game;
+
+///
+/// Live slice of one school for a diagnostic dump. Built on the worker thread; HTTP never sees
+/// the World.
+///
+internal sealed record SchoolLiveDump(
+ DateTime GameTime,
+ bool Running,
+ IReadOnlyList Presence,
+ IReadOnlyDictionary> Needs,
+ IReadOnlyList Now);
+
+/// Reads presence and needs from the live school. Call only on that school's worker.
+internal static class SchoolDumpReader
+{
+ private static readonly QueryDescription IdentityAndNeeds =
+ new QueryDescription().WithAll();
+
+ public static SchoolLiveDump Read(School school, int weekDays)
+ {
+ var needs = new Dictionary>(StringComparer.Ordinal);
+ school.World.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds live) =>
+ {
+ needs[identity.Id] = new Dictionary(live.Values, StringComparer.Ordinal);
+ });
+
+ IReadOnlyList now = [];
+ if (school.Timetable is not null && school.Catalog is not null)
+ {
+ now = TimetableClock.OccurringAt(school.Timetable, school.Catalog, school.Clock.Time, weekDays);
+ }
+
+ return new SchoolLiveDump(
+ school.Clock.Time,
+ school.Clock.IsRunning,
+ school.CapturePresence(),
+ needs,
+ now);
+ }
+}
diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs
index 7d8c54b..c807740 100644
--- a/src/HSchool.Server/Game/SchoolStore.cs
+++ b/src/HSchool.Server/Game/SchoolStore.cs
@@ -142,6 +142,21 @@ internal sealed class SchoolStore
continue;
}
+ if (save.Format > CurrentFormat)
+ {
+ _logger.LogWarning(
+ "Save {Path} is format {Format}; this build reads format {Current}. Leaving the file in place.",
+ path,
+ save.Format,
+ CurrentFormat);
+ continue;
+ }
+
+ if (save.Format < CurrentFormat)
+ {
+ save = UpgradeOlderSave(save);
+ }
+
// Claimed last, so a file rejected above does not reserve an id a good file needs.
if (!claimed.TryAdd(save.Id, path))
{
@@ -178,6 +193,13 @@ internal sealed class SchoolStore
return saves;
}
+ ///
+ /// Named upgrade seam for saves older than . Empty on purpose:
+ /// missing fields already default and extra fields are ignored. Put a migration here when a
+ /// format bump actually needs one.
+ ///
+ internal static SchoolSave UpgradeOlderSave(SchoolSave save) => save;
+
public void Save(SchoolSave save)
{
WriteAtomic(SchoolPath(save.Id), save);
diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs
index 972b94e..c14fc14 100644
--- a/src/HSchool.Server/Game/SchoolWorker.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.cs
@@ -410,6 +410,10 @@ internal sealed class SchoolWorker
ApplySkip(school);
break;
+ case WorkerCommand.Dump dump:
+ dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
+ break;
+
case WorkerCommand.GetPerson getPerson:
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
getPerson.Result.TrySetResult(
@@ -468,6 +472,9 @@ internal sealed class SchoolWorker
{
switch (command)
{
+ case WorkerCommand.Dump dump:
+ dump.Result.TrySetResult(null);
+ break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break;
@@ -493,6 +500,9 @@ internal sealed class SchoolWorker
{
switch (command)
{
+ case WorkerCommand.Dump dump:
+ dump.Result.TrySetException(exception);
+ break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception);
break;
diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs
index 17b2068..5b263ae 100644
--- a/src/HSchool.Server/Game/WorkerCommand.cs
+++ b/src/HSchool.Server/Game/WorkerCommand.cs
@@ -20,6 +20,8 @@ internal abstract record WorkerCommand
internal sealed record SkipEmpty : WorkerCommand;
+ internal sealed record Dump(TaskCompletionSource Result) : WorkerCommand;
+
internal sealed record GetPerson(
string PersonId,
string Locale,
diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs
index 4965956..9c992c3 100644
--- a/src/HSchool.Server/Program.cs
+++ b/src/HSchool.Server/Program.cs
@@ -73,6 +73,8 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath }))
.WithName("GetSavesDirectory");
+
+ app.MapDevEndpoints();
}
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
diff --git a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs
new file mode 100644
index 0000000..abc7633
--- /dev/null
+++ b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs
@@ -0,0 +1,194 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace HSchool.AppHost.Tests;
+
+[Collection(AppHostCollection.Name)]
+public class ServiceabilityTests(AppHostFixture fixture)
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task FutureFormatSave_LeavesTheSchoolUnstartedAndTheFileUntouched()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var directory = await SavesDirectoryAsync(client);
+
+ try
+ {
+ InstallGolden(directory, "current");
+ var path = Path.Combine(directory, "1.json");
+ BumpFormat(path, 99);
+ var before = File.ReadAllBytes(path);
+
+ using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
+ reload.EnsureSuccessStatusCode();
+
+ Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
+ Assert.Equal(before, File.ReadAllBytes(path));
+ }
+ finally
+ {
+ DeleteSchoolFiles(directory, 1);
+ using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
+ cleanup.EnsureSuccessStatusCode();
+ }
+ }
+
+ [Fact]
+ public async Task CurrentFormatSave_LoadsAsBefore()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var directory = await SavesDirectoryAsync(client);
+
+ try
+ {
+ InstallGolden(directory, "current");
+ using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
+ reload.EnsureSuccessStatusCode();
+
+ var school = Assert.Single((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
+ Assert.Equal(1, school.Id);
+ Assert.Equal("Золотая", school.Name);
+ }
+ finally
+ {
+ await SchoolApiTests.ResetAsync(client);
+ }
+ }
+
+ [Fact]
+ public async Task Dump_ReturnsPeopleNodesAndTimetable()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Дамп", TuesdayMorning);
+ await HireMathAsync(client, school.Id);
+
+ var dump = await client.GetFromJsonAsync(
+ $"/api/dev/schools/{school.Id}/dump",
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(dump);
+ Assert.Equal(school.Id, dump.Id);
+ Assert.NotEmpty(dump.People);
+ Assert.Contains(dump.People, person => person.NodeId is not null || person.Needs.Count > 0);
+ Assert.All(dump.People, person =>
+ {
+ Assert.False(string.IsNullOrWhiteSpace(person.Id));
+ Assert.False(string.IsNullOrWhiteSpace(person.FullName));
+ Assert.NotEmpty(person.Needs);
+ });
+ Assert.Contains(dump.Lessons, lesson => lesson.Subject == "Mathematics");
+ }
+
+ [Fact]
+ public async Task Dump_UnknownSchool_IsNotFound()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+
+ using var response = await client.GetAsync("/api/dev/schools/999999/dump", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("unknown-school", await ProblemCodeAsync(response));
+ }
+
+ private static async Task HireMathAsync(HttpClient client, int schoolId)
+ {
+ var staffing = await client.GetFromJsonAsync(
+ $"/api/schools/{schoolId}/staffing",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(staffing);
+ var applicant = Assert.Single(staffing.Applicants.Take(1));
+
+ using var hire = await client.PostAsJsonAsync(
+ $"/api/schools/{schoolId}/staff/hire",
+ new { personId = applicant.Id, position = "Teacher" },
+ TestContext.Current.CancellationToken);
+ hire.EnsureSuccessStatusCode();
+
+ using var assign = await client.PostAsJsonAsync(
+ $"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects",
+ new { subject = "Mathematics" },
+ TestContext.Current.CancellationToken);
+ assign.EnsureSuccessStatusCode();
+ }
+
+ 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 static void InstallGolden(string directory, string folder)
+ {
+ var source = Path.Combine(AppContext.BaseDirectory, "golden", folder);
+ foreach (var file in Directory.EnumerateFiles(source))
+ {
+ File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: true);
+ }
+ }
+
+ private static void BumpFormat(string path, int format)
+ {
+ var node = JsonNode.Parse(File.ReadAllText(path))
+ ?? throw new InvalidOperationException($"Save {path} parsed to nothing.");
+ node["format"] = format;
+ File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ private static void DeleteSchoolFiles(string directory, int id)
+ {
+ foreach (var name in new[] { $"{id}.json", $"{id}.people.json", $"{id}.timetable.json" })
+ {
+ var path = Path.Combine(directory, name);
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+
+ private static async Task ProblemCodeAsync(HttpResponseMessage response)
+ {
+ var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ return problem?.Code;
+ }
+
+ private sealed record SavesDirectoryResponse(string Path);
+
+ private sealed record ProblemResponse(string? Code);
+
+ private sealed record StaffingResponse(IReadOnlyList Applicants);
+
+ private sealed record ApplicantResponse(string Id);
+
+ private sealed record SchoolDumpResponse(
+ int Id,
+ string Name,
+ DateTime GameTime,
+ bool Running,
+ IReadOnlyList People,
+ IReadOnlyList Now,
+ IReadOnlyList Lessons);
+
+ private sealed record DumpPersonResponse(
+ string Id,
+ string FullName,
+ string? NodeId,
+ IReadOnlyDictionary Needs);
+
+ private sealed record DumpLessonResponse(
+ string ClassId,
+ string Subject,
+ string TeacherId,
+ string RoomId,
+ int Day,
+ int Period);
+}
diff --git a/tests/HSchool.Simulation.Tests/AssemblyInfo.cs b/tests/HSchool.Simulation.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..ff623c6
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/AssemblyInfo.cs
@@ -0,0 +1,2 @@
+// Arch's native allocator is not safe across parallel test classes in this suite.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]