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
+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>