Refuse newer save formats and add a gated dump of a live school.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 00:30:39 +03:00
co-authored by Cursor
parent 0ae633995f
commit a7955a0dce
13 changed files with 479 additions and 11 deletions
+3
View File
@@ -48,6 +48,9 @@ internal abstract record GameCommand
/// </summary>
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
/// <summary>Live presence and needs for a diagnostic dump. Completes on that school's worker.</summary>
internal sealed record DumpSchool(int SchoolId, TaskCompletionSource<SchoolLiveDump?> Result) : GameCommand;
/// <summary>One person's card, including live needs. Completes on that school's worker thread.</summary>
internal sealed record GetPerson(
int SchoolId,
@@ -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)
@@ -0,0 +1,45 @@
using Arch.Core;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Live slice of one school for a diagnostic dump. Built on the worker thread; HTTP never sees
/// the <c>World</c>.
/// </summary>
internal sealed record SchoolLiveDump(
DateTime GameTime,
bool Running,
IReadOnlyList<PresenceSnapshot> Presence,
IReadOnlyDictionary<string, IReadOnlyDictionary<string, float>> Needs,
IReadOnlyList<LessonPlacement> Now);
/// <summary>Reads presence and needs from the live school. Call only on that school's worker.</summary>
internal static class SchoolDumpReader
{
private static readonly QueryDescription IdentityAndNeeds =
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
public static SchoolLiveDump Read(School school, int weekDays)
{
var needs = new Dictionary<string, IReadOnlyDictionary<string, float>>(StringComparer.Ordinal);
school.World.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds live) =>
{
needs[identity.Id] = new Dictionary<string, float>(live.Values, StringComparer.Ordinal);
});
IReadOnlyList<LessonPlacement> 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);
}
}
+22
View File
@@ -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;
}
/// <summary>
/// Named upgrade seam for saves older than <see cref="CurrentFormat"/>. Empty on purpose:
/// missing fields already default and extra fields are ignored. Put a migration here when a
/// format bump actually needs one.
/// </summary>
internal static SchoolSave UpgradeOlderSave(SchoolSave save) => save;
public void Save(SchoolSave save)
{
WriteAtomic(SchoolPath(save.Id), save);
+10
View File
@@ -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;
+2
View File
@@ -20,6 +20,8 @@ internal abstract record WorkerCommand
internal sealed record SkipEmpty : WorkerCommand;
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
internal sealed record GetPerson(
string PersonId,
string Locale,