From 1a21a64b4b6feb667d9f5611081b7ba076940ab4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:42:11 +0300 Subject: [PATCH 1/3] 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] From 20349463789472e3f240e4cfd64ea429223392ec Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:43:16 +0300 Subject: [PATCH 2/3] Show talk-circle partners and topic on the location panel and Now tab. The frame and card share member ids; the client builds the sentence from the directory and locale. Co-authored-by: Cursor --- docs/phases/45-presence-talk.md | 16 ++-- .../src/format/talkCircle.test.ts | 51 ++++++++++++ src/HSchool.Client/src/format/talkCircle.ts | 64 +++++++++++++++ src/HSchool.Client/src/i18n/strings.test.ts | 2 + src/HSchool.Client/src/i18n/strings.ts | 20 +++++ src/HSchool.Client/src/net/api.ts | 2 + src/HSchool.Client/src/ui/gameScreen.test.ts | 82 ++++++++++++++++++- src/HSchool.Client/src/ui/gameScreen.ts | 35 ++++---- .../src/ui/managementPanel.test.ts | 2 + src/HSchool.Client/src/ui/managementPanel.ts | 4 + src/HSchool.Client/src/ui/peoplePanel.ts | 4 + src/HSchool.Client/src/ui/personCard.test.ts | 24 ++++++ src/HSchool.Client/src/ui/personCard.ts | 21 ++++- src/HSchool.Client/src/ui/personCardHost.ts | 17 +++- src/HSchool.Server/Api/PeopleModels.cs | 4 +- src/HSchool.Server/Game/PersonCardReader.cs | 6 +- src/HSchool.Server/Game/PresenceFrame.cs | 5 +- src/HSchool.Simulation/PresenceSystem.cs | 3 + src/HSchool.Simulation/School.cs | 17 ++++ .../TalkCircleTests.cs | 7 ++ 20 files changed, 356 insertions(+), 30 deletions(-) create mode 100644 src/HSchool.Client/src/format/talkCircle.test.ts create mode 100644 src/HSchool.Client/src/format/talkCircle.ts diff --git a/docs/phases/45-presence-talk.md b/docs/phases/45-presence-talk.md index ffa40d1..d9a7438 100644 --- a/docs/phases/45-presence-talk.md +++ b/docs/phases/45-presence-talk.md @@ -11,18 +11,18 @@ ## Задачи -- [ ] Кадр присутствия: у человека, который в кружке, список id участников и id темы. +- [x] Кадр присутствия: у человека, который в кружке, список id участников и id темы. Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали -- [ ] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите -- [ ] Не в кружке — пустой список, как сейчас только id/узел/состояние -- [ ] Вкладка «Сейчас» на карточке согласована с тем же составом -- [ ] Старый клиент отваливается Hello, как обычно +- [x] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите +- [x] Не в кружке — пустой список, как сейчас только id/узел/состояние +- [x] Вкладка «Сейчас» на карточке согласована с тем же составом +- [x] Старый клиент отваливается Hello, как обычно ## Тесты, без которых фаза не закрыта -- [ ] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах -- [ ] Имя человека не встречается в кадре присутствия -- [ ] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра +- [x] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах +- [x] Имя человека не встречается в кадре присутствия +- [x] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра ## Критерий готовности diff --git a/src/HSchool.Client/src/format/talkCircle.test.ts b/src/HSchool.Client/src/format/talkCircle.test.ts new file mode 100644 index 0000000..e943590 --- /dev/null +++ b/src/HSchool.Client/src/format/talkCircle.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { PresenceState, type PresencePerson } from '../net/protocol.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { locationPersonLine, talkCircleText } from './talkCircle.ts'; + +const initial = getLocale(); + +afterEach(() => setLocale(initial)); + +describe('location talk line', () => { + it('shows the partner name from the directory and the topic from locale, not from the frame', () => { + setLocale('ru'); + const person: PresencePerson = { + id: 'f0.c0', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: ['f0.c0', 'f0.c1'], + talkTopicId: 'TopicSport', + }; + const names = new Map([ + ['f0.c0', 'Иванова Мария'], + ['f0.c1', 'Петрова Маша'], + ]); + + const line = locationPersonLine(person, names); + + expect(JSON.stringify(person)).not.toContain('Маша'); + expect(JSON.stringify(person)).not.toContain('Иванова'); + expect(line).toContain('Петрова Маша'); + expect(line).toContain(t('talkTopicTopicSport')); + expect(line).toBe(`Иванова Мария — ${t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') })}`); + }); + + it('omits talk text when the circle list is empty', () => { + const person: PresencePerson = { + id: 'f0.c0', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: [], + talkTopicId: '', + }; + const names = new Map([['f0.c0', 'Иванова Мария']]); + + expect(locationPersonLine(person, names)).toBe('Иванова Мария'); + expect(talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names)).toBe(''); + }); +}); diff --git a/src/HSchool.Client/src/format/talkCircle.ts b/src/HSchool.Client/src/format/talkCircle.ts new file mode 100644 index 0000000..2e32453 --- /dev/null +++ b/src/HSchool.Client/src/format/talkCircle.ts @@ -0,0 +1,64 @@ +import { PresenceState, type PresencePerson } from '../net/protocol.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; + +const TOPIC_KEYS: Record = { + TopicStudy: 'talkTopicTopicStudy', + TopicGames: 'talkTopicTopicGames', + TopicFood: 'talkTopicTopicFood', + TopicFamily: 'talkTopicTopicFamily', + TopicSport: 'talkTopicTopicSport', + TopicGossip: 'talkTopicTopicGossip', + TopicRude: 'talkTopicTopicRude', + TopicAppearance: 'talkTopicTopicAppearance', +}; + +export function talkTopicLabel(topicId: string): string { + const key = TOPIC_KEYS[topicId]; + return key === undefined ? topicId : t(key); +} + +export function talkPartnersLabel( + selfId: string, + memberIds: readonly string[], + names: ReadonlyMap, +): string { + const others = memberIds + .filter((id) => id !== selfId) + .map((id) => names.get(id) ?? id); + if (others.length === 0) { + return ''; + } + + if (others.length === 1) { + return others[0] ?? ''; + } + + const last = others[others.length - 1] ?? ''; + const head = others.slice(0, -1).join(', '); + return t('talkPartnersJoin', { head, last }); +} + +export function talkCircleText( + selfId: string, + memberIds: readonly string[], + topicId: string, + names: ReadonlyMap, +): string { + if (memberIds.length === 0 || topicId.length === 0) { + return ''; + } + + const partners = talkPartnersLabel(selfId, memberIds, names); + if (partners.length === 0) { + return ''; + } + + return t('locationTalking', { partners, topic: talkTopicLabel(topicId) }); +} + +export function locationPersonLine(person: PresencePerson, names: ReadonlyMap): string { + const name = names.get(person.id) ?? person.id; + const base = person.state === PresenceState.Walking ? t('locationWalking', { name }) : name; + const talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names); + return talk.length > 0 ? `${base} — ${talk}` : base; +} diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index c646cd7..dfb4545 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -34,6 +34,8 @@ describe('t', () => { expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' })) .toBe('Класс 101 (18 · Математика · 5А)'); expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)'); + expect(t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') })) + .toBe('talking with Петрова Маша about sport'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 5c63cc9..8e210cf 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -335,6 +335,16 @@ const ru = { presenceWalking: 'в пути ({name})', presenceAway: 'вне школы', locationWalking: '{name} (в пути)', + locationTalking: 'говорит с {partners} о {topic}', + talkPartnersJoin: '{head} и {last}', + talkTopicTopicStudy: 'учёбе', + talkTopicTopicGames: 'играх', + talkTopicTopicFood: 'еде', + talkTopicTopicFamily: 'семье', + talkTopicTopicSport: 'спорте', + talkTopicTopicGossip: 'сплетнях', + talkTopicTopicRude: 'грубом', + talkTopicTopicAppearance: 'внешности', timetableTitle: 'Расписание', timetableClass: 'Класс', timetableEmpty: 'Нет уроков.', @@ -692,6 +702,16 @@ const en: Messages = { presenceWalking: 'walking ({name})', presenceAway: 'off campus', locationWalking: '{name} (walking)', + locationTalking: 'talking with {partners} about {topic}', + talkPartnersJoin: '{head} and {last}', + talkTopicTopicStudy: 'schoolwork', + talkTopicTopicGames: 'games', + talkTopicTopicFood: 'food', + talkTopicTopicFamily: 'family', + talkTopicTopicSport: 'sport', + talkTopicTopicGossip: 'gossip', + talkTopicTopicRude: 'rough talk', + talkTopicTopicAppearance: 'looks', timetableTitle: 'Timetable', timetableClass: 'Class', timetableEmpty: 'No lessons.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index aa7cbd4..a1ab0e4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -324,6 +324,8 @@ export interface PersonCard { readonly needs: readonly NeedStat[]; readonly activity: string | null; readonly activityLabel: string | null; + readonly talkCircleMemberIds: readonly string[]; + readonly talkTopicId: string | null; readonly family: { readonly parents: readonly PersonRel[]; readonly children: readonly PersonRel[]; diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts index bbfa7cf..75bf408 100644 --- a/src/HSchool.Client/src/ui/gameScreen.test.ts +++ b/src/HSchool.Client/src/ui/gameScreen.test.ts @@ -1,9 +1,24 @@ /** * @vitest-environment happy-dom */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PresenceState } from '../net/protocol.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; import { GameScreen } from './gameScreen.ts'; +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDirectory: async () => [], + }; +}); + +const initial = getLocale(); + +afterEach(() => setLocale(initial)); + describe('GameScreen speed buttons', () => { it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => { const onSetSpeed = vi.fn(); @@ -47,3 +62,68 @@ describe('GameScreen speed buttons', () => { expect((screen.element.querySelector('.clock__controls') as HTMLElement).hidden).toBe(true); }); }); + +describe('GameScreen location talk', () => { + it('shows talk partners from the directory on the location list, not from the frame', () => { + setLocale('ru'); + const screen = new GameScreen({ + onLeave: () => {}, + onSetRunning: () => {}, + onSetSpeed: () => {}, + onSkip: () => {}, + }); + document.body.append(screen.element); + screen.show({ + id: 1, + name: 'Test', + gameTime: '2012-03-31T10:00:00.000Z', + running: true, + speedIndex: 1, + seed: 1, + mine: true, + }); + screen.applyMap(1, [ + { + kind: 3, + id: 'corridor-1', + parentId: '', + name: 'Коридор', + pupilSlots: 0, + items: [], + positions: [], + }, + ]); + const presence = { + type: 'presence' as const, + schoolId: 1, + nodes: [{ id: 'corridor-1', count: 2, activitySubject: '', activityClass: '' }], + people: [ + { + id: 'f0.c0', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: ['f0.c0', 'f0.c1'], + talkTopicId: 'TopicSport', + }, + { + id: 'f0.c1', + nodeId: 'corridor-1', + state: PresenceState.Here, + talkMemberIds: ['f0.c0', 'f0.c1'], + talkTopicId: 'TopicSport', + }, + ], + }; + expect(JSON.stringify(presence)).not.toContain('Маша'); + screen.applyPresence(1, presence); + screen.applyDirectory([ + { id: 'f0.c0', fullName: 'Иванова Мария' }, + { id: 'f0.c1', fullName: 'Петрова Маша' }, + ]); + + const items = [...screen.element.querySelectorAll('.panel__list li')].map((item) => item.textContent ?? ''); + expect(items.some((text) => text.includes('Петрова Маша') && text.includes(t('talkTopicTopicSport')))).toBe( + true, + ); + }); +}); diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 8ed4a87..b8953bf 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -15,7 +15,7 @@ import { fetchDirectory, type School } from '../net/api.ts'; import { clear, el } from './dom.ts'; import { ManagementPanel } from './managementPanel.ts'; import { PeoplePanel } from './peoplePanel.ts'; -import { formatPersonPlace } from './personCard.ts'; +import { locationPersonLine } from '../format/talkCircle.ts'; interface GameScreenOptions { readonly onLeave: () => void; @@ -204,8 +204,7 @@ export class GameScreen { this.paintSkip(); this.paintSeed(); - this.people.setLocate((id) => this.placeOf(id)); - this.management.setLocate((id) => this.placeOf(id)); + this.bindPeople(); } /** Called when the screen opens, before the first clock frame and snapshot arrive. */ @@ -234,8 +233,7 @@ export class GameScreen { this.rebuildTree(); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); this.people.show(school.id); - this.people.setLocate((id) => this.placeOf(id)); - this.management.setLocate((id) => this.placeOf(id)); + this.bindPeople(); this.showTab('map'); this.inspect('location'); this.showMode('overview'); @@ -267,13 +265,19 @@ export class GameScreen { this.presence = message; this.paintTreeLabels(); this.paintSelection(); - this.people.setLocate((id) => this.placeOf(id)); - this.management.setLocate((id) => this.placeOf(id)); + this.bindPeople(); if (message.people.some((person) => !this.directory.has(person.id))) { void this.loadDirectory(); } } + /** Names for the presence stream. The frame itself never carries display names. */ + applyDirectory(people: readonly { id: string; fullName: string }[]): void { + this.directory = new Map(people.map((person) => [person.id, person.fullName])); + this.paintSelection(); + this.bindPeople(); + } + update(clock: ClockMessage): void { this.applyClock( clock.gameTime, @@ -458,10 +462,7 @@ export class GameScreen { const names = this.presence.people .filter((person) => person.nodeId === id) - .map((person) => { - const name = this.directory.get(person.id) ?? person.id; - return person.state === PresenceState.Walking ? t('locationWalking', { name }) : name; - }); + .map((person) => locationPersonLine(person, this.directory)); names.sort((left, right) => left.localeCompare(right)); return names; } @@ -476,6 +477,13 @@ export class GameScreen { return formatPersonPlace(person.state === PresenceState.Walking ? 'walking' : 'here', nodeName); } + private bindPeople(): void { + this.people.setLocate((id) => this.placeOf(id)); + this.management.setLocate((id) => this.placeOf(id)); + this.people.setNames(this.directory); + this.management.setNames(this.directory); + } + private async loadDirectory(): Promise { const schoolId = this.schoolId; if (schoolId === null) { @@ -489,10 +497,7 @@ export class GameScreen { return; } - this.directory = new Map(people.map((person) => [person.id, person.fullName])); - this.paintSelection(); - this.people.setLocate((id) => this.placeOf(id)); - this.management.setLocate((id) => this.placeOf(id)); + this.applyDirectory(people); } catch { // Names stay as ids until the next presence frame retries. } diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index d067d83..36a6770 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -67,6 +67,8 @@ function personCard(): PersonCard { needs: [], activity: null, activityLabel: null, + talkCircleMemberIds: [], + talkTopicId: null, family: { parents: [], children: [], siblings: [], partners: [] }, worn: [], carried: [], diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index a60157f..93a4774 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -194,6 +194,10 @@ export class ManagementPanel { this.cardHost.relocate((id) => this.placeOf(id)); } + setNames(names: ReadonlyMap): void { + this.cardHost.setNames(names); + } + private async reload(): Promise { const schoolId = this.schoolId; if (schoolId === null) { diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index 8111e30..ea1adac 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -184,6 +184,10 @@ export class PeoplePanel { this.cardHost.relocate((id) => this.placeOf(id)); } + setNames(names: ReadonlyMap): void { + this.cardHost.setNames(names); + } + private onFilterChange(): void { this.page = 1; void this.reload(); diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 81517ad..ab38a9e 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -33,6 +33,8 @@ function card(overrides: Partial = {}): PersonCard { needs: [], activity: null, activityLabel: null, + talkCircleMemberIds: [], + talkTopicId: null, family: { parents: [], children: [], siblings: [], partners: [] }, worn: [ { @@ -322,4 +324,26 @@ describe('renderPersonCard', () => { expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome')); expect(root.querySelector('.people__now-activity')?.textContent).not.toBe(''); }); + + it('shows the same talk-circle partners on Now as the location line uses', () => { + setLocale('ru'); + const root = document.createElement('div'); + const names = new Map([ + ['f0.c0', 'Иванова Мария'], + ['f0.c1', 'Петрова Маша'], + ]); + renderPersonCard( + root, + card({ + talkCircleMemberIds: ['f0.c0', 'f0.c1'], + talkTopicId: 'TopicSport', + }), + () => {}, + { tab: 'now', personNames: names }, + ); + + expect(root.querySelector('.people__now-activity')?.textContent).toBe( + t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') }), + ); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 0d62dae..a3b5339 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -10,6 +10,7 @@ import type { } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts'; +import { talkCircleText } from '../format/talkCircle.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; @@ -46,6 +47,8 @@ export interface RenderPersonCardOptions { readonly swarmConfigured?: boolean; /** null while checking or when SwarmUI is not configured. */ readonly swarmConnected?: boolean | null; + /** id→fullName from the presence directory; used to label the talk circle on Now. */ + readonly personNames?: ReadonlyMap; } export function roleLabels(roles: readonly string[]): string { @@ -67,7 +70,21 @@ export function placement(person: Pick 0 ? parts.join(' · ') : '—'; } -export function nowActivityText(card: PersonCard, away: boolean): string { +export function nowActivityText( + card: PersonCard, + away: boolean, + names: ReadonlyMap = new Map(), +): string { + const talk = talkCircleText( + card.id, + card.talkCircleMemberIds, + card.talkTopicId ?? '', + names, + ); + if (talk.length > 0) { + return talk; + } + if (away && (card.activityLabel === null || card.activityLabel.length === 0)) { return t('peopleAtHome'); } @@ -307,7 +324,7 @@ function fillNow( log: PersonLogPage | null | undefined, options: RenderPersonCardOptions, ): void { - const activity = nowActivityText(card, away); + const activity = nowActivityText(card, away, options.personNames); if (activity.length > 0) { parent.append(el('p', { class: 'people__now-activity', text: activity })); } diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 63f5f1b..af9b5be 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -41,6 +41,7 @@ export class PersonCardHost { private container: HTMLElement | null = null; private onRelative: (id: string) => void = () => {}; private placeOf: (id: string) => string = () => ''; + private personNames: ReadonlyMap = new Map(); private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void; private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; private logPage: PersonLogPage | null = null; @@ -172,6 +173,7 @@ export class PersonCardHost { onShowPortraitPrompt: (kind) => void this.togglePortraitPrompt(kind), swarmConfigured: this.swarmConfigured, swarmConnected: this.swarmConnected, + personNames: this.personNames, }); const overview = container.querySelector('[data-card-tab="overview"]'); if (overview instanceof HTMLElement) { @@ -301,7 +303,20 @@ export class PersonCardHost { line.textContent = place; const now = this.container?.querySelector('.people__now-activity'); if (now instanceof HTMLElement) { - now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away')); + now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'), this.personNames); + } + } + + setNames(names: ReadonlyMap): void { + this.personNames = names; + if (this.painted === null) { + return; + } + + const now = this.container?.querySelector('.people__now-activity'); + if (now instanceof HTMLElement) { + const place = this.placeOf(this.painted.id); + now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'), names); } } } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 13d1f65..eaa6ae8 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -82,7 +82,9 @@ internal sealed record PersonCardResponse( bool HasCustom = false, bool HasFullBody = false, string? CustomPortraitPrompt = null, - PersonConnectionsResponse? Connections = null); + PersonConnectionsResponse? Connections = null, + IReadOnlyList? TalkCircleMemberIds = null, + string? TalkTopicId = null); internal sealed record WornItemResponse( string DefName, diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index c6568c9..7af3c2c 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -60,6 +60,8 @@ internal static class PersonCardReader activityLabel = activityId; } + var circle = school.TalkCircleOf(personId); + return new PersonCardResponse( person.Id, person.Name.Full, @@ -89,7 +91,9 @@ internal static class PersonCardReader person.LockerRoomId is not null || person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)), - Connections: Connections(roster, person, catalog, locale)); + Connections: Connections(roster, person, catalog, locale), + TalkCircleMemberIds: circle?.MemberIds ?? [], + TalkTopicId: circle?.TopicId); } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) diff --git a/src/HSchool.Server/Game/PresenceFrame.cs b/src/HSchool.Server/Game/PresenceFrame.cs index badd455..4c33441 100644 --- a/src/HSchool.Server/Game/PresenceFrame.cs +++ b/src/HSchool.Server/Game/PresenceFrame.cs @@ -25,10 +25,13 @@ internal static class PresenceFrame } var walking = row.Path.Count > 0 || row.RemainingMinutes > 0; + var circle = school.TalkCircleOf(row.PersonId); people.Add(new PresencePerson( row.PersonId, row.NodeId, - walking ? PresenceState.Walking : PresenceState.Here)); + walking ? PresenceState.Walking : PresenceState.Here, + circle?.MemberIds ?? [], + circle?.TopicId ?? "")); counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1; } diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index 694a754..418e633 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -621,3 +621,6 @@ public sealed record PresenceSnapshot( string? GoalId = null, float GoalWeight = 0f, string? GoalAction = null); + +/// Ids of an active talk circle. Names are resolved by the client, not here. +public sealed record TalkCirclePresence(string TopicId, IReadOnlyList MemberIds); diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 65f2d9a..3c46d29 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -218,6 +218,23 @@ public sealed class School : IDisposable public IReadOnlyList CapturePresence() => PresenceSystem.Capture(this); + /// + /// Live circle for the presence frame and the person card. Null when this person is not talking. + /// Member ids include self and are sorted; names stay off the wire. + /// + public TalkCirclePresence? TalkCircleOf(string personId) + { + if (!TalkCircleByPerson.TryGetValue(personId, out var circleId) + || !TalkCirclesById.TryGetValue(circleId, out var circle)) + { + return null; + } + + var members = circle.Members.ToArray(); + Array.Sort(members, StringComparer.Ordinal); + return new TalkCirclePresence(circle.TopicId, members); + } + public void RestorePresence(IReadOnlyList? saved) => PresenceSystem.Restore(this, saved); public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this); diff --git a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs index 7da77f9..0d8729f 100644 --- a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs +++ b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs @@ -26,6 +26,13 @@ public class TalkCircleTests var rows = school.CapturePresence(); Assert.Equal(TalkActions.Chat, rows.Single(row => row.PersonId == first.Id).ActionId); Assert.Equal(TalkActions.Chat, rows.Single(row => row.PersonId == second.Id).ActionId); + var circle = school.TalkCircleOf(first.Id); + Assert.NotNull(circle); + Assert.Contains(first.Id, circle.MemberIds); + Assert.Contains(second.Id, circle.MemberIds); + Assert.False(string.IsNullOrEmpty(circle.TopicId)); + Assert.Equal(circle.MemberIds, school.TalkCircleOf(second.Id)?.MemberIds); + Assert.Null(school.TalkCircleOf("missing")); } } From 663edaccae71b4ba8b696dbdfc01946e87b3c89e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:43:56 +0300 Subject: [PATCH 3/3] Mark phase 45 presence talk as complete. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 284e542..bd30e1c 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -212,7 +212,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [44. Ссора и драка](44-quarrel-fight.md) | ⬜ | Жертва задиры, заступник, извинение, двор/физкультура | -| [45. Кружок в присутствии](45-presence-talk.md) | ⬜ | Id участников и темы в кадре, протокол +1 | +| [45. Кружок в присутствии](45-presence-talk.md) | ✅ | Id участников и темы в кадре, протокол +1 | 44 и 45 стоят на 42, можно параллельно.