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]