Update wire protocol to version 6 and enhance timetable functionality
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s

- Bumped the wire protocol version to 6, reflecting changes in the communication structure.
- Expanded the timetable API with new endpoints for fetching and managing lesson schedules, including `GET /api/schools/{id}/timetable` and `POST /api/schools/{id}/timetable/pin`.
- Updated the protocol documentation to include detailed descriptions of the new timetable features and message structures.
- Enhanced the client-side implementation to support the new timetable functionalities, including lesson pinning and unpinning.
- Revised server-side logic to handle timetable operations and ensure proper integration with existing school management features.
- Added tests to validate the new timetable functionalities and ensure robustness in handling lesson data.
This commit is contained in:
Leonid Pershin
2026-08-19 10:05:05 +03:00
parent 2011d12b1d
commit cad3068bac
30 changed files with 1621 additions and 43 deletions
@@ -8,6 +8,7 @@ namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. People list and staffing read published
/// snapshots; the person card, hire and subject changes go through the school's mailbox.
/// The timetable is a published snapshot; pin/unpin go through the mailbox.
/// </summary>
internal static class SchoolEndpoints
{
@@ -0,0 +1,181 @@
using HSchool.People;
using HSchool.Schedule;
using HSchool.Server.Game;
namespace HSchool.Server.Api;
internal static class TimetableEndpoints
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapTimetableEndpoints(this IEndpointRouteBuilder builder)
{
var schools = builder.MapGroup("/api/schools");
schools.MapGet("/{id:int}/timetable", (
int id,
string? classId,
string? personId,
string? lang,
GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, ParseLocale(lang), classId, personId));
})
.WithName("GetSchoolTimetable");
schools.MapPost("/{id:int}/timetable/pin", async (
int id,
PinLessonRequest request,
string? lang,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!TryDefName(request.ClassId, "classId", out var classId, out var error)
|| !TryDefName(request.Subject, "subject", out var subject, out error)
|| !TryDefName(request.RoomId, "roomId", out var roomId, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.PinLesson(
id,
classId,
subject,
roomId,
request.Day,
request.Period,
NewCompletion<TimetableOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null);
})
.WithName("PinSchoolLesson");
schools.MapDelete("/{id:int}/timetable/pin", async (
int id,
string? classId,
string? subject,
int? day,
int? period,
string? lang,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!TryDefName(classId, "classId", out var classValue, out var error)
|| !TryDefName(subject, "subject", out var subjectValue, out error)
|| day is null
|| period is null)
{
return Problem(
StatusCodes.Status400BadRequest,
"invalid-query",
error.Length > 0 ? error : "classId, subject, day and period are required.");
}
var command = new GameCommand.UnpinLesson(
id,
classValue,
subjectValue,
day.Value,
period.Value,
NewCompletion<TimetableOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null);
})
.WithName("UnpinSchoolLesson");
}
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static string ParseLocale(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
private static TimetableResponse MapTimetable(
PublishedSchoolPeople published,
int weekDays,
string locale,
string? classId,
string? personId)
{
var table = published.Timetable ?? new Timetable([], []);
var roster = published.Roster ?? new Roster([], [], []);
if (published.Catalog is null)
{
return new TimetableResponse(weekDays, 0, [], []);
}
return TimetableMapper.From(table, roster, published.Catalog, weekDays, locale, classId, personId);
}
private static IResult TimetableResult(
int schoolId,
TimetableOutcome outcome,
GameLoopService loop,
string locale,
string? classId,
string? personId)
{
if (outcome.Error != TimetableError.None)
{
return TimetableProblem(outcome.Error);
}
var published = loop.FindPeople(schoolId);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, locale, classId, personId));
}
private static IResult TimetableProblem(TimetableError error) =>
error switch
{
TimetableError.UnknownClass =>
Problem(StatusCodes.Status400BadRequest, "unknown-class", "That class is not in the school."),
TimetableError.UnknownSubject =>
Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."),
TimetableError.UnknownRoom =>
Problem(StatusCodes.Status400BadRequest, "unknown-room", "That room is not on the map."),
TimetableError.NoTeacher =>
Problem(StatusCodes.Status409Conflict, "no-teacher", "Nobody is assigned that subject."),
TimetableError.PinRejected =>
Problem(StatusCodes.Status409Conflict, "pin-rejected", "That slot or room violates the timetable constraints."),
TimetableError.UnknownLesson =>
Problem(StatusCodes.Status404NotFound, "unknown-lesson", "That locked lesson is not on the timetable."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
private static bool TryDefName(string? value, string field, out string name, out string error)
{
name = value?.Trim() ?? string.Empty;
if (name.Length is < 1 or > 64)
{
error = $"{field} is not valid.";
return false;
}
error = string.Empty;
return true;
}
private static IResult Problem(int statusCode, string code, string detail)
{
return Results.Problem(
detail: detail,
statusCode: statusCode,
title: code,
extensions: new Dictionary<string, object?> { ["code"] = code });
}
}
+134
View File
@@ -0,0 +1,134 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Server.Api;
internal enum TimetableError
{
None,
UnknownSchool,
UnknownClass,
UnknownSubject,
UnknownRoom,
NoTeacher,
PinRejected,
UnknownLesson,
}
internal sealed record TimetableOutcome(TimetableError Error, Timetable? Table = null)
{
public static TimetableOutcome Ok(Timetable table) => new(TimetableError.None, table);
public static TimetableOutcome Fail(TimetableError error) => new(error);
}
internal sealed record PinLessonRequest(string? ClassId, string? Subject, string? RoomId, int Day, int Period);
internal sealed record TimetableResponse(
int WeekDays,
int LessonCount,
IReadOnlyList<TimetableLessonResponse> Lessons,
IReadOnlyList<UncoveredLessonResponse> Uncovered);
internal sealed record TimetableLessonResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
string TeacherId,
string TeacherName,
string RoomId,
int Day,
int Period,
bool Locked);
internal sealed record UncoveredLessonResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
int Hours);
internal static class TimetableMapper
{
public static TimetableResponse From(
Timetable table,
Roster roster,
DefCatalog catalog,
int weekDays,
string locale,
string? classId,
string? personId)
{
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var classes = roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
var lessons = table.Lessons.AsEnumerable();
var uncovered = table.Uncovered.AsEnumerable();
if (!string.IsNullOrWhiteSpace(classId))
{
lessons = lessons.Where(lesson => lesson.ClassId == classId);
uncovered = uncovered.Where(row => row.ClassId == classId);
}
if (!string.IsNullOrWhiteSpace(personId))
{
lessons = lessons.Where(lesson => lesson.TeacherId == personId);
}
return new TimetableResponse(
weekDays,
catalog.DayFrame?.LessonCount ?? 0,
lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale)).ToArray(),
uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray());
}
private static TimetableLessonResponse MapLesson(
LessonPlacement lesson,
IReadOnlyDictionary<string, SchoolClass> classes,
IReadOnlyDictionary<string, Person> people,
DefCatalog catalog,
string locale)
{
classes.TryGetValue(lesson.ClassId, out var schoolClass);
people.TryGetValue(lesson.TeacherId, out var teacher);
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
? catalog.Label(locale, subject)
: lesson.Subject;
return new TimetableLessonResponse(
lesson.ClassId,
schoolClass?.Year ?? 0,
schoolClass?.Letter ?? "",
lesson.Subject,
subjectLabel,
lesson.TeacherId,
teacher?.Name.Full ?? lesson.TeacherId,
lesson.RoomId,
lesson.Day,
lesson.Period,
lesson.Locked);
}
private static UncoveredLessonResponse MapUncovered(
UncoveredDemand row,
IReadOnlyDictionary<string, SchoolClass> classes,
DefCatalog catalog,
string locale)
{
classes.TryGetValue(row.ClassId, out var schoolClass);
var subjectLabel = catalog.Subjects.TryGetValue(row.Subject, out var subject)
? catalog.Label(locale, subject)
: row.Subject;
return new UncoveredLessonResponse(
row.ClassId,
schoolClass?.Year ?? 0,
schoolClass?.Letter ?? "",
row.Subject,
subjectLabel,
row.Hours);
}
}
+17
View File
@@ -69,4 +69,21 @@ internal abstract record GameCommand
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record PinLesson(
int SchoolId,
string ClassId,
string Subject,
string RoomId,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
internal sealed record UnpinLesson(
int SchoolId,
string ClassId,
string Subject,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
}
+35 -2
View File
@@ -1,6 +1,7 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -64,7 +65,12 @@ internal sealed class GameLoopService(
{
if (worker.Id == schoolId)
{
return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.ApplicantSnapshot, worker.CatalogSnapshot);
return new PublishedSchoolPeople(
worker.Snapshot,
worker.RosterSnapshot,
worker.ApplicantSnapshot,
worker.CatalogSnapshot,
worker.TimetableSnapshot);
}
}
@@ -192,6 +198,20 @@ internal sealed class GameLoopService(
new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result),
unassign.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;
}
}
@@ -212,6 +232,14 @@ internal sealed class GameLoopService(
}
}
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. Drop it from the table so the menu stops drawing a card whose clock
/// never moves again, and tell anybody watching it to go back to the menu. The save file stays
@@ -610,4 +638,9 @@ internal sealed class GameLoopService(
}
}
internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, ApplicantPool? Applicants, DefCatalog? Catalog);
internal sealed record PublishedSchoolPeople(
SchoolState School,
Roster? Roster,
ApplicantPool? Applicants,
DefCatalog? Catalog,
Timetable? Timetable);
+93
View File
@@ -0,0 +1,93 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Overlays the current lesson onto map-tree nodes. Occupancy is derived from the timetable and
/// the clock; it is not stored on the map.
/// </summary>
internal static class MapOccupancy
{
public static void Apply(
MapSnapshotNode[] nodes,
School school,
int weekDays,
string locale)
{
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
{
return;
}
var occurring = TimetableClock.OccurringAt(
school.Timetable,
school.Catalog,
school.Clock.Time,
weekDays);
if (occurring.Count == 0)
{
return;
}
var catalog = school.Catalog;
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
var people = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var byRoom = occurring.ToDictionary(lesson => lesson.RoomId, StringComparer.Ordinal);
for (var i = 0; i < nodes.Length; i++)
{
if (!byRoom.TryGetValue(nodes[i].Id, out var lesson))
{
continue;
}
classes.TryGetValue(lesson.ClassId, out var schoolClass);
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
? catalog.Label(locale, subject)
: lesson.Subject;
var classLabel = schoolClass is null
? lesson.ClassId
: $"{schoolClass.Year}{schoolClass.Letter}";
nodes[i] = nodes[i] with
{
ActivitySubject = subjectLabel,
ActivityClass = classLabel,
Characters = NamesOf(lesson, schoolClass, people),
};
}
}
private static string[] NamesOf(
LessonPlacement lesson,
SchoolClass? schoolClass,
IReadOnlyDictionary<string, Person> people)
{
var names = new List<string>();
if (people.TryGetValue(lesson.TeacherId, out var teacher))
{
names.Add(teacher.Name.Full);
}
else if (lesson.TeacherId.Length > 0)
{
names.Add(lesson.TeacherId);
}
if (schoolClass is not null)
{
foreach (var pupilId in schoolClass.PupilIds)
{
if (people.TryGetValue(pupilId, out var pupil))
{
names.Add(pupil.Name.Full);
}
}
}
return names.Count > byte.MaxValue ? [.. names.Take(byte.MaxValue)] : [.. names];
}
}
+37 -1
View File
@@ -1,6 +1,7 @@
using System.Text.Json;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
@@ -104,7 +105,8 @@ internal sealed class SchoolStore
{
var fileName = Path.GetFileName(path);
if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase))
|| fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".timetable.json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
@@ -188,6 +190,12 @@ internal sealed class SchoolStore
{
File.Delete(people);
}
var timetable = TimetablePath(id);
if (File.Exists(timetable))
{
File.Delete(timetable);
}
}
public RosterDocument? TryReadPeople(int id)
@@ -215,10 +223,38 @@ internal sealed class SchoolStore
WriteAtomic(PeoplePath(id), document, RosterJson.Options);
}
public Timetable? TryReadTimetable(int id)
{
var path = TimetablePath(id);
if (!File.Exists(path))
{
return null;
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Timetable>(json, Json);
}
catch (Exception ex)
{
throw new SchoolContentUnavailableException(
$"School {id} timetable file could not be read.",
ex);
}
}
public void SaveTimetable(int id, Timetable table)
{
WriteAtomic(TimetablePath(id), table);
}
private string SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json");
private string PeoplePath(int id) => Path.Combine(DirectoryPath, $"{id}.people.json");
private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json");
private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null)
+270 -1
View File
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -44,6 +45,8 @@ internal sealed class SchoolWorker
private Roster? _rosterSnapshot;
private ApplicantPool? _applicantSnapshot;
private DefCatalog? _catalogSnapshot;
private Timetable? _timetableSnapshot;
private OccupancyKey _occupancyKey;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
@@ -103,6 +106,9 @@ internal sealed class SchoolWorker
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
/// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot);
public void Start()
{
_run = Task.Factory.StartNew(
@@ -284,12 +290,17 @@ internal sealed class SchoolWorker
if (peopleChanged)
{
PersistPeople();
if (school.TimetableDirty)
{
RebuildTimetable(school);
}
}
if (steps > 0)
{
PublishSnapshot();
BroadcastClock();
MaybeBroadcastOccupancy(school);
}
FlushSettings();
@@ -402,6 +413,16 @@ internal sealed class SchoolWorker
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(
ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period));
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
break;
}
}
catch (Exception ex)
@@ -443,6 +464,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(Staffing.UnknownSchool());
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
}
}
@@ -462,6 +489,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetException(exception);
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetException(exception);
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetException(exception);
break;
}
}
@@ -491,7 +524,7 @@ internal sealed class SchoolWorker
{
school.ApplyStaffing(outcome.Roster, outcome.Pool);
PersistPeople();
PublishSnapshot();
RebuildTimetable(school);
}
return outcome;
@@ -531,6 +564,7 @@ internal sealed class SchoolWorker
(byte)school.Clock.SpeedIndex));
Volatile.Write(ref _rosterSnapshot, school.Roster);
Volatile.Write(ref _applicantSnapshot, school.Applicants);
Volatile.Write(ref _timetableSnapshot, school.Timetable);
}
/// <summary>
@@ -555,6 +589,238 @@ internal sealed class SchoolWorker
}
}
private void InstallTimetable(School school)
{
if (!_isNew)
{
var saved = _store.TryReadTimetable(_id);
if (saved is not null)
{
var restored = RestoreTimetable(school, saved);
school.SetTimetable(restored);
if (!saved.Lessons.SequenceEqual(restored.Lessons)
|| !saved.Uncovered.SequenceEqual(restored.Uncovered))
{
PersistTimetable(school);
}
RememberOccupancy(school);
return;
}
}
RebuildTimetable(school, broadcast: false);
}
private Timetable RestoreTimetable(School school, Timetable saved)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return saved;
}
var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
var valid = saved.Lessons
.Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId))
.ToArray();
if (valid.Length == saved.Lessons.Count)
{
return saved;
}
var locks = valid.Where(lesson => lesson.Locked).ToArray();
return SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks,
_options.SchoolWeekDays);
}
private void RebuildTimetable(School school, bool broadcast = true)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return;
}
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
ApplyTable(
school,
SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays),
broadcast);
}
private TimetableOutcome ApplyPin(
School school,
string classId,
string subject,
string roomId,
int day,
int period)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
}
if (school.Roster.Classes.All(item => item.Id != classId))
{
return TimetableOutcome.Fail(TimetableError.UnknownClass);
}
if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract)
{
return TimetableOutcome.Fail(TimetableError.UnknownSubject);
}
if (school.Map.Rooms.All(room => room.Id != roomId))
{
return TimetableOutcome.Fail(TimetableError.UnknownRoom);
}
var teacherId = TeacherFor(school, classId, subject);
if (teacherId is null)
{
return TimetableOutcome.Fail(TimetableError.NoTeacher);
}
var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true);
var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? [])
.Where(lesson => lesson.ClassId != classId || lesson.Subject != subject
|| lesson.Day != day || lesson.Period != period)
.Append(pin)
.ToArray();
var table = SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks,
_options.SchoolWeekDays);
if (!table.Lessons.Any(lesson =>
lesson.Locked
&& lesson.ClassId == classId
&& lesson.Subject == subject
&& lesson.RoomId == roomId
&& lesson.Day == day
&& lesson.Period == period))
{
return TimetableOutcome.Fail(TimetableError.PinRejected);
}
ApplyTable(school, table, broadcast: true);
return TimetableOutcome.Ok(table);
}
private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
}
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
var match = locks.FirstOrDefault(lesson =>
lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period);
if (match is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownLesson);
}
var next = SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks.Where(lesson => lesson != match).ToArray(),
_options.SchoolWeekDays);
ApplyTable(school, next, broadcast: true);
return TimetableOutcome.Ok(next);
}
private static string? TeacherFor(School school, string classId, string subject)
{
var existing = school.Timetable?.Lessons.FirstOrDefault(lesson =>
lesson.ClassId == classId && lesson.Subject == subject);
if (existing is not null)
{
return existing.TeacherId;
}
return school.Roster?.People
.Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal))
.OrderBy(person => person.Id, StringComparer.Ordinal)
.Select(person => person.Id)
.FirstOrDefault();
}
private void ApplyTable(School school, Timetable table, bool broadcast)
{
school.SetTimetable(table);
PersistTimetable(school);
PublishSnapshot();
if (broadcast)
{
MaybeBroadcastOccupancy(school, force: true);
}
else
{
RememberOccupancy(school);
}
}
/// <summary>
/// Writes the lesson table. Not called from the 30-second clock save — the table changes on
/// hire, unassign, pin and yearly intake, not every tick.
/// </summary>
private void PersistTimetable(School school)
{
if (school.Timetable is null)
{
return;
}
try
{
_store.SaveTimetable(school.Id, school.Timetable);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id);
}
}
private void RememberOccupancy(School school)
{
if (school.Catalog is not null)
{
_occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
}
}
private void MaybeBroadcastOccupancy(School school, bool force = false)
{
if (school.Catalog is null)
{
return;
}
var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
if (!force && key == _occupancyKey)
{
return;
}
_occupancyKey = key;
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
SendMapSnapshot(client, school);
}
}
}
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
{
var nameSetId = ResolveNameSetId(catalog, _nameSetId);
@@ -609,6 +875,7 @@ internal sealed class SchoolWorker
}
school.InstallPeople(roster, seed, nameSetId, applicants);
InstallTimetable(school);
return generated;
}
@@ -708,6 +975,8 @@ internal sealed class SchoolWorker
node.Positions);
}
MapOccupancy.Apply(nodes, school, _options.SchoolWeekDays, locale);
// 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);
+15
View File
@@ -37,4 +37,19 @@ internal abstract record WorkerCommand
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record PinLesson(
string ClassId,
string Subject,
string RoomId,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
internal sealed record UnpinLesson(
string ClassId,
string Subject,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
}
+1
View File
@@ -13,6 +13,7 @@
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
+1
View File
@@ -51,6 +51,7 @@ app.UseWebSockets(new WebSocketOptions
});
app.MapSchoolEndpoints();
app.MapTimetableEndpoints();
app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>