Update wire protocol to version 7 and enhance presence management features
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 15s

- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure.
- Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states.
- Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution.
- Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream.
- Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity.
- Updated documentation to reflect the new protocol features and changes in presence management.
- Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
Leonid Pershin
2026-08-19 17:00:53 +03:00
parent 4137400621
commit 3c54f981b7
31 changed files with 1025 additions and 373 deletions
+2
View File
@@ -14,10 +14,12 @@ public enum MessageType : byte
ClientCloseSchool = 0x04,
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ClientSkipEmpty = 0x07,
ServerWelcome = 0x81,
ServerPong = 0x82,
ServerClock = 0x83,
ServerSchoolGone = 0x84,
ServerMapSnapshot = 0x85,
ServerPresence = 0x86,
}
+38 -13
View File
@@ -31,12 +31,16 @@ public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTi
/// State of the open school's calendar, sent every tick.
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
/// interpreted as UTC — the game calendar has no time zone.
/// <paramref name="SkipAllowed"/> is the server's verdict; the client must not recompute it.
/// <paramref name="SkipTargetUnixMs"/> is 0 when skip is refused.
/// </summary>
public readonly record struct ServerClockMessage(
int SchoolId,
long GameTimeUnixMs,
bool Running,
byte SpeedIndex);
byte SpeedIndex,
bool SkipAllowed = false,
long SkipTargetUnixMs = 0);
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
@@ -45,9 +49,7 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId);
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
/// <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.
/// on the server, not by the client. Live occupancy lives on <see cref="ServerPresenceMessage"/>.
/// </summary>
public sealed record MapSnapshotNode(
byte Kind,
@@ -56,19 +58,42 @@ public sealed record MapSnapshotNode(
string Name,
ushort PupilSlots,
IReadOnlyList<MapSnapshotItem> Items,
IReadOnlyList<string> Positions,
string ActivitySubject = "",
string ActivityClass = "",
IReadOnlyList<string>? Characters = null)
{
public IReadOnlyList<string> Present => Characters ?? [];
}
IReadOnlyList<string> Positions);
/// <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 when that school is opened and again
/// when the current lesson slot changes. Occupancy is computed from the timetable and the clock.
/// One school's map, labelled in the Hello locale. Sent once when that school is opened.
/// Structure only — people and the current lesson ride the presence stream.
/// </summary>
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
/// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary>
public readonly record struct ClientSkipEmptyMessage;
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
public static class PresenceState
{
public const byte Here = 1;
public const byte Walking = 2;
}
/// <summary>One occupied (or currently taught) map node in a presence frame.</summary>
public sealed record PresenceNode(
string Id,
ushort Count,
string ActivitySubject = "",
string ActivityClass = "");
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary>
public sealed record PresencePerson(string Id, string NodeId, byte State);
/// <summary>
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole
/// map; the client filters to the selected tree node.
/// </summary>
public sealed record ServerPresenceMessage(
int SchoolId,
IReadOnlyList<PresenceNode> Nodes,
IReadOnlyList<PresencePerson> People);
+97 -44
View File
@@ -10,10 +10,10 @@ namespace HSchool.Protocol;
public static class ProtocolCodec
{
/// <summary>
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots use
/// <see cref="ProtocolConstants.MaxMessageSize"/> instead.
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots and
/// presence frames use <see cref="ProtocolConstants.MaxMessageSize"/> instead.
/// </summary>
public const int MaxFrameSize = 16;
public const int MaxFrameSize = 24;
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
{
@@ -63,6 +63,13 @@ public static class ProtocolCodec
return writer.Position;
}
public static int WriteSkipEmpty(Span<byte> destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSkipEmpty);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
@@ -90,6 +97,8 @@ public static class ProtocolCodec
writer.WriteInt64(message.GameTimeUnixMs);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
writer.WriteByte(message.SpeedIndex);
writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0);
writer.WriteInt64(message.SkipTargetUnixMs);
return writer.Position;
}
@@ -129,18 +138,6 @@ 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;
@@ -160,9 +157,9 @@ public static class ProtocolCodec
foreach (var node in message.Nodes)
{
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue || node.Present.Count > byte.MaxValue)
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue)
{
throw new ProtocolException($"Map node '{node.Id}' has too many items, positions or people for a u8 count.");
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
}
writer.WriteByte(node.Kind);
@@ -182,8 +179,53 @@ public static class ProtocolCodec
{
writer.WriteString(position);
}
}
if (HasActivity(node))
return writer.Position;
}
public static int PresenceSize(ServerPresenceMessage message)
{
var size = sizeof(byte) + sizeof(int) + sizeof(ushort);
foreach (var node in message.Nodes)
{
size += StringSize(node.Id) + sizeof(ushort) + sizeof(byte);
if (HasPresenceActivity(node))
{
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
}
}
size += sizeof(ushort);
foreach (var person in message.People)
{
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte);
}
return size;
}
public static int WritePresence(Span<byte> destination, ServerPresenceMessage message)
{
if (message.Nodes.Count > ushort.MaxValue)
{
throw new ProtocolException($"Presence has {message.Nodes.Count} nodes; u16 count cannot hold it.");
}
if (message.People.Count > ushort.MaxValue)
{
throw new ProtocolException($"Presence has {message.People.Count} people; u16 count cannot hold it.");
}
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerPresence);
writer.WriteInt32(message.SchoolId);
writer.WriteUInt16((ushort)message.Nodes.Count);
foreach (var node in message.Nodes)
{
writer.WriteString(node.Id);
writer.WriteUInt16(node.Count);
if (HasPresenceActivity(node))
{
writer.WriteByte(1);
writer.WriteString(node.ActivitySubject);
@@ -193,12 +235,14 @@ public static class ProtocolCodec
{
writer.WriteByte(0);
}
}
writer.WriteByte((byte)node.Present.Count);
foreach (var person in node.Present)
{
writer.WriteString(person);
}
writer.WriteUInt16((ushort)message.People.Count);
foreach (var person in message.People)
{
writer.WriteString(person.Id);
writer.WriteString(person.NodeId);
writer.WriteByte(person.State);
}
return writer.Position;
@@ -271,7 +315,9 @@ public static class ProtocolCodec
var gameTime = reader.ReadInt64();
var running = reader.ReadByte() != 0;
var speedIndex = reader.ReadByte();
return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
var skipAllowed = reader.ReadByte() != 0;
var skipTarget = reader.ReadInt64();
return new ServerClockMessage(schoolId, gameTime, running, speedIndex, skipAllowed, skipTarget);
}
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
@@ -312,6 +358,23 @@ public static class ProtocolCodec
positions[position] = reader.ReadString();
}
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions);
}
return new ServerMapSnapshotMessage(schoolId, nodes);
}
public static ServerPresenceMessage ReadPresence(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerPresence);
var schoolId = reader.ReadInt32();
var nodeCount = reader.ReadUInt16();
var nodes = new PresenceNode[nodeCount];
for (var i = 0; i < nodeCount; i++)
{
var id = reader.ReadString();
var count = reader.ReadUInt16();
var hasActivity = reader.ReadByte() != 0;
var activitySubject = "";
var activityClass = "";
@@ -321,30 +384,20 @@ public static class ProtocolCodec
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);
nodes[i] = new PresenceNode(id, count, activitySubject, activityClass);
}
return new ServerMapSnapshotMessage(schoolId, nodes);
var personCount = reader.ReadUInt16();
var people = new PresencePerson[personCount];
for (var i = 0; i < personCount; i++)
{
people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte());
}
return new ServerPresenceMessage(schoolId, nodes, people);
}
private static bool HasActivity(MapSnapshotNode node) =>
private static bool HasPresenceActivity(PresenceNode node) =>
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
+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 = 6;
public const byte Version = 7;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;