Merge branch 'phase/45-presence-talk'

# Conflicts:
#	docs/phases/README.md
#	src/HSchool.Server/Api/PeopleModels.cs
#	src/HSchool.Server/Game/PersonCardReader.cs
This commit is contained in:
Leonid Pershin
2026-08-20 11:27:58 +03:00
28 changed files with 679 additions and 41 deletions
+8 -8
View File
@@ -11,18 +11,18 @@
## Задачи ## Задачи
- [ ] Кадр присутствия: у человека, который в кружке, список id участников и id темы. - [x] Кадр присутствия: у человека, который в кружке, список id участников и id темы.
Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали
- [ ] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите - [x] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите
- [ ] Не в кружке — пустой список, как сейчас только id/узел/состояние - [x] Не в кружке — пустой список, как сейчас только id/узел/состояние
- [ ] Вкладка «Сейчас» на карточке согласована с тем же составом - [x] Вкладка «Сейчас» на карточке согласована с тем же составом
- [ ] Старый клиент отваливается Hello, как обычно - [x] Старый клиент отваливается Hello, как обычно
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах - [x] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах
- [ ] Имя человека не встречается в кадре присутствия - [x] Имя человека не встречается в кадре присутствия
- [ ] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра - [x] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -212,7 +212,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [44. Ссора и драка](44-quarrel-fight.md) | 🔄 | Жертва задиры, заступник, извинение, двор/физкультура | | [44. Ссора и драка](44-quarrel-fight.md) | 🔄 | Жертва задиры, заступник, извинение, двор/физкультура |
| [45. Кружок в присутствии](45-presence-talk.md) | 🔄 | Id участников и темы в кадре, протокол +1 | | [45. Кружок в присутствии](45-presence-talk.md) | | Id участников и темы в кадре, протокол +1 |
44 и 45 стоят на 42, можно параллельно. 44 и 45 стоят на 42, можно параллельно.
+13 -2
View File
@@ -1,4 +1,4 @@
# Wire protocol v8 # Wire protocol v9
The client talks to the server two ways: The client talks to the server two ways:
@@ -316,6 +316,9 @@ grid fetches `GET .../timetable?classId=` with it.
`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` `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. 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 `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. no Chemistry; a related tongue from the name set may sit beside the native at a low value.
@@ -358,6 +361,8 @@ compute thresholds. There is no school-wide opinions endpoint.
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }], "needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
"activity": null, "activity": null,
"activityLabel": null, "activityLabel": null,
"talkCircleMemberIds": [],
"talkTopicId": null,
"family": { "family": {
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }], "parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
"children": [], "children": [],
@@ -983,6 +988,12 @@ Each person:
| string | person id | | string | person id |
| string | node id they occupy | | string | node id they occupy |
| `u8` | `1` here, `2` walking | | `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 ## Guarantees and limits
@@ -997,7 +1008,7 @@ Each person:
the oldest, because a stale clock is worthless once a newer one exists. 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. - 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 — 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. the tree and the location panel are filtered on the client from the snapshot plus presence.
@@ -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('');
});
});
@@ -0,0 +1,64 @@
import { PresenceState, type PresencePerson } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
const TOPIC_KEYS: Record<string, MessageKey> = {
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, string>,
): 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, string>,
): 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, string>): 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;
}
@@ -34,6 +34,8 @@ describe('t', () => {
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' })) expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
.toBe('Класс 101 (18 · Математика · 5А)'); .toBe('Класс 101 (18 · Математика · 5А)');
expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)'); expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)');
expect(t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') }))
.toBe('talking with Петрова Маша about sport');
}); });
}); });
+20
View File
@@ -339,6 +339,16 @@ const ru = {
presenceWalking: 'в пути ({name})', presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы', presenceAway: 'вне школы',
locationWalking: '{name} (в пути)', locationWalking: '{name} (в пути)',
locationTalking: 'говорит с {partners} о {topic}',
talkPartnersJoin: '{head} и {last}',
talkTopicTopicStudy: 'учёбе',
talkTopicTopicGames: 'играх',
talkTopicTopicFood: 'еде',
talkTopicTopicFamily: 'семье',
talkTopicTopicSport: 'спорте',
talkTopicTopicGossip: 'сплетнях',
talkTopicTopicRude: 'грубом',
talkTopicTopicAppearance: 'внешности',
timetableTitle: 'Расписание', timetableTitle: 'Расписание',
timetableClass: 'Класс', timetableClass: 'Класс',
timetableEmpty: 'Нет уроков.', timetableEmpty: 'Нет уроков.',
@@ -700,6 +710,16 @@ const en: Messages = {
presenceWalking: 'walking ({name})', presenceWalking: 'walking ({name})',
presenceAway: 'off campus', presenceAway: 'off campus',
locationWalking: '{name} (walking)', 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', timetableTitle: 'Timetable',
timetableClass: 'Class', timetableClass: 'Class',
timetableEmpty: 'No lessons.', timetableEmpty: 'No lessons.',
+2
View File
@@ -335,6 +335,8 @@ export interface PersonCard {
readonly needs: readonly NeedStat[]; readonly needs: readonly NeedStat[];
readonly activity: string | null; readonly activity: string | null;
readonly activityLabel: string | null; readonly activityLabel: string | null;
readonly talkCircleMemberIds: readonly string[];
readonly talkTopicId: string | null;
readonly family: { readonly family: {
readonly parents: readonly PersonRel[]; readonly parents: readonly PersonRel[];
readonly children: readonly PersonRel[]; readonly children: readonly PersonRel[];
+124 -2
View File
@@ -262,7 +262,9 @@ describe('decodeServerMessage', () => {
+ 2 + 2
+ 2 + personId.length + 2 + personId.length
+ 2 + personNode.length + 2 + personNode.length
+ 1, + 1
+ 1
+ 2,
); );
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence); view.setUint8(0, MessageType.ServerPresence);
@@ -296,6 +298,10 @@ describe('decodeServerMessage', () => {
new Uint8Array(buffer).set(personNode, offset); new Uint8Array(buffer).set(personNode, offset);
offset += personNode.length; offset += personNode.length;
view.setUint8(offset, PresenceState.Here); view.setUint8(offset, PresenceState.Here);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint16(offset, 0, true);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'presence', type: 'presence',
@@ -308,7 +314,123 @@ describe('decodeServerMessage', () => {
activityClass: '5А', 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',
},
],
}); });
}); });
+21 -2
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian. * 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 = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -122,6 +122,8 @@ export interface PresencePerson {
readonly id: string; readonly id: string;
readonly nodeId: string; readonly nodeId: string;
readonly state: number; readonly state: number;
readonly talkMemberIds: readonly string[];
readonly talkTopicId: string;
} }
export interface PresenceMessage { export interface PresenceMessage {
@@ -375,7 +377,24 @@ function decodePresence(view: DataView): PresenceMessage {
offset = nodeId.next; offset = nodeId.next;
const state = readU8(view, offset); const state = readU8(view, offset);
offset += 1; 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 }; return { type: 'presence', schoolId, nodes, people };
+81 -1
View File
@@ -1,9 +1,24 @@
/** /**
* @vitest-environment happy-dom * @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'; import { GameScreen } from './gameScreen.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchDirectory: async () => [],
};
});
const initial = getLocale();
afterEach(() => setLocale(initial));
describe('GameScreen speed buttons', () => { describe('GameScreen speed buttons', () => {
it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => { it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => {
const onSetSpeed = vi.fn(); const onSetSpeed = vi.fn();
@@ -47,3 +62,68 @@ describe('GameScreen speed buttons', () => {
expect((screen.element.querySelector('.clock__controls') as HTMLElement).hidden).toBe(true); 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,
);
});
});
+20 -15
View File
@@ -15,7 +15,7 @@ import { fetchDirectory, type School } from '../net/api.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { ManagementPanel } from './managementPanel.ts'; import { ManagementPanel } from './managementPanel.ts';
import { PeoplePanel } from './peoplePanel.ts'; import { PeoplePanel } from './peoplePanel.ts';
import { formatPersonPlace } from './personCard.ts'; import { locationPersonLine } from '../format/talkCircle.ts';
interface GameScreenOptions { interface GameScreenOptions {
readonly onLeave: () => void; readonly onLeave: () => void;
@@ -204,8 +204,7 @@ export class GameScreen {
this.paintSkip(); this.paintSkip();
this.paintSeed(); this.paintSeed();
this.people.setLocate((id) => this.placeOf(id)); this.bindPeople();
this.management.setLocate((id) => this.placeOf(id));
} }
/** Called when the screen opens, before the first clock frame and snapshot arrive. */ /** Called when the screen opens, before the first clock frame and snapshot arrive. */
@@ -234,8 +233,7 @@ export class GameScreen {
this.rebuildTree(); this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
this.people.show(school.id); this.people.show(school.id);
this.people.setLocate((id) => this.placeOf(id)); this.bindPeople();
this.management.setLocate((id) => this.placeOf(id));
this.showTab('map'); this.showTab('map');
this.inspect('location'); this.inspect('location');
this.showMode('overview'); this.showMode('overview');
@@ -267,13 +265,19 @@ export class GameScreen {
this.presence = message; this.presence = message;
this.paintTreeLabels(); this.paintTreeLabels();
this.paintSelection(); this.paintSelection();
this.people.setLocate((id) => this.placeOf(id)); this.bindPeople();
this.management.setLocate((id) => this.placeOf(id));
if (message.people.some((person) => !this.directory.has(person.id))) { if (message.people.some((person) => !this.directory.has(person.id))) {
void this.loadDirectory(); 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 { update(clock: ClockMessage): void {
this.applyClock( this.applyClock(
clock.gameTime, clock.gameTime,
@@ -458,10 +462,7 @@ export class GameScreen {
const names = this.presence.people const names = this.presence.people
.filter((person) => person.nodeId === id) .filter((person) => person.nodeId === id)
.map((person) => { .map((person) => locationPersonLine(person, this.directory));
const name = this.directory.get(person.id) ?? person.id;
return person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
});
names.sort((left, right) => left.localeCompare(right)); names.sort((left, right) => left.localeCompare(right));
return names; return names;
} }
@@ -476,6 +477,13 @@ export class GameScreen {
return formatPersonPlace(person.state === PresenceState.Walking ? 'walking' : 'here', nodeName); 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<void> { private async loadDirectory(): Promise<void> {
const schoolId = this.schoolId; const schoolId = this.schoolId;
if (schoolId === null) { if (schoolId === null) {
@@ -489,10 +497,7 @@ export class GameScreen {
return; return;
} }
this.directory = new Map(people.map((person) => [person.id, person.fullName])); this.applyDirectory(people);
this.paintSelection();
this.people.setLocate((id) => this.placeOf(id));
this.management.setLocate((id) => this.placeOf(id));
} catch { } catch {
// Names stay as ids until the next presence frame retries. // Names stay as ids until the next presence frame retries.
} }
@@ -67,6 +67,8 @@ function personCard(): PersonCard {
needs: [], needs: [],
activity: null, activity: null,
activityLabel: null, activityLabel: null,
talkCircleMemberIds: [],
talkTopicId: null,
family: { parents: [], children: [], siblings: [], partners: [] }, family: { parents: [], children: [], siblings: [], partners: [] },
worn: [], worn: [],
carried: [], carried: [],
@@ -194,6 +194,10 @@ export class ManagementPanel {
this.cardHost.relocate((id) => this.placeOf(id)); this.cardHost.relocate((id) => this.placeOf(id));
} }
setNames(names: ReadonlyMap<string, string>): void {
this.cardHost.setNames(names);
}
private async reload(): Promise<void> { private async reload(): Promise<void> {
const schoolId = this.schoolId; const schoolId = this.schoolId;
if (schoolId === null) { if (schoolId === null) {
+4
View File
@@ -184,6 +184,10 @@ export class PeoplePanel {
this.cardHost.relocate((id) => this.placeOf(id)); this.cardHost.relocate((id) => this.placeOf(id));
} }
setNames(names: ReadonlyMap<string, string>): void {
this.cardHost.setNames(names);
}
private onFilterChange(): void { private onFilterChange(): void {
this.page = 1; this.page = 1;
void this.reload(); void this.reload();
@@ -33,6 +33,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
needs: [], needs: [],
activity: null, activity: null,
activityLabel: null, activityLabel: null,
talkCircleMemberIds: [],
talkTopicId: null,
family: { parents: [], children: [], siblings: [], partners: [] }, family: { parents: [], children: [], siblings: [], partners: [] },
worn: [ worn: [
{ {
@@ -351,4 +353,26 @@ describe('renderPersonCard', () => {
expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome')); expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome'));
expect(root.querySelector('.people__now-activity')?.textContent).not.toBe(''); 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') }),
);
});
}); });
+19 -2
View File
@@ -10,6 +10,7 @@ import type {
} from '../net/api.ts'; } from '../net/api.ts';
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { talkCircleText } from '../format/talkCircle.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
@@ -46,6 +47,8 @@ export interface RenderPersonCardOptions {
readonly swarmConfigured?: boolean; readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */ /** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null; readonly swarmConnected?: boolean | null;
/** id→fullName from the presence directory; used to label the talk circle on Now. */
readonly personNames?: ReadonlyMap<string, string>;
} }
export function roleLabels(roles: readonly string[]): string { export function roleLabels(roles: readonly string[]): string {
@@ -67,7 +70,21 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
return parts.length > 0 ? parts.join(' · ') : '—'; return parts.length > 0 ? parts.join(' · ') : '—';
} }
export function nowActivityText(card: PersonCard, away: boolean): string { export function nowActivityText(
card: PersonCard,
away: boolean,
names: ReadonlyMap<string, string> = 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)) { if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
return t('peopleAtHome'); return t('peopleAtHome');
} }
@@ -324,7 +341,7 @@ function fillNow(
log: PersonLogPage | null | undefined, log: PersonLogPage | null | undefined,
options: RenderPersonCardOptions, options: RenderPersonCardOptions,
): void { ): void {
const activity = nowActivityText(card, away); const activity = nowActivityText(card, away, options.personNames);
if (activity.length > 0) { if (activity.length > 0) {
parent.append(el('p', { class: 'people__now-activity', text: activity })); parent.append(el('p', { class: 'people__now-activity', text: activity }));
} }
+16 -1
View File
@@ -41,6 +41,7 @@ export class PersonCardHost {
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private onRelative: (id: string) => void = () => {}; private onRelative: (id: string) => void = () => {};
private placeOf: (id: string) => string = () => ''; private placeOf: (id: string) => string = () => '';
private personNames: ReadonlyMap<string, string> = new Map();
private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void; private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void;
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
private logPage: PersonLogPage | null = null; private logPage: PersonLogPage | null = null;
@@ -172,6 +173,7 @@ export class PersonCardHost {
onShowPortraitPrompt: (kind) => void this.togglePortraitPrompt(kind), onShowPortraitPrompt: (kind) => void this.togglePortraitPrompt(kind),
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected, swarmConnected: this.swarmConnected,
personNames: this.personNames,
}); });
const overview = container.querySelector('[data-card-tab="overview"]'); const overview = container.querySelector('[data-card-tab="overview"]');
if (overview instanceof HTMLElement) { if (overview instanceof HTMLElement) {
@@ -301,7 +303,20 @@ export class PersonCardHost {
line.textContent = place; line.textContent = place;
const now = this.container?.querySelector('.people__now-activity'); const now = this.container?.querySelector('.people__now-activity');
if (now instanceof HTMLElement) { 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<string, string>): 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);
} }
} }
} }
+16 -2
View File
@@ -98,8 +98,22 @@ public sealed record PresenceNode(
string ActivitySubject = "", string ActivitySubject = "",
string ActivityClass = ""); string ActivityClass = "");
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary> /// <summary>
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.
/// </summary>
public sealed record PresencePerson(
string Id,
string NodeId,
byte State,
IReadOnlyList<string> TalkMemberIds,
string TalkTopicId)
{
public PresencePerson(string id, string nodeId, byte state)
: this(id, nodeId, state, [], "")
{
}
}
/// <summary> /// <summary>
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole /// Live occupancy of an open school, about twice a second. Counts and people cover the whole
+33 -1
View File
@@ -202,6 +202,14 @@ public static class ProtocolCodec
foreach (var person in message.People) foreach (var person in message.People)
{ {
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte); 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; return size;
@@ -242,9 +250,22 @@ public static class ProtocolCodec
writer.WriteUInt16((ushort)message.People.Count); writer.WriteUInt16((ushort)message.People.Count);
foreach (var person in message.People) 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.Id);
writer.WriteString(person.NodeId); writer.WriteString(person.NodeId);
writer.WriteByte(person.State); writer.WriteByte(person.State);
writer.WriteByte((byte)members.Count);
foreach (var memberId in members)
{
writer.WriteString(memberId);
}
writer.WriteString(person.TalkTopicId ?? "");
} }
return writer.Position; return writer.Position;
@@ -403,7 +424,18 @@ public static class ProtocolCodec
var people = new PresencePerson[personCount]; var people = new PresencePerson[personCount];
for (var i = 0; i < personCount; i++) 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); return new ServerPresenceMessage(schoolId, nodes, people);
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants public static class ProtocolConstants
{ {
/// <summary>Bumped on every breaking change to the binary layout.</summary> /// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 8; public const byte Version = 9;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary> /// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024; public const int MaxMessageSize = 8 * 1024;
+4 -1
View File
@@ -83,7 +83,10 @@ internal sealed record PersonCardResponse(
bool HasFullBody = false, bool HasFullBody = false,
string? CustomPortraitPrompt = null, string? CustomPortraitPrompt = null,
PersonConnectionsResponse? Connections = null, PersonConnectionsResponse? Connections = null,
DefLabelResponse? Orientation = null); DefLabelResponse? Orientation = null,
IReadOnlyList<string>? TalkCircleMemberIds = null,
string? TalkTopicId = null);
internal sealed record WornItemResponse( internal sealed record WornItemResponse(
string DefName, string DefName,
+5 -1
View File
@@ -60,6 +60,8 @@ internal static class PersonCardReader
activityLabel = activityId; activityLabel = activityId;
} }
var circle = school.TalkCircleOf(personId);
return new PersonCardResponse( return new PersonCardResponse(
person.Id, person.Id,
person.Name.Full, person.Name.Full,
@@ -90,7 +92,9 @@ internal static class PersonCardReader
|| person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), || person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, 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),
Orientation: OrientationOf(person, catalog, locale)); Orientation: OrientationOf(person, catalog, locale),
TalkCircleMemberIds: circle?.MemberIds ?? [],
TalkTopicId: circle?.TopicId);
} }
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId) private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
+4 -1
View File
@@ -25,10 +25,13 @@ internal static class PresenceFrame
} }
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0; var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
var circle = school.TalkCircleOf(row.PersonId);
people.Add(new PresencePerson( people.Add(new PresencePerson(
row.PersonId, row.PersonId,
row.NodeId, row.NodeId,
walking ? PresenceState.Walking : PresenceState.Here)); walking ? PresenceState.Walking : PresenceState.Here,
circle?.MemberIds ?? [],
circle?.TopicId ?? ""));
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1; counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
} }
+3
View File
@@ -662,3 +662,6 @@ public sealed record PresenceSnapshot(
string? GoalId = null, string? GoalId = null,
float GoalWeight = 0f, float GoalWeight = 0f,
string? GoalAction = null); string? GoalAction = null);
/// <summary>Ids of an active talk circle. Names are resolved by the client, not here.</summary>
public sealed record TalkCirclePresence(string TopicId, IReadOnlyList<string> MemberIds);
+17
View File
@@ -218,6 +218,23 @@ public sealed class School : IDisposable
public IReadOnlyList<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this); public IReadOnlyList<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this);
/// <summary>
/// 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.
/// </summary>
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<PresenceSnapshot>? saved) => PresenceSystem.Restore(this, saved); public void RestorePresence(IReadOnlyList<PresenceSnapshot>? saved) => PresenceSystem.Restore(this, saved);
public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this); public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
@@ -246,7 +246,120 @@ public class ProtocolCodecTests
Assert.Equal("f0.c0", read.People[0].Id); Assert.Equal("f0.c0", read.People[0].Id);
Assert.Equal("classroom-101", read.People[0].NodeId); Assert.Equal("classroom-101", read.People[0].NodeId);
Assert.Equal(PresenceState.Here, read.People[0].State); 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.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] [Fact]
@@ -26,6 +26,13 @@ public class TalkCircleTests
var rows = school.CapturePresence(); 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 == first.Id).ActionId);
Assert.Equal(TalkActions.Chat, rows.Single(row => row.PersonId == second.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"));
} }
} }