using HSchool.Content; using HSchool.People; using HSchool.Protocol; using HSchool.Schedule; using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; using Microsoft.Extensions.Options; namespace HSchool.Server.Game; /// /// Thin supervisor: create/delete/load schools, route commands into per-school mailboxes, expose /// published snapshots to the menu. It never touches a or its World. /// internal sealed class GameLoopService( IOptions options, GameCommandQueue commands, ClientRegistry clients, GameMetrics metrics, SchoolStore store, ModContent mods, SwarmUiSettingsStore swarmSettings, ILoggerFactory loggerFactory, ILogger logger) : BackgroundService { private readonly SimulationOptions _options = options.Value; private readonly SchoolNameGenerator _names = new(); private readonly Dictionary _workers = []; private readonly Dictionary _incompatible = []; private readonly List _order = []; private SchoolWorker[] _publishedWorkers = []; private SchoolState[] _publishedIncompatible = []; private int _currentTick; private int _nextId = 1; public uint CurrentTick => (uint)Volatile.Read(ref _currentTick); public SimulationOptions Options => _options; /// /// Menu requests read this instead of blocking a worker. Each card's clock is whatever that /// school's worker last published. /// public SchoolsState SchoolsState { get { var workers = Volatile.Read(ref _publishedWorkers); var broken = Volatile.Read(ref _publishedIncompatible); var schools = new List(workers.Length + broken.Length); for (var i = 0; i < workers.Length; i++) { schools.Add(workers[i].Snapshot); } schools.AddRange(broken); schools.Sort((left, right) => left.Id.CompareTo(right.Id)); return new SchoolsState(_options.MaxSchools, schools); } } /// Create-time SwarmUI copy for this living school, or null on older saves. public SwarmUiConfigFile? PortraitSettingsOf(int schoolId) { foreach (var worker in Volatile.Read(ref _publishedWorkers)) { if (worker.Id == schoolId) { return worker.PortraitSettings; } } return null; } /// /// Menu-style read of one school's published roster and frozen catalog. Does not post to the /// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks. /// public PublishedSchoolPeople? FindPeople(int schoolId) { foreach (var worker in Volatile.Read(ref _publishedWorkers)) { if (worker.Id == schoolId) { return new PublishedSchoolPeople( worker.Snapshot, worker.RosterSnapshot, worker.ApplicantSnapshot, worker.CatalogSnapshot, worker.TimetableSnapshot, worker.MapSnapshot); } } return null; } public SchoolState? FindSchool(int schoolId) { foreach (var worker in Volatile.Read(ref _publishedWorkers)) { if (worker.Id == schoolId) { return worker.Snapshot; } } if (_incompatible.TryGetValue(schoolId, out var broken)) { return broken; } return null; } public int CountOwnedBy(string normalizedUserName) { var count = 0; foreach (var school in SchoolsState.Schools) { if (SchoolOwnership.IsOwner(school, normalizedUserName)) { count++; } } return count; } public Task ReloadFromDiskAsync(CancellationToken cancellationToken) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); commands.Enqueue(new GameCommand.ReloadSaves(completion)); return completion.Task.WaitAsync(cancellationToken); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation( "School supervisor starting at {TickRate} Hz, up to {MaxSchoolsTotal} schools ({MaxSchools} per player), saves in {Directory}.", _options.TickRate, _options.MaxSchoolsTotal, _options.MaxSchools, store.DirectoryPath); await StartWorkersFromDiskAsync().ConfigureAwait(false); _ = HeartbeatAsync(stoppingToken); try { while (await commands.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false)) { while (commands.Reader.TryRead(out var command)) { await DispatchAsync(command).ConfigureAwait(false); } } } catch (OperationCanceledException) { // Normal shutdown. } finally { await StopAllWorkersAsync(persist: true).ConfigureAwait(false); logger.LogInformation("School supervisor stopped at tick {Tick}.", CurrentTick); } } private async Task HeartbeatAsync(CancellationToken cancellationToken) { using var timer = new PeriodicTimer(_options.TickInterval); try { while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { Interlocked.Increment(ref _currentTick); } } catch (OperationCanceledException) { // Normal shutdown. } } private async Task DispatchAsync(GameCommand command) { switch (command) { case GameCommand.CreateSchool create: await HandleCreateAsync(create).ConfigureAwait(false); break; case GameCommand.DeleteSchool delete: await HandleDeleteAsync(delete).ConfigureAwait(false); break; case GameCommand.SuggestName suggest: Complete(suggest.Result, () => SuggestName(suggest.Language)); break; case GameCommand.OpenSchool open: HandleOpen(open); break; case GameCommand.CloseSchool close: StopWatching(close.PlayerId, close.SchoolId); Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId)); break; case GameCommand.SetRunning setRunning: HandleClockCommand(setRunning.PlayerId, setRunning.NormalizedUserName, new WorkerCommand.SetRunning(setRunning.Running)); break; case GameCommand.SetSpeed setSpeed: HandleClockCommand(setSpeed.PlayerId, setSpeed.NormalizedUserName, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex)); break; case GameCommand.SkipEmpty skipEmpty: HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty()); break; case GameCommand.DismissNotice dismiss: HandleClockCommand(dismiss.PlayerId, dismiss.NormalizedUserName, new WorkerCommand.DismissNotice(dismiss.NoticeId)); break; case GameCommand.DismissSchoolNotice dismissHttp: HandleDismissHttp(dismissHttp); break; case GameCommand.PostSchoolNotice postNotice: HandlePostNotice(postNotice); break; case GameCommand.GetNoticePortraitTarget getTarget: HandleGetNoticePortraitTarget(getTarget); break; case GameCommand.ReloadSaves reload: await HandleReloadAsync(reload).ConfigureAwait(false); break; case GameCommand.WorkerFailed failed: HandleWorkerFailed(failed.SchoolId); break; case GameCommand.DumpSchool dump: HandleDump(dump); break; case GameCommand.GetPerson getPerson: HandleGetPerson(getPerson); break; case GameCommand.GetPortraitBuildInput getBuild: HandleGetPortraitBuildInput(getBuild); break; case GameCommand.GetPersonLog getLog: HandleGetPersonLog(getLog); break; case GameCommand.HireStaff hire: HandleStaffing( hire.SchoolId, new WorkerCommand.HireStaff(hire.PersonId, hire.Position, hire.Result), hire.Result); break; case GameCommand.AssignSubject assign: HandleStaffing( assign.SchoolId, new WorkerCommand.AssignSubject(assign.PersonId, assign.Subject, assign.Result), assign.Result); break; case GameCommand.UnassignSubject unassign: HandleStaffing( unassign.SchoolId, new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result), unassign.Result); break; case GameCommand.AssignClassTeacher assignClassTeacher: HandleClassTeacher( assignClassTeacher.SchoolId, new WorkerCommand.AssignClassTeacher( assignClassTeacher.ClassId, assignClassTeacher.PersonId, assignClassTeacher.Result), assignClassTeacher.Result); break; case GameCommand.ClearClassTeacher clearClassTeacher: HandleClassTeacher( clearClassTeacher.SchoolId, new WorkerCommand.ClearClassTeacher(clearClassTeacher.ClassId, clearClassTeacher.Result), clearClassTeacher.Result); break; case GameCommand.PinLesson pin: HandleTimetable( pin.SchoolId, new WorkerCommand.PinLesson(pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period, pin.Result), pin.Result); break; case GameCommand.UnpinLesson unpin: HandleTimetable( unpin.SchoolId, new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result), unpin.Result); break; case GameCommand.GetDressRules getRules: HandleDressRules(getRules.SchoolId, new WorkerCommand.GetDressRules(getRules.Result), getRules.Result); break; case GameCommand.SetDressRules setRules: HandleDressRules( setRules.SchoolId, new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result), setRules.Result); break; case GameCommand.GetSpeechRules getSpeech: HandleSpeechRules(getSpeech.SchoolId, new WorkerCommand.GetSpeechRules(getSpeech.Result), getSpeech.Result); break; case GameCommand.SetSpeechRules setSpeech: HandleSpeechRules( setSpeech.SchoolId, new WorkerCommand.SetSpeechRules(setSpeech.PendingStudents, setSpeech.PendingStaff, setSpeech.Result), setSpeech.Result); break; } } 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) || !worker.Post(new WorkerCommand.GetPerson(command.PersonId, command.Locale, command.Result))) { command.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); } } private void HandleGetPortraitBuildInput(GameCommand.GetPortraitBuildInput command) { if (!_workers.TryGetValue(command.SchoolId, out var worker) || !worker.Post(new WorkerCommand.GetPortraitBuildInput(command.PersonId, command.Locale, command.Result))) { command.Result.TrySetResult( new PortraitBuildInputResult(null, PortraitScene.Empty, PersonLookupError.UnknownSchool)); } } private void HandleGetPersonLog(GameCommand.GetPersonLog command) { if (!_workers.TryGetValue(command.SchoolId, out var worker) || !worker.Post(new WorkerCommand.GetPersonLog(command.PersonId, command.Locale, command.Query, command.Result))) { command.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool)); } } private void HandleDressRules(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) { result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); } } private void HandleSpeechRules(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) { result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool)); } } private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) { result.TrySetResult(Staffing.UnknownSchool()); } } private void HandleClassTeacher(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) { result.TrySetResult(ClassTeachers.UnknownSchool()); } } private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) { result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); } } /// /// A school's thread died. Keep a menu card so the player can delete the file instead of /// opening a school that no longer ticks. /// private void HandleWorkerFailed(int schoolId) { if (!_workers.TryGetValue(schoolId, out var worker)) { return; } RememberIncompatible(worker.Snapshot with { Incompatible = true, Running = false }); Untrack(schoolId); foreach (var client in clients.All) { if (client.OpenSchoolId == schoolId) { client.OpenSchoolId = null; SendSchoolGone(client, schoolId); } } logger.LogError("School {SchoolId} stopped after a worker failure; its save file is unchanged.", schoolId); } private async Task HandleCreateAsync(GameCommand.CreateSchool command) { try { if (_workers.Count >= _options.MaxSchoolsTotal) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ServerFull)); return; } if (CountOwnedBy(command.Owner) >= _options.MaxSchools) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached)); return; } if (!SchoolNames.TryNormalize(command.Name, out var normalized)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName)); return; } if (!GameClock.IsValidStartDate(command.StartDate)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate)); 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; } } IReadOnlyList packIds; try { packIds = mods.ResolveSelectedPacks(extras); } catch (PackDependencyException ex) when (ex.Code == PackDependencyException.MissingCode) { command.Result.TrySetResult(new SchoolCreationOutcome( null, SchoolCreationError.MissingMod, ex.MissingPackId)); return; } catch (PackDependencyException) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ModCycle)); return; } catch (ContentLoadException) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); return; } if (!mods.PackExists(CatalogLoader.CorePackId)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); return; } DefCatalog catalog; try { catalog = mods.LoadCatalog(packIds); } catch (Exception ex) when (ex is ContentLoadException or SchoolContentUnavailableException) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); return; } var countryId = ResolveCountryId(catalog, command.CountryId); if (countryId is null || !catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownCountry)); return; } if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(country.Names, command.NativeLanguage)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage)); return; } var id = _nextId++; store.WriteNextId(_nextId); var seed = command.Seed ?? Random.Shared.Next(); var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true); var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true); var portrait = SwarmUiConfigFile.Clone(command.PortraitSettings ?? swarmSettings.Current); var worker = SpawnWorker( id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed, owner: command.Owner, portraitSettings: portrait); Track(worker); worker.Start(); try { 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); await worker.StopAsync(persist: false).ConfigureAwait(false); throw; } logger.LogInformation("School {SchoolId} \"{Name}\" created with seed {Seed}.", id, normalized, seed); command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None)); } catch (Exception ex) { command.Result.TrySetException(ex); } } private async Task HandleDeleteAsync(GameCommand.DeleteSchool command) { try { if (!_workers.TryGetValue(command.SchoolId, out var worker)) { if (_incompatible.Remove(command.SchoolId)) { store.Delete(command.SchoolId); PublishWorkers(); logger.LogInformation("Incompatible school {SchoolId} deleted.", command.SchoolId); command.Result.TrySetResult(true); return; } command.Result.TrySetResult(false); return; } Untrack(command.SchoolId); foreach (var client in clients.All) { if (client.OpenSchoolId == command.SchoolId) { client.OpenSchoolId = null; SendSchoolGone(client, command.SchoolId); } } await worker.StopAsync(persist: false).ConfigureAwait(false); store.Delete(command.SchoolId); logger.LogInformation("School {SchoolId} deleted.", command.SchoolId); command.Result.TrySetResult(true); } catch (Exception ex) { command.Result.TrySetException(ex); } } private async Task HandleReloadAsync(GameCommand.ReloadSaves command) { try { await StopAllWorkersAsync(persist: true).ConfigureAwait(false); await StartWorkersFromDiskAsync().ConfigureAwait(false); command.Result.TrySetResult(); } catch (Exception ex) { command.Result.TrySetException(ex); } } private void HandleOpen(GameCommand.OpenSchool command) { var client = clients.Find(command.PlayerId); if (client is null) { return; } if (!_workers.TryGetValue(command.SchoolId, out var worker)) { SendSchoolGone(client, command.SchoolId); return; } client.OpenSchoolId = command.SchoolId; worker.Post(new WorkerCommand.Open(client)); logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, command.SchoolId); } /// /// The connection stops receiving clock frames for that school. The calendar keeps running — /// schools live whether or not somebody is looking at them. /// private void StopWatching(uint playerId, int schoolId) { var client = clients.Find(playerId); if (client?.OpenSchoolId == schoolId) { client.OpenSchoolId = null; } } private string SuggestName(SchoolNameLanguage language) { var taken = SchoolsState.Schools.Select(school => school.Name); return _names.Next(taken, language); } private void Route(int schoolId, WorkerCommand command) { if (_workers.TryGetValue(schoolId, out var worker)) { worker.Post(command); } } private void HandleClockCommand(uint playerId, string normalizedUserName, WorkerCommand command) { var client = clients.Find(playerId); if (client?.OpenSchoolId is not { } schoolId) { return; } if (!_workers.TryGetValue(schoolId, out var worker) || !SchoolOwnership.CanManage(worker.Snapshot, normalizedUserName)) { return; } Route(schoolId, command); } private void HandleDismissHttp(GameCommand.DismissSchoolNotice command) { if (!_workers.TryGetValue(command.SchoolId, out var worker) || !worker.Post(new WorkerCommand.DismissNoticeHttp(command.NoticeId, command.Result))) { command.Result.TrySetResult(); } } private void HandlePostNotice(GameCommand.PostSchoolNotice command) { if (!_workers.TryGetValue(command.SchoolId, out var worker) || !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonKey, command.Kind, command.Result))) { command.Result.TrySetResult(null); } } private void HandleGetNoticePortraitTarget(GameCommand.GetNoticePortraitTarget command) { if (!_workers.TryGetValue(command.SchoolId, out var worker) || !worker.Post(new WorkerCommand.GetNoticePortraitTarget(command.NoticeId, command.Result))) { command.Result.TrySetResult(NoticePortraitTarget.UnknownSchool); } } private async Task StartWorkersFromDiskAsync() { var saves = store.LoadAll(); var nextId = Math.Max(store.ReadNextId(), 1); if (saves.Count > 0) { nextId = Math.Max(nextId, saves.Max(save => save.Id) + 1); } _nextId = nextId; var startable = new List(); foreach (var save in saves) { if (SchoolStore.CanStart(save)) { startable.Add(save); } else { RememberIncompatible(FromSave(save)); } } if (startable.Count > _options.MaxSchoolsTotal) { logger.LogWarning( "Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.", startable.Count, _options.MaxSchoolsTotal, _options.MaxSchoolsTotal); foreach (var extra in startable.Skip(_options.MaxSchoolsTotal)) { RememberIncompatible(FromSave(extra)); } startable = [.. startable.Take(_options.MaxSchoolsTotal)]; } foreach (var save in startable) { var worker = SpawnWorker( save.Id, save.Name, save.GameTime, save.Running, save.SpeedIndex, isNew: false, save.ModIds, save.Map, save.CountryId, save.ClimatePresetId, save.NativeLanguage, createSeed: null, save.Presence, save.DressRules, save.SpeechRules, save.Owner, save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings), save.Notices); worker.Start(); try { await worker.Started.ConfigureAwait(false); Track(worker); } catch (Exception ex) { logger.LogWarning( ex, "School {SchoolId} \"{Name}\" was not started; the save file is unchanged.", save.Id, save.Name); await worker.StopAsync(persist: false).ConfigureAwait(false); RememberIncompatible(FromSave(save)); } } PublishWorkers(); if (_workers.Count > 0) { logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count); } } private async Task StopAllWorkersAsync(bool persist) { var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray(); _workers.Clear(); _order.Clear(); _incompatible.Clear(); PublishWorkers(); if (stopping.Length > 0) { await Task.WhenAll(stopping).ConfigureAwait(false); } } private SchoolWorker SpawnWorker( int id, string name, DateTime time, bool running, int speedIndex, bool isNew, IReadOnlyList? modIds, MapLayout? map, string? countryId, string? climatePresetId, string? nativeLanguage, int? createSeed = null, IReadOnlyList? presence = null, SchoolDressRules? dressRules = null, SchoolSpeechRules? speechRules = null, string? owner = null, SwarmUiConfigFile? portraitSettings = null, IReadOnlyList? notices = null) => new( id, name, time, running, speedIndex, isNew, modIds, map, countryId, climatePresetId, nativeLanguage, createSeed, presence, dressRules, speechRules, owner, portraitSettings, notices, _options, clients, metrics, store, mods, onFailed: schoolId => commands.Enqueue(new GameCommand.WorkerFailed(schoolId)), loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}")); /// /// Empty request uses the first placeable country (core's Russia). A named id must exist in /// the catalog already loaded for this pack list — unknown extras were rejected above. /// internal static string? ResolveCountryId(DefCatalog catalog, string? requested) { var available = catalog.Countries.Values .Where(def => !def.Abstract) .Select(def => def.DefName) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); if (available.Length == 0) { return null; } if (string.IsNullOrWhiteSpace(requested)) { return available[0]; } return available.Contains(requested, StringComparer.Ordinal) ? requested : null; } private void Track(SchoolWorker worker) { _workers[worker.Id] = worker; _order.Add(worker.Id); PublishWorkers(); } private void Untrack(int id) { _workers.Remove(id); _order.Remove(id); PublishWorkers(); } private void PublishWorkers() { var snapshot = new SchoolWorker[_order.Count]; for (var i = 0; i < _order.Count; i++) { snapshot[i] = _workers[_order[i]]; } Volatile.Write(ref _publishedWorkers, snapshot); metrics.SchoolsChanged(snapshot.Length); Volatile.Write( ref _publishedIncompatible, [.. _incompatible.Values.OrderBy(school => school.Id)]); } private void RememberIncompatible(SchoolState school) { _incompatible[school.Id] = school with { Incompatible = true, Running = false }; } private SchoolState FromSave(SchoolSave save) => new( save.Id, save.Name, save.GameTime, Running: false, (byte)Math.Clamp(save.SpeedIndex, 0, 255), save.ModIds ?? [], SeedOf(save.Id), Owner: string.IsNullOrWhiteSpace(save.Owner) ? null : save.Owner, Incompatible: true); private int SeedOf(int schoolId) { try { return store.TryReadPeople(schoolId)?.Seed ?? 0; } catch (Exception) { return 0; } } private static void SendSchoolGone(GameClient client, int schoolId) { var frame = new byte[ProtocolCodec.MaxFrameSize]; var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId)); client.TrySend(frame.AsMemory(0, length)); } private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) => ex.InnerException switch { MapValidationException => SchoolCreationError.InvalidMap, ContentLoadException => SchoolCreationError.InvalidCatalog, _ => SchoolCreationError.InvalidCatalog, }; /// Runs work for a waiting request thread without letting an exception kill the supervisor. private static void Complete(TaskCompletionSource completion, Func work) { try { completion.TrySetResult(work()); } catch (Exception ex) { completion.TrySetException(ex); } } } internal sealed record PublishedSchoolPeople( SchoolState School, Roster? Roster, ApplicantPool? Applicants, DefCatalog? Catalog, Timetable? Timetable, MapLayout? Map);