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);
}
}