using System.Diagnostics; using HSchool.Content; using HSchool.People; using HSchool.Protocol; using HSchool.Server.Net; using HSchool.Simulation; namespace HSchool.Server.Game; internal sealed partial class SchoolWorker { private void RunSync() { try { RunLoop(_stopping.Token); } catch (SchoolContentUnavailableException ex) { _logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id); _started.TrySetException(ex); ReportFailure(); } catch (Exception ex) { _logger.LogError(ex, "School {SchoolId} worker died.", _id); _started.TrySetException(ex); ReportFailure(); } } /// /// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and /// the menu kept drawing its card with a frozen clock, as if the school were alive. /// private void ReportFailure() { if (_stopping.IsCancellationRequested) { // Already being torn down on purpose; the supervisor knows. return; } try { _onFailed(_id); } catch (Exception ex) { _logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id); } } private void RunLoop(CancellationToken cancellationToken) { var packIds = _mods.NormalizePackIds(_modIds); _logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds)); foreach (var packId in packIds) { if (!_mods.PackExists(packId)) { throw new SchoolContentUnavailableException( $"School {_id} needs mod '{packId}', but that folder is missing."); } } var catalog = _mods.LoadCatalog(packIds, _logger); Volatile.Write(ref _catalogSnapshot, catalog); var map = _mods.LoadMap(packIds, _savedMap); Volatile.Write(ref _mapSnapshot, map); try { MapValidator.Validate(map, catalog); } catch (MapValidationException ex) { throw new SchoolContentUnavailableException(ex.Message, ex); } var school = _isNew ? School.Create(_id, _name, _time, catalog, map) : School.Load(_id, _name, _time, _running, _speedIndex, catalog, map); var peopleDirty = false; try { peopleDirty = InstallPeople(school, catalog, map); } catch { school.Dispose(); throw; } _school = school; school.DressRules = _savedDressRules ?? new SchoolDressRules(); school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules(); PublishSnapshot(); if (_isNew) { Persist(); } if (peopleDirty) { PersistPeople(); } _started.TrySetResult(); using var timer = new PeriodicTimer(_options.TickInterval); var fixedDelta = _options.FixedDeltaTime; var lastTimestamp = Stopwatch.GetTimestamp(); var accumulator = 0d; var lastSave = lastTimestamp; var peopleChanged = false; try { while (!cancellationToken.IsCancellationRequested) { if (!WaitForTick(timer, cancellationToken)) { break; } DrainMailbox(); var now = Stopwatch.GetTimestamp(); accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds; lastTimestamp = now; var steps = 0; peopleChanged = false; while (accumulator >= fixedDelta && steps < MaxCatchUpSteps) { var stepStarted = Stopwatch.GetTimestamp(); peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond); _metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds); accumulator -= fixedDelta; steps++; } if (steps == MaxCatchUpSteps && accumulator >= fixedDelta) { _logger.LogWarning( "School {SchoolId} is behind by {Backlog:F0} ms; dropping the backlog.", _id, accumulator * 1000); accumulator = 0d; } if (peopleChanged) { PersistPeople(); if (school.TimetableDirty) { RebuildTimetable(school); } } if (steps > 0) { PublishSnapshot(); BroadcastClock(); MaybeBroadcastPresence(school); } FlushSettings(); if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval) { Persist(); lastSave = Stopwatch.GetTimestamp(); } } } catch (OperationCanceledException) { // Normal shutdown. } finally { DrainMailbox(); if (Volatile.Read(ref _persistOnStop)) { Persist(); } school.Dispose(); _school = null; } } /// /// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine; /// then runs here, not as a pool callback. /// private static bool WaitForTick(PeriodicTimer timer, CancellationToken cancellationToken) { try { return timer.WaitForNextTickAsync(cancellationToken).AsTask().GetAwaiter().GetResult(); } catch (OperationCanceledException) { return false; } } private void MaybeBroadcastPresence(School school) { _presenceAge++; var interval = Math.Max(1, _options.TickRate / 2); if (_presenceAge < interval) { return; } _presenceAge = 0; BroadcastPresence(); } private void ApplySkip(School school) { var result = school.TrySkipEmpty(); if (!result.Succeeded) { return; } if (result.PeopleChanged) { PersistPeople(); if (school.TimetableDirty) { RebuildTimetable(school); } } PublishSnapshot(); Persist(); BroadcastClock(); BroadcastPresence(); _presenceAge = 0; } private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) { var countryId = ResolveCountryId(catalog, _countryId); if (countryId is null) { throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); } if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) { throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); } _climatePresetId = ResolveClimatePreset(country, _climatePresetId); var demand = SchoolDemand.From(catalog, map); Roster roster; ApplicantPool applicants; int seed; var generated = false; string? native; if (_isNew) { if (_createSeed is not int createSeed) { throw new InvalidOperationException($"School {_id} was created without a people seed."); } seed = createSeed; native = ResolveNative(country, seed, _nativeLanguage, generating: true); _nativeLanguage = native; roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native); applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); generated = true; } else { var loaded = _store.TryReadPeople(_id); if (loaded is null) { throw new SchoolContentUnavailableException( $"School {_id} has no people file; the school was left unstarted."); } seed = loaded.Seed; native = ResolveNative(country, seed, _nativeLanguage, generating: false); _nativeLanguage = native; roster = loaded.ToRoster(); if (loaded.Applicants is { Applicants.Count: > 0 }) { applicants = loaded.Applicants; } else { applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); generated = true; } if (DressGenerator.NeedsDressing(roster, applicants)) { roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time); applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time); generated = true; } RequireKnownApparel(catalog, roster, applicants); } if (OpinionGenerator.NeedsFamilyOpinions(roster)) { roster = OpinionGenerator.SeedFamily(catalog, roster); generated = true; } var assigned = OrientationGenerator.Assign(catalog, roster, seed); generated |= !ReferenceEquals(assigned, roster); roster = assigned; var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed); generated |= !ReferenceEquals(assignedPool, applicants); applicants = assignedPool; if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0) { generated = true; } roster = LockerAssigner.Apply(catalog, map, roster); if (!RosterFit.Matches(roster, demand)) { throw new SchoolContentUnavailableException( $"School {_id} roster does not match its map; the people file was left untouched."); } school.InstallPeople(roster, seed, countryId, applicants, _nativeLanguage, _climatePresetId); InstallTimetable(school); school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); school.RestorePresence(_savedPresence); return generated; } private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants) { foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person))) { foreach (var item in person.Items) { if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) { throw new SchoolContentUnavailableException( $"School roster references unusable thing '{item.Def}'."); } } } } private static string? ResolveCountryId(DefCatalog catalog, string? requested) { if (string.IsNullOrWhiteSpace(requested)) { return null; } return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract ? requested : null; } private static string? ResolveClimatePreset(CountryDef country, string? requested) { if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal)) { return requested; } return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false); } private static string? ResolveNative( CountryDef country, int schoolSeed, string? requested, bool generating) => NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); private void BroadcastClock() { var school = _school; if (school is null) { return; } foreach (var client in _clients.All) { if (client.IsReady && client.OpenSchoolId == _id) { BroadcastClockTo(client, school); } } } private void SendMapSnapshot(GameClient client, School school) { if (school.Catalog is null || school.Map is null) { return; } var locale = ProtocolConstants.CatalogLocale(client.Locale); var view = MapView.Build(school.Catalog, school.Map, locale); var nodes = new MapSnapshotNode[view.Count]; for (var i = 0; i < view.Count; i++) { var node = view[i]; var items = new MapSnapshotItem[node.Items.Count]; for (var item = 0; item < node.Items.Count; item++) { items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count); } nodes[i] = new MapSnapshotNode( (byte)node.Kind, node.Id, node.ParentId, node.Name, (ushort)node.PupilSlots, items, node.Positions); } // Sized from the message, not from the inbound frame limit: a map the player enlarged in // the create editor outgrows 8 KiB somewhere past sixty furnished rooms. var message = new ServerMapSnapshotMessage(school.Id, nodes); var frame = new byte[ProtocolCodec.MapSnapshotSize(message)]; var length = ProtocolCodec.WriteMapSnapshot(frame, message); client.TrySendReliable(frame.AsMemory(0, length)); } private void BroadcastClockTo(GameClient client, School school) { var skip = school.PeekSkipEmpty(); var frame = new byte[ProtocolCodec.MaxFrameSize]; var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage( school.Id, new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(), school.Clock.IsRunning, (byte)school.Clock.SpeedIndex, skip.Allowed, skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0, school.Weather.Tenths, (byte)school.Weather.Precipitation)); client.TrySend(frame.AsMemory(0, length)); } private void BroadcastPresence() { var school = _school; if (school is null) { return; } foreach (var client in _clients.All) { if (client.IsReady && client.OpenSchoolId == _id) { SendPresence(client, school); } } } private void SendPresence(GameClient client, School school) { var locale = ProtocolConstants.CatalogLocale(client.Locale); var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale); var frame = new byte[ProtocolCodec.PresenceSize(message)]; var length = ProtocolCodec.WritePresence(frame, message); client.TrySendReliable(frame.AsMemory(0, length)); } }