Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+1
View File
@@ -19,4 +19,5 @@ public enum MessageType : byte
ServerPong = 0x82,
ServerClock = 0x83,
ServerSchoolGone = 0x84,
ServerMapSnapshot = 0x85,
}
+23 -2
View File
@@ -1,7 +1,10 @@
namespace HSchool.Protocol;
/// <summary>First frame from the client; carries nothing but the version handshake.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion);
/// <summary>
/// First frame from the client. <paramref name="Locale"/> is <see cref="ProtocolConstants.LocaleRussian"/>
/// or <see cref="ProtocolConstants.LocaleEnglish"/> — the same language the catalog HTTP API uses.
/// </summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion, byte Locale);
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
public readonly record struct ClientPingMessage(long ClientTimeMs);
@@ -37,3 +40,21 @@ public readonly record struct ServerClockMessage(
/// <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);
/// <summary>
/// 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.
/// </summary>
public sealed record MapSnapshotNode(
byte Kind,
string Id,
string ParentId,
string Name,
IReadOnlyList<string> Items,
IReadOnlyList<string> Positions);
/// <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.
/// </summary>
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
+19
View File
@@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
@@ -22,6 +23,14 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
public MessageType ReadMessageType() => (MessageType)ReadByte();
public ushort ReadUInt16()
{
EnsureAvailable(sizeof(ushort));
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
_position += sizeof(ushort);
return value;
}
public uint ReadUInt32()
{
EnsureAvailable(sizeof(uint));
@@ -30,6 +39,16 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
return value;
}
/// <summary><c>u16</c> byte length, then UTF-8. Empty string is a zero length.</summary>
public string ReadString()
{
var byteCount = ReadUInt16();
EnsureAvailable(byteCount);
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
_position += byteCount;
return value;
}
public int ReadInt32()
{
EnsureAvailable(sizeof(int));
+23
View File
@@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
@@ -22,6 +23,13 @@ public ref struct PacketWriter(Span<byte> buffer)
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt16(ushort value)
{
EnsureRoom(sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
_position += sizeof(ushort);
}
public void WriteUInt32(uint value)
{
EnsureRoom(sizeof(uint));
@@ -29,6 +37,21 @@ public ref struct PacketWriter(Span<byte> buffer)
_position += sizeof(uint);
}
/// <summary><c>u16</c> byte length, then UTF-8. Empty string is a zero length.</summary>
public void WriteString(string value)
{
var byteCount = Encoding.UTF8.GetByteCount(value);
if (byteCount > ushort.MaxValue)
{
throw new ProtocolException($"String is {byteCount} bytes; u16 length cannot hold it.");
}
WriteUInt16((ushort)byteCount);
EnsureRoom(byteCount);
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
_position += byteCount;
}
public void WriteInt32(int value)
{
EnsureRoom(sizeof(int));
+81 -2
View File
@@ -7,7 +7,10 @@ namespace HSchool.Protocol;
/// </summary>
public static class ProtocolCodec
{
/// <summary>Largest frame this codec produces; handlers can size their buffers from it.</summary>
/// <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)
@@ -15,6 +18,7 @@ public static class ProtocolCodec
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion);
writer.WriteByte(message.Locale);
return writer.Position;
}
@@ -95,6 +99,45 @@ public static class ProtocolCodec
return writer.Position;
}
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)
{
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
}
writer.WriteByte(node.Kind);
writer.WriteString(node.Id);
writer.WriteString(node.ParentId);
writer.WriteString(node.Name);
writer.WriteByte((byte)node.Items.Count);
foreach (var item in node.Items)
{
writer.WriteString(item);
}
writer.WriteByte((byte)node.Positions.Count);
foreach (var position in node.Positions)
{
writer.WriteString(position);
}
}
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -102,7 +145,9 @@ public static class ProtocolCodec
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello);
return new ClientHelloMessage(reader.ReadByte());
var version = reader.ReadByte();
var locale = reader.ReadByte();
return new ClientHelloMessage(version, locale);
}
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
@@ -170,6 +215,40 @@ public static class ProtocolCodec
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 itemCount = reader.ReadByte();
var items = new string[itemCount];
for (var item = 0; item < itemCount; item++)
{
items[item] = reader.ReadString();
}
var positionCount = reader.ReadByte();
var positions = new string[positionCount];
for (var position = 0; position < positionCount; position++)
{
positions[position] = reader.ReadString();
}
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, items, positions);
}
return new ServerMapSnapshotMessage(schoolId, nodes);
}
private static void Expect(ref PacketReader reader, MessageType expected)
{
var actual = reader.ReadMessageType();
+11 -1
View File
@@ -4,8 +4,18 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 3;
public const byte Version = 4;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;
/// <summary>Hello locale byte: Russian. Any other value that is not <see cref="LocaleEnglish"/> is treated as this.</summary>
public const byte LocaleRussian = 0;
/// <summary>Hello locale byte: English. Same value the catalog HTTP API takes as <c>lang=en</c>.</summary>
public const byte LocaleEnglish = 1;
/// <summary>Catalog locale string matching the Hello byte. Unknown bytes fall back to Russian.</summary>
public static string CatalogLocale(byte locale) =>
locale == LocaleEnglish ? "en" : "ru";
}