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:
@@ -0,0 +1,117 @@
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic HTTP that exists only when <c>HSchool:AllowSaveReload</c> is on. Never mapped in
|
||||
/// production by default.
|
||||
/// </summary>
|
||||
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<SchoolLiveDump?>());
|
||||
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<string, PresenceSnapshot> presence,
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, float>> needs)
|
||||
{
|
||||
string? nodeId = null;
|
||||
if (presence.TryGetValue(person.Id, out var row))
|
||||
{
|
||||
nodeId = row.NodeId;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, float> 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<T> NewCompletion<T>() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
|
||||
internal sealed record SchoolDumpResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
DateTime GameTime,
|
||||
bool Running,
|
||||
IReadOnlyList<SchoolDumpPersonResponse> People,
|
||||
IReadOnlyList<SchoolDumpLessonResponse> Now,
|
||||
IReadOnlyList<SchoolDumpLessonResponse> Lessons);
|
||||
|
||||
internal sealed record SchoolDumpPersonResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
string? NodeId,
|
||||
IReadOnlyDictionary<string, float> Needs);
|
||||
|
||||
internal sealed record SchoolDumpLessonResponse(
|
||||
string ClassId,
|
||||
string Subject,
|
||||
string TeacherId,
|
||||
string RoomId,
|
||||
int Day,
|
||||
int Period);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user