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
@@ -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А' }))
.toBe('Класс 101 (18 · Математика · 5А)');
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})',
presenceAway: 'вне школы',
locationWalking: '{name} (в пути)',
locationTalking: 'говорит с {partners} о {topic}',
talkPartnersJoin: '{head} и {last}',
talkTopicTopicStudy: 'учёбе',
talkTopicTopicGames: 'играх',
talkTopicTopicFood: 'еде',
talkTopicTopicFamily: 'семье',
talkTopicTopicSport: 'спорте',
talkTopicTopicGossip: 'сплетнях',
talkTopicTopicRude: 'грубом',
talkTopicTopicAppearance: 'внешности',
timetableTitle: 'Расписание',
timetableClass: 'Класс',
timetableEmpty: 'Нет уроков.',
@@ -700,6 +710,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.',
+2
View File
@@ -335,6 +335,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[];
+124 -2
View File
@@ -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',
},
],
});
});
+21 -2
View File
@@ -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 };
+81 -1
View File
@@ -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<typeof import('../net/api.ts')>();
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,
);
});
});
+20 -15
View File
@@ -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<void> {
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.
}
@@ -67,6 +67,8 @@ function personCard(): PersonCard {
needs: [],
activity: null,
activityLabel: null,
talkCircleMemberIds: [],
talkTopicId: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [],
carried: [],
@@ -194,6 +194,10 @@ export class ManagementPanel {
this.cardHost.relocate((id) => this.placeOf(id));
}
setNames(names: ReadonlyMap<string, string>): void {
this.cardHost.setNames(names);
}
private async reload(): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null) {
+4
View File
@@ -184,6 +184,10 @@ export class PeoplePanel {
this.cardHost.relocate((id) => this.placeOf(id));
}
setNames(names: ReadonlyMap<string, string>): void {
this.cardHost.setNames(names);
}
private onFilterChange(): void {
this.page = 1;
void this.reload();
@@ -33,6 +33,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
needs: [],
activity: null,
activityLabel: null,
talkCircleMemberIds: [],
talkTopicId: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [
{
@@ -351,4 +353,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') }),
);
});
});
+19 -2
View File
@@ -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<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(' · ') : '—';
}
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)) {
return t('peopleAtHome');
}
@@ -324,7 +341,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 }));
}
+16 -1
View File
@@ -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<string, string> = 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<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);
}
}
}