From 1a21a64b4b6feb667d9f5611081b7ba076940ab4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:42:11 +0300 Subject: [PATCH] Bump protocol to v9 and carry talk-circle ids on the presence frame. Names stay off the wire; a person not in a circle writes an empty member list and topic. Co-authored-by: Cursor --- docs/protocol.md | 15 ++- src/HSchool.Client/src/net/protocol.test.ts | 126 +++++++++++++++++- src/HSchool.Client/src/net/protocol.ts | 23 +++- src/HSchool.Protocol/Messages.cs | 18 ++- src/HSchool.Protocol/ProtocolCodec.cs | 34 ++++- src/HSchool.Protocol/ProtocolConstants.cs | 2 +- .../ProtocolCodecTests.cs | 113 ++++++++++++++++ 7 files changed, 321 insertions(+), 10 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 392d8e0..b6d8a55 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,4 +1,4 @@ -# Wire protocol v8 +# Wire protocol v9 The client talks to the server two ways: @@ -308,6 +308,9 @@ grid fetches `GET .../timetable?classId=` with it. `activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` is that def in the request locale. HTTP JSON is additive — no protocol version bump. +`talkCircleMemberIds` is the live circle (including self, sorted) or `[]`; `talkTopicId` is the +topic def id or `null` when not talking. Same ids as the presence frame; names are not repeated +here either — the Now tab uses the directory and locale, like the location panel. `skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has no Chemistry; a related tongue from the name set may sit beside the native at a low value. @@ -347,6 +350,8 @@ The client does not compute thresholds. There is no school-wide opinions endpoin "needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }], "activity": null, "activityLabel": null, + "talkCircleMemberIds": [], + "talkTopicId": null, "family": { "parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }], "children": [], @@ -972,6 +977,12 @@ Each person: | string | person id | | string | node id they occupy | | `u8` | `1` here, `2` walking | +| `u8` | talk-circle member count, then that many person-id strings | +| string | topic id (empty when not in a circle) | + +Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean +the person is not talking — the same id/node/state as before the circle fields. Names are not +on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale. ## Guarantees and limits @@ -986,7 +997,7 @@ Each person: the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out. -## Not in v8 yet +## Not in v9 yet Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server — the tree and the location panel are filtered on the client from the snapshot plus presence. diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 7a90d4c..c054cba 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -262,7 +262,9 @@ describe('decodeServerMessage', () => { + 2 + 2 + personId.length + 2 + personNode.length - + 1, + + 1 + + 1 + + 2, ); const view = new DataView(buffer); view.setUint8(0, MessageType.ServerPresence); @@ -296,6 +298,10 @@ describe('decodeServerMessage', () => { new Uint8Array(buffer).set(personNode, offset); offset += personNode.length; view.setUint8(offset, PresenceState.Here); + offset += 1; + view.setUint8(offset, 0); + offset += 1; + view.setUint16(offset, 0, true); expect(decodeServerMessage(buffer)).toEqual({ type: 'presence', @@ -308,7 +314,123 @@ describe('decodeServerMessage', () => { activityClass: '5А', }, ], - people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }], + people: [ + { + id: 'f0.c0', + nodeId: 'classroom-101', + state: PresenceState.Here, + talkMemberIds: [], + talkTopicId: '', + }, + ], + }); + }); + + it('reads a presence person with talk-circle member ids and topic id', () => { + const encoder = new TextEncoder(); + const putString = (target: DataView, at: number, text: Uint8Array): number => { + target.setUint16(at, text.length, true); + new Uint8Array(target.buffer).set(text, at + 2); + return at + 2 + text.length; + }; + + const corridor = encoder.encode('corridor-1'); + const selfId = encoder.encode('f0.c0'); + const partnerId = encoder.encode('f0.c1'); + const topic = encoder.encode('TopicSport'); + const buffer = new ArrayBuffer( + 7 + + 2 + + 2 + + 2 + selfId.length + + 2 + corridor.length + + 1 + + 1 + + 2 + selfId.length + + 2 + partnerId.length + + 2 + topic.length, + ); + const view = new DataView(buffer); + view.setUint8(0, MessageType.ServerPresence); + view.setInt32(1, 1, true); + view.setUint16(5, 0, true); + view.setUint16(7, 1, true); + let offset = 9; + offset = putString(view, offset, selfId); + offset = putString(view, offset, corridor); + view.setUint8(offset, PresenceState.Here); + offset += 1; + view.setUint8(offset, 2); + offset += 1; + offset = putString(view, offset, selfId); + offset = putString(view, offset, partnerId); + putString(view, offset, topic); + + expect(decodeServerMessage(buffer)).toEqual({ + type: 'presence', + schoolId: 1, + nodes: [], + people: [ + { + id: 'f0.c0', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: ['f0.c0', 'f0.c1'], + talkTopicId: 'TopicSport', + }, + ], + }); + }); + + it('does not carry a person display name in a presence frame', () => { + const encoder = new TextEncoder(); + const putString = (target: DataView, at: number, text: Uint8Array): number => { + target.setUint16(at, text.length, true); + new Uint8Array(target.buffer).set(text, at + 2); + return at + 2 + text.length; + }; + + const corridor = encoder.encode('corridor-1'); + const selfId = encoder.encode('f0.c0'); + const topic = encoder.encode('TopicSport'); + const buffer = new ArrayBuffer( + 7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length, + ); + const view = new DataView(buffer); + view.setUint8(0, MessageType.ServerPresence); + view.setInt32(1, 1, true); + view.setUint16(5, 0, true); + view.setUint16(7, 1, true); + let offset = 9; + offset = putString(view, offset, selfId); + offset = putString(view, offset, corridor); + view.setUint8(offset, PresenceState.Here); + offset += 1; + view.setUint8(offset, 1); + offset += 1; + offset = putString(view, offset, selfId); + putString(view, offset, topic); + + const bytes = new Uint8Array(buffer); + const text = new TextDecoder().decode(bytes); + expect(text).not.toContain('Мария'); + expect(text).not.toContain('Иванова'); + expect(text).not.toContain('Маша'); + expect(text).toContain('f0.c0'); + expect(text).toContain('TopicSport'); + expect(decodeServerMessage(buffer)).toEqual({ + type: 'presence', + schoolId: 1, + nodes: [], + people: [ + { + id: 'f0.c0', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: ['f0.c0'], + talkTopicId: 'TopicSport', + }, + ], }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 2b00a7b..950da5e 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -5,7 +5,7 @@ * changed together and documented in `docs/protocol.md`. All numbers are little-endian. */ -export const PROTOCOL_VERSION = 8; +export const PROTOCOL_VERSION = 9; export const MessageType = { ClientHello: 0x01, @@ -122,6 +122,8 @@ export interface PresencePerson { readonly id: string; readonly nodeId: string; readonly state: number; + readonly talkMemberIds: readonly string[]; + readonly talkTopicId: string; } export interface PresenceMessage { @@ -375,7 +377,24 @@ function decodePresence(view: DataView): PresenceMessage { offset = nodeId.next; const state = readU8(view, offset); offset += 1; - people.push({ id: id.text, nodeId: nodeId.text, state }); + const memberCount = readU8(view, offset); + offset += 1; + const talkMemberIds: string[] = []; + for (let member = 0; member < memberCount; member++) { + const memberId = readString(view, offset); + offset = memberId.next; + talkMemberIds.push(memberId.text); + } + + const topic = readString(view, offset); + offset = topic.next; + people.push({ + id: id.text, + nodeId: nodeId.text, + state, + talkMemberIds, + talkTopicId: topic.text, + }); } return { type: 'presence', schoolId, nodes, people }; diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index 1c92079..81939a5 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -98,8 +98,22 @@ public sealed record PresenceNode( string ActivitySubject = "", string ActivityClass = ""); -/// One on-campus person. Names are resolved over HTTP, not on this frame. -public sealed record PresencePerson(string Id, string NodeId, byte State); +/// +/// One on-campus person. Names are resolved over HTTP, not on this frame. Talk members and +/// topic are ids; an empty list means the person is not in a circle. +/// +public sealed record PresencePerson( + string Id, + string NodeId, + byte State, + IReadOnlyList TalkMemberIds, + string TalkTopicId) +{ + public PresencePerson(string id, string nodeId, byte state) + : this(id, nodeId, state, [], "") + { + } +} /// /// Live occupancy of an open school, about twice a second. Counts and people cover the whole diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index 34024fb..5eae532 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -202,6 +202,14 @@ public static class ProtocolCodec foreach (var person in message.People) { size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte); + var members = person.TalkMemberIds ?? []; + size += sizeof(byte); + foreach (var memberId in members) + { + size += StringSize(memberId); + } + + size += StringSize(person.TalkTopicId ?? ""); } return size; @@ -242,9 +250,22 @@ public static class ProtocolCodec writer.WriteUInt16((ushort)message.People.Count); foreach (var person in message.People) { + var members = person.TalkMemberIds ?? []; + if (members.Count > byte.MaxValue) + { + throw new ProtocolException($"Presence person {person.Id} has {members.Count} talk members; u8 count cannot hold it."); + } + writer.WriteString(person.Id); writer.WriteString(person.NodeId); writer.WriteByte(person.State); + writer.WriteByte((byte)members.Count); + foreach (var memberId in members) + { + writer.WriteString(memberId); + } + + writer.WriteString(person.TalkTopicId ?? ""); } return writer.Position; @@ -403,7 +424,18 @@ public static class ProtocolCodec var people = new PresencePerson[personCount]; for (var i = 0; i < personCount; i++) { - people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte()); + var id = reader.ReadString(); + var nodeId = reader.ReadString(); + var state = reader.ReadByte(); + var memberCount = reader.ReadByte(); + var members = new string[memberCount]; + for (var member = 0; member < memberCount; member++) + { + members[member] = reader.ReadString(); + } + + var topicId = reader.ReadString(); + people[i] = new PresencePerson(id, nodeId, state, members, topicId); } return new ServerPresenceMessage(schoolId, nodes, people); diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index 6abec88..ba1f9d8 100644 --- a/src/HSchool.Protocol/ProtocolConstants.cs +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -4,7 +4,7 @@ namespace HSchool.Protocol; public static class ProtocolConstants { /// Bumped on every breaking change to the binary layout. - public const byte Version = 8; + public const byte Version = 9; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index 0f032de..aca3f99 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -246,7 +246,120 @@ public class ProtocolCodecTests Assert.Equal("f0.c0", read.People[0].Id); Assert.Equal("classroom-101", read.People[0].NodeId); Assert.Equal(PresenceState.Here, read.People[0].State); + Assert.Empty(read.People[0].TalkMemberIds); + Assert.Equal("", read.People[0].TalkTopicId); Assert.Equal(PresenceState.Walking, read.People[1].State); + Assert.Empty(read.People[1].TalkMemberIds); + } + + [Fact] + public void Presence_PersonWithoutCircle_WritesEmptyMemberListAndEmptyTopic() + { + var message = new ServerPresenceMessage( + 1, + [], + [new PresencePerson("f0.c0", "corridor-1", PresenceState.Here)]); + var buffer = new byte[ProtocolCodec.PresenceSize(message)]; + + var length = ProtocolCodec.WritePresence(buffer, message); + + Assert.Equal((byte)MessageType.ServerPresence, buffer[0]); + Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4))); + Assert.Equal((ushort)0, BitConverter.ToUInt16(buffer.AsSpan(5, 2))); + Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(7, 2))); + + var offset = 9; + offset = AssertWireString(buffer, offset, "f0.c0"); + offset = AssertWireString(buffer, offset, "corridor-1"); + Assert.Equal(PresenceState.Here, buffer[offset]); + offset += 1; + Assert.Equal(0, buffer[offset]); + offset += 1; + offset = AssertWireString(buffer, offset, ""); + Assert.Equal(length, offset); + Assert.Equal(length, ProtocolCodec.PresenceSize(message)); + + var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length)); + Assert.Empty(read.People[0].TalkMemberIds); + Assert.Equal("", read.People[0].TalkTopicId); + Assert.Equal(message.People[0].Id, read.People[0].Id); + Assert.Equal(message.People[0].NodeId, read.People[0].NodeId); + Assert.Equal(message.People[0].State, read.People[0].State); + } + + [Fact] + public void Presence_PersonWithCircle_RoundTripsMemberIdsAndTopic() + { + var members = new[] { "f0.c0", "f0.c1" }; + var message = new ServerPresenceMessage( + 1, + [new PresenceNode("corridor-1", 2)], + [new PresencePerson("f0.c0", "corridor-1", PresenceState.Here, members, "TopicSport")]); + var buffer = new byte[ProtocolCodec.PresenceSize(message)]; + + var length = ProtocolCodec.WritePresence(buffer, message); + + Assert.Equal((byte)MessageType.ServerPresence, buffer[0]); + Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4))); + Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(5, 2))); + + var offset = 7; + offset = AssertWireString(buffer, offset, "corridor-1"); + Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(offset, 2))); + offset += 2; + Assert.Equal(0, buffer[offset]); + offset += 1; + Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(offset, 2))); + offset += 2; + offset = AssertWireString(buffer, offset, "f0.c0"); + offset = AssertWireString(buffer, offset, "corridor-1"); + Assert.Equal(PresenceState.Here, buffer[offset]); + offset += 1; + Assert.Equal(2, buffer[offset]); + offset += 1; + offset = AssertWireString(buffer, offset, "f0.c0"); + offset = AssertWireString(buffer, offset, "f0.c1"); + offset = AssertWireString(buffer, offset, "TopicSport"); + Assert.Equal(length, offset); + Assert.Equal(length, ProtocolCodec.PresenceSize(message)); + + var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length)); + Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds); + Assert.Equal("TopicSport", read.People[0].TalkTopicId); + Assert.Equal(message.SchoolId, read.SchoolId); + Assert.Equal(message.People[0].Id, read.People[0].Id); + } + + [Fact] + public void Presence_DoesNotContainPersonDisplayName() + { + var message = new ServerPresenceMessage( + 1, + [], + [new PresencePerson( + "f0.c0", + "corridor-1", + PresenceState.Here, + ["f0.c0", "f0.c1"], + "TopicSport")]); + var buffer = new byte[ProtocolCodec.PresenceSize(message)]; + + ProtocolCodec.WritePresence(buffer, message); + var text = System.Text.Encoding.UTF8.GetString(buffer); + + Assert.DoesNotContain("Мария", text); + Assert.DoesNotContain("Иванова", text); + Assert.DoesNotContain("Маша", text); + Assert.Contains("f0.c0", text); + Assert.Contains("TopicSport", text); + } + + private static int AssertWireString(byte[] buffer, int offset, string expected) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(expected); + Assert.Equal((ushort)bytes.Length, BitConverter.ToUInt16(buffer.AsSpan(offset, 2))); + Assert.Equal(bytes, buffer.AsSpan(offset + 2, bytes.Length).ToArray()); + return offset + 2 + bytes.Length; } [Fact]