1002 lines
34 KiB
C#
1002 lines
34 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Thin supervisor: create/delete/load schools, route commands into per-school mailboxes, expose
|
|
/// published snapshots to the menu. It never touches a <see cref="School"/> or its <c>World</c>.
|
|
/// </summary>
|
|
internal sealed class GameLoopService(
|
|
IOptions<SimulationOptions> options,
|
|
GameCommandQueue commands,
|
|
ClientRegistry clients,
|
|
GameMetrics metrics,
|
|
SchoolStore store,
|
|
ModContent mods,
|
|
SwarmUiSettingsStore swarmSettings,
|
|
ILoggerFactory loggerFactory,
|
|
ILogger<GameLoopService> logger) : BackgroundService
|
|
{
|
|
private readonly SimulationOptions _options = options.Value;
|
|
private readonly SchoolNameGenerator _names = new();
|
|
private readonly Dictionary<int, SchoolWorker> _workers = [];
|
|
private readonly Dictionary<int, SchoolState> _incompatible = [];
|
|
private readonly List<int> _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;
|
|
|
|
/// <summary>
|
|
/// Menu requests read this instead of blocking a worker. Each card's clock is whatever that
|
|
/// school's worker last published.
|
|
/// </summary>
|
|
public SchoolsState SchoolsState
|
|
{
|
|
get
|
|
{
|
|
var workers = Volatile.Read(ref _publishedWorkers);
|
|
var broken = Volatile.Read(ref _publishedIncompatible);
|
|
var schools = new List<SchoolState>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Create-time SwarmUI copy for this living school, or null on older saves.</summary>
|
|
public SwarmUiConfigFile? PortraitSettingsOf(int schoolId)
|
|
{
|
|
foreach (var worker in Volatile.Read(ref _publishedWorkers))
|
|
{
|
|
if (worker.Id == schoolId)
|
|
{
|
|
return worker.PortraitSettings;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<DressRulesOutcome> 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<SpeechRulesOutcome> 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<StaffingOutcome> result)
|
|
{
|
|
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
|
{
|
|
result.TrySetResult(Staffing.UnknownSchool());
|
|
}
|
|
}
|
|
|
|
private void HandleClassTeacher(int schoolId, WorkerCommand command, TaskCompletionSource<ClassTeacherOutcome> result)
|
|
{
|
|
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
|
{
|
|
result.TrySetResult(ClassTeachers.UnknownSchool());
|
|
}
|
|
}
|
|
|
|
private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource<TimetableOutcome> result)
|
|
{
|
|
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
|
{
|
|
result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The connection stops receiving clock frames for that school. The calendar keeps running —
|
|
/// schools live whether or not somebody is looking at them.
|
|
/// </summary>
|
|
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<SchoolSave>();
|
|
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<string>? modIds,
|
|
MapLayout? map,
|
|
string? countryId,
|
|
string? climatePresetId,
|
|
string? nativeLanguage,
|
|
int? createSeed = null,
|
|
IReadOnlyList<PresenceSnapshot>? presence = null,
|
|
SchoolDressRules? dressRules = null,
|
|
SchoolSpeechRules? speechRules = null,
|
|
string? owner = null,
|
|
SwarmUiConfigFile? portraitSettings = null,
|
|
IReadOnlyList<StickyNoticeSave>? 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}"));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
|
|
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> 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);
|