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
+11 -3
View File
@@ -46,6 +46,8 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId);
/// <paramref name="ParentId"/> is empty for the yard.
/// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things
/// on the server, not by the client.
/// <paramref name="ActivitySubject"/> and <paramref name="ActivityClass"/> are empty when the
/// room is free. <paramref name="Characters"/> are the people the timetable puts there right now.
/// </summary>
public sealed record MapSnapshotNode(
byte Kind,
@@ -54,13 +56,19 @@ public sealed record MapSnapshotNode(
string Name,
ushort PupilSlots,
IReadOnlyList<MapSnapshotItem> Items,
IReadOnlyList<string> Positions);
IReadOnlyList<string> Positions,
string ActivitySubject = "",
string ActivityClass = "",
IReadOnlyList<string>? Characters = null)
{
public IReadOnlyList<string> Present => Characters ?? [];
}
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1255.</summary>
public sealed record MapSnapshotItem(string Name, byte Count);
/// <summary>
/// One school's map, labelled in the Hello locale. Sent once when that school is opened, not every tick.
/// People and in-place activities are omitted — the client keeps those sections empty.
/// One school's map, labelled in the Hello locale. Sent when that school is opened and again
/// when the current lesson slot changes. Occupancy is computed from the timetable and the clock.
/// </summary>
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
+61 -3
View File
@@ -129,6 +129,18 @@ public static class ProtocolCodec
{
size += StringSize(position);
}
size += sizeof(byte);
if (HasActivity(node))
{
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
}
size += sizeof(byte);
foreach (var person in node.Present)
{
size += StringSize(person);
}
}
return size;
@@ -148,9 +160,9 @@ public static class ProtocolCodec
foreach (var node in message.Nodes)
{
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue)
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue || node.Present.Count > byte.MaxValue)
{
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
throw new ProtocolException($"Map node '{node.Id}' has too many items, positions or people for a u8 count.");
}
writer.WriteByte(node.Kind);
@@ -170,6 +182,23 @@ public static class ProtocolCodec
{
writer.WriteString(position);
}
if (HasActivity(node))
{
writer.WriteByte(1);
writer.WriteString(node.ActivitySubject);
writer.WriteString(node.ActivityClass);
}
else
{
writer.WriteByte(0);
}
writer.WriteByte((byte)node.Present.Count);
foreach (var person in node.Present)
{
writer.WriteString(person);
}
}
return writer.Position;
@@ -283,12 +312,41 @@ public static class ProtocolCodec
positions[position] = reader.ReadString();
}
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions);
var hasActivity = reader.ReadByte() != 0;
var activitySubject = "";
var activityClass = "";
if (hasActivity)
{
activitySubject = reader.ReadString();
activityClass = reader.ReadString();
}
var characterCount = reader.ReadByte();
var characters = new string[characterCount];
for (var person = 0; person < characterCount; person++)
{
characters[person] = reader.ReadString();
}
nodes[i] = new MapSnapshotNode(
kind,
id,
parentId,
name,
pupilSlots,
items,
positions,
activitySubject,
activityClass,
characters);
}
return new ServerMapSnapshotMessage(schoolId, nodes);
}
private static bool HasActivity(MapSnapshotNode node) =>
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
private static int StringSize(string value) => sizeof(ushort) + Encoding.UTF8.GetByteCount(value);
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 5;
public const byte Version = 6;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;