Files
h-school/src/HSchool.Protocol/ProtocolCodec.cs
T
Leonid Pershin cad3068bac
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s
Update wire protocol to version 6 and enhance timetable functionality
- 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.
2026-08-19 10:05:05 +03:00

362 lines
13 KiB
C#

using System.Text;
namespace HSchool.Protocol;
/// <summary>
/// The single place where the wire format is defined on the .NET side.
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
/// and documented in <c>docs/protocol.md</c>.
/// </summary>
public static class ProtocolCodec
{
/// <summary>
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots use
/// <see cref="ProtocolConstants.MaxMessageSize"/> instead.
/// </summary>
public const int MaxFrameSize = 16;
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion);
writer.WriteByte(message.Locale);
return writer.Position;
}
public static int WritePing(Span<byte> destination, in ClientPingMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientPing);
writer.WriteInt64(message.ClientTimeMs);
return writer.Position;
}
public static int WriteOpenSchool(Span<byte> destination, in ClientOpenSchoolMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientOpenSchool);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
public static int WriteCloseSchool(Span<byte> destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientCloseSchool);
return writer.Position;
}
public static int WriteSetRunning(Span<byte> destination, in ClientSetRunningMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetRunning);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
return writer.Position;
}
public static int WriteSetSpeed(Span<byte> destination, in ClientSetSpeedMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetSpeed);
writer.WriteByte(message.SpeedIndex);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerWelcome);
writer.WriteByte(message.ProtocolVersion);
writer.WriteByte(message.TickRate);
writer.WriteByte(message.MaxSchools);
return writer.Position;
}
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerPong);
writer.WriteInt64(message.ClientTimeMs);
writer.WriteUInt32(message.ServerTick);
return writer.Position;
}
public static int WriteClock(Span<byte> destination, in ServerClockMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerClock);
writer.WriteInt32(message.SchoolId);
writer.WriteInt64(message.GameTimeUnixMs);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
writer.WriteByte(message.SpeedIndex);
return writer.Position;
}
public static int WriteSchoolGone(Span<byte> destination, in ServerSchoolGoneMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSchoolGone);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
/// <summary>
/// Bytes <see cref="WriteMapSnapshot"/> needs for this message.
///
/// A school's map has no fixed size — the create editor lets a player add rooms — so a
/// snapshot can outgrow <see cref="ProtocolConstants.MaxMessageSize"/>. That limit guards
/// what the server *reads*; callers size an outbound snapshot from the message itself.
/// </summary>
public static int MapSnapshotSize(ServerMapSnapshotMessage message)
{
var size = sizeof(byte) + sizeof(int) + sizeof(ushort);
foreach (var node in message.Nodes)
{
size += sizeof(byte);
size += StringSize(node.Id) + StringSize(node.ParentId) + StringSize(node.Name);
size += sizeof(ushort);
size += sizeof(byte);
foreach (var item in node.Items)
{
size += StringSize(item.Name) + sizeof(byte);
}
size += sizeof(byte);
foreach (var position in node.Positions)
{
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;
}
public static int WriteMapSnapshot(Span<byte> destination, ServerMapSnapshotMessage message)
{
if (message.Nodes.Count > ushort.MaxValue)
{
throw new ProtocolException($"Map snapshot has {message.Nodes.Count} nodes; u16 count cannot hold it.");
}
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerMapSnapshot);
writer.WriteInt32(message.SchoolId);
writer.WriteUInt16((ushort)message.Nodes.Count);
foreach (var node in message.Nodes)
{
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, positions or people for a u8 count.");
}
writer.WriteByte(node.Kind);
writer.WriteString(node.Id);
writer.WriteString(node.ParentId);
writer.WriteString(node.Name);
writer.WriteUInt16(node.PupilSlots);
writer.WriteByte((byte)node.Items.Count);
foreach (var item in node.Items)
{
writer.WriteString(item.Name);
writer.WriteByte(item.Count);
}
writer.WriteByte((byte)node.Positions.Count);
foreach (var position in node.Positions)
{
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;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello);
var version = reader.ReadByte();
var locale = reader.ReadByte();
return new ClientHelloMessage(version, locale);
}
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientPing);
return new ClientPingMessage(reader.ReadInt64());
}
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientOpenSchool);
return new ClientOpenSchoolMessage(reader.ReadInt32());
}
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetRunning);
return new ClientSetRunningMessage(reader.ReadByte() != 0);
}
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetSpeed);
return new ClientSetSpeedMessage(reader.ReadByte());
}
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerWelcome);
var version = reader.ReadByte();
var tickRate = reader.ReadByte();
var maxSchools = reader.ReadByte();
return new ServerWelcomeMessage(version, tickRate, maxSchools);
}
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerPong);
var clientTime = reader.ReadInt64();
var serverTick = reader.ReadUInt32();
return new ServerPongMessage(clientTime, serverTick);
}
public static ServerClockMessage ReadClock(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerClock);
var schoolId = reader.ReadInt32();
var gameTime = reader.ReadInt64();
var running = reader.ReadByte() != 0;
var speedIndex = reader.ReadByte();
return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
}
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSchoolGone);
return new ServerSchoolGoneMessage(reader.ReadInt32());
}
public static ServerMapSnapshotMessage ReadMapSnapshot(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerMapSnapshot);
var schoolId = reader.ReadInt32();
var nodeCount = reader.ReadUInt16();
var nodes = new MapSnapshotNode[nodeCount];
for (var i = 0; i < nodeCount; i++)
{
var kind = reader.ReadByte();
var id = reader.ReadString();
var parentId = reader.ReadString();
var name = reader.ReadString();
var pupilSlots = reader.ReadUInt16();
var itemCount = reader.ReadByte();
var items = new MapSnapshotItem[itemCount];
for (var item = 0; item < itemCount; item++)
{
var itemName = reader.ReadString();
var count = reader.ReadByte();
items[item] = new MapSnapshotItem(itemName, count);
}
var positionCount = reader.ReadByte();
var positions = new string[positionCount];
for (var position = 0; position < positionCount; position++)
{
positions[position] = reader.ReadString();
}
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);
private static void Expect(ref PacketReader reader, MessageType expected)
{
var actual = reader.ReadMessageType();
if (actual != expected)
{
throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}.");
}
}
}