Merge branch 'phase/71-summons-notices'

This commit is contained in:
Leonid Pershin
2026-08-21 13:13:44 +03:00
30 changed files with 454 additions and 29 deletions
+9 -9
View File
@@ -11,21 +11,21 @@
## Задачи ## Задачи
- [ ] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений - [x] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений
среза 11 среза 11
- [ ] Тост не паузит часы; не в сейве sticky - [x] Тост не паузит часы; не в сейве sticky
- [ ] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме» - [x] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме»
— через действие и узел; новый байт в кадре присутствия — только если иначе нельзя; тогда — через действие и узел; новый байт в кадре присутствия — только если иначе нельзя; тогда
протокол +1 в этом коммите (`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`) протокол +1 в этом коммите (`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`)
- [ ] Гость видит те же info; ничего не назначает - [x] Гость видит те же info; ничего не назначает
- [ ] Локали RU/EN - [x] Локали RU/EN
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Qualifying-вызов эмитит notice с ожидаемым `EventDef` - [x] Qualifying-вызов эмитит notice с ожидаемым `EventDef`
- [ ] Info не пишет sticky в сейв - [x] Info не пишет sticky в сейв
- [ ] Если бамп протокола — round-trip и byte-layout на C# и TS - [x] Если бамп протокола — round-trip и byte-layout на C# и TS
- [ ] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима) - [x] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -21,7 +21,7 @@
| --- | --- | --- | | --- | --- | --- |
| [69. Память проступков](69-offense-memory.md) | ✅ | Короткий список на человеке и карточке | | [69. Память проступков](69-offense-memory.md) | ✅ | Короткий список на человеке и карточке |
| [70. Вызов и очередь](70-director-summons.md) | ✅ | Авто, ходьба, коридор, один в кабинете | | [70. Вызов и очередь](70-director-summons.md) | ✅ | Авто, ходьба, коридор, один в кабинете |
| [71. Тосты вызова](71-summons-notices.md) | 🔄 | `EventDef` info; очередь видна | | [71. Тосты вызова](71-summons-notices.md) | | `EventDef` info; очередь видна |
69 стоит на 44; 70 — на 69 и ходьбу/решения; 71 — на 70 и 64. 69 стоит на 44; 70 — на 69 и ходьбу/решения; 71 — на 70 и 64.
+5 -2
View File
@@ -1,4 +1,4 @@
# Wire protocol v10 # Wire protocol v11
The client talks to the server two ways: The client talks to the server two ways:
@@ -1159,10 +1159,13 @@ Each person:
| `u8` | `1` here, `2` walking | | `u8` | `1` here, `2` walking |
| `u8` | talk-circle member count, then that many person-id strings | | `u8` | talk-circle member count, then that many person-id strings |
| string | topic id (empty when not in a circle) | | string | topic id (empty when not in a circle) |
| `u8` | summon phase: `0` none, `1` going to the principal, `2` waiting in the corridor, `3` in a hearing |
Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean 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 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. on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
Summon phase is a dedicated byte because node + here/walking alone cannot tell a corridor waiter
from a passer-by (protocol v11).
### `0x87` Notice — variable ### `0x87` Notice — variable
@@ -1197,7 +1200,7 @@ Layout extra after the v10 personId field; protocol version stays 10.
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, presence and notices use a separate reliable queue so ticks cannot crowd them out. - The map snapshot, presence and notices use a separate reliable queue so ticks cannot crowd them out.
## Not in v10 yet ## Not in v11 yet
Authentication, Sit orders, a year-long event log, walk animation, and `OpenLocation` on the server — Authentication, Sit orders, a year-long 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.
+18
View File
@@ -10,6 +10,24 @@ public static class DirectorSummons
{ {
public const string HearingAction = "PrincipalHearing"; public const string HearingAction = "PrincipalHearing";
/// <summary>Presence / card label while walking to the office or wait node. Not a planner pick.</summary>
public const string GoingAction = "GoingToPrincipal";
/// <summary>Presence / card label while queued in the corridor. Not a planner pick.</summary>
public const string WaitingAction = "WaitForPrincipal";
/// <summary>Presence frame byte when not under an auto summons.</summary>
public const byte PresenceNone = 0;
/// <summary>Walking toward the wait node or office.</summary>
public const byte PresenceGoing = 1;
/// <summary>Idle in the corridor queue.</summary>
public const byte PresenceWaiting = 2;
/// <summary>In the office hearing (or admitted and already there).</summary>
public const byte PresenceHearing = 3;
public const string OfficeRoomDef = "PrincipalsOffice"; public const string OfficeRoomDef = "PrincipalsOffice";
public const string CorridorRoomDef = "Corridor"; public const string CorridorRoomDef = "Corridor";
@@ -2,7 +2,7 @@
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { PresenceState, type PresencePerson } from '../net/protocol.ts'; import { PresenceState, PresenceSummonPhase, type PresencePerson } from '../net/protocol.ts';
import { getLocale, setLocale } from '../i18n/locale.ts'; import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { locationPersonLine, talkCircleText } from './talkCircle.ts'; import { locationPersonLine, talkCircleText } from './talkCircle.ts';
@@ -20,6 +20,7 @@ describe('location talk line', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'], talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport', talkTopicId: 'TopicSport',
summonPhase: PresenceSummonPhase.None,
}; };
const names = new Map([ const names = new Map([
['f0.c0', 'Иванова Мария'], ['f0.c0', 'Иванова Мария'],
@@ -42,10 +43,42 @@ describe('location talk line', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: [], talkMemberIds: [],
talkTopicId: '', talkTopicId: '',
summonPhase: PresenceSummonPhase.None,
}; };
const names = new Map([['f0.c0', 'Иванова Мария']]); const names = new Map([['f0.c0', 'Иванова Мария']]);
expect(locationPersonLine(person, names)).toBe('Иванова Мария'); expect(locationPersonLine(person, names)).toBe('Иванова Мария');
expect(talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names)).toBe(''); expect(talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names)).toBe('');
}); });
it('labels waiting and hearing summons on the location line', () => {
setLocale('ru');
const names = new Map([['f0.c0', 'Иванова Мария']]);
expect(
locationPersonLine(
{
id: 'f0.c0',
nodeId: 'corridor-1',
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
summonPhase: PresenceSummonPhase.Waiting,
},
names,
),
).toBe(t('locationWaitingForPrincipal', { name: 'Иванова Мария' }));
expect(
locationPersonLine(
{
id: 'f0.c0',
nodeId: 'principals-office',
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
summonPhase: PresenceSummonPhase.Hearing,
},
names,
),
).toBe(t('locationPrincipalHearing', { name: 'Иванова Мария' }));
});
}); });
+19 -1
View File
@@ -1,4 +1,4 @@
import { PresenceState, type PresencePerson } from '../net/protocol.ts'; import { PresenceState, PresenceSummonPhase, type PresencePerson } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
const TOPIC_KEYS: Record<string, MessageKey> = { const TOPIC_KEYS: Record<string, MessageKey> = {
@@ -58,7 +58,25 @@ export function talkCircleText(
export function locationPersonLine(person: PresencePerson, names: ReadonlyMap<string, string>): string { export function locationPersonLine(person: PresencePerson, names: ReadonlyMap<string, string>): string {
const name = names.get(person.id) ?? person.id; const name = names.get(person.id) ?? person.id;
const summon = summonPresenceLabel(person.summonPhase, name);
if (summon.length > 0) {
return summon;
}
const base = person.state === PresenceState.Walking ? t('locationWalking', { name }) : name; const base = person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
const talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names); const talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names);
return talk.length > 0 ? `${base}${talk}` : base; return talk.length > 0 ? `${base}${talk}` : base;
} }
function summonPresenceLabel(phase: number, name: string): string {
switch (phase) {
case PresenceSummonPhase.Going:
return t('locationGoingToPrincipal', { name });
case PresenceSummonPhase.Waiting:
return t('locationWaitingForPrincipal', { name });
case PresenceSummonPhase.Hearing:
return t('locationPrincipalHearing', { name });
default:
return '';
}
}
+8
View File
@@ -367,6 +367,9 @@ const ru = {
presenceWalking: 'в пути ({name})', presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы', presenceAway: 'вне школы',
locationWalking: '{name} (в пути)', locationWalking: '{name} (в пути)',
locationGoingToPrincipal: '{name} — идёт к директору',
locationWaitingForPrincipal: '{name} — ждёт у кабинета',
locationPrincipalHearing: '{name} — на приёме',
locationTalking: 'говорит с {partners} о {topic}', locationTalking: 'говорит с {partners} о {topic}',
talkPartnersJoin: '{head} и {last}', talkPartnersJoin: '{head} и {last}',
talkTopicTopicStudy: 'учёбе', talkTopicTopicStudy: 'учёбе',
@@ -398,6 +401,7 @@ const ru = {
DayStarted: 'Начало дня', DayStarted: 'Начало дня',
LessonStarted: 'Начало урока', LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет', GenerationFailed: 'Не удалось нарисовать портрет',
DirectorSummoned: 'Ученика вызвали к директору',
noticeDismiss: 'Закрыть', noticeDismiss: 'Закрыть',
noticeGenerateImage: 'Создать картинку', noticeGenerateImage: 'Создать картинку',
noticeGenerateImageBusy: 'Рисуем…', noticeGenerateImageBusy: 'Рисуем…',
@@ -773,6 +777,9 @@ const en: Messages = {
presenceWalking: 'walking ({name})', presenceWalking: 'walking ({name})',
presenceAway: 'off campus', presenceAway: 'off campus',
locationWalking: '{name} (walking)', locationWalking: '{name} (walking)',
locationGoingToPrincipal: '{name} — going to the principal',
locationWaitingForPrincipal: '{name} — waiting at the office',
locationPrincipalHearing: '{name} — in a hearing',
locationTalking: 'talking with {partners} about {topic}', locationTalking: 'talking with {partners} about {topic}',
talkPartnersJoin: '{head} and {last}', talkPartnersJoin: '{head} and {last}',
talkTopicTopicStudy: 'schoolwork', talkTopicTopicStudy: 'schoolwork',
@@ -804,6 +811,7 @@ const en: Messages = {
DayStarted: 'The day has started', DayStarted: 'The day has started',
LessonStarted: 'A lesson has started', LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed', GenerationFailed: 'Portrait generation failed',
DirectorSummoned: 'A pupil was summoned to the principal',
noticeDismiss: 'Close', noticeDismiss: 'Close',
noticeGenerateImage: 'Create image', noticeGenerateImage: 'Create image',
noticeGenerateImageBusy: 'Drawing…', noticeGenerateImageBusy: 'Drawing…',
+61 -5
View File
@@ -12,6 +12,7 @@ import {
encodeDismissNotice, encodeDismissNotice,
MessageType, MessageType,
PresenceState, PresenceState,
PresenceSummonPhase,
ProtocolError, ProtocolError,
PROTOCOL_VERSION, PROTOCOL_VERSION,
} from './protocol.ts'; } from './protocol.ts';
@@ -274,7 +275,8 @@ describe('decodeServerMessage', () => {
+ 2 + personNode.length + 2 + personNode.length
+ 1 + 1
+ 1 + 1
+ 2, + 2
+ 1,
); );
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence); view.setUint8(0, MessageType.ServerPresence);
@@ -312,6 +314,8 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 0); view.setUint8(offset, 0);
offset += 1; offset += 1;
view.setUint16(offset, 0, true); view.setUint16(offset, 0, true);
offset += 2;
view.setUint8(offset, PresenceSummonPhase.None);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'presence', type: 'presence',
@@ -331,6 +335,7 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: [], talkMemberIds: [],
talkTopicId: '', talkTopicId: '',
summonPhase: PresenceSummonPhase.None,
}, },
], ],
}); });
@@ -358,7 +363,8 @@ describe('decodeServerMessage', () => {
+ 1 + 1
+ 2 + selfId.length + 2 + selfId.length
+ 2 + partnerId.length + 2 + partnerId.length
+ 2 + topic.length, + 2 + topic.length
+ 1,
); );
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence); view.setUint8(0, MessageType.ServerPresence);
@@ -374,7 +380,8 @@ describe('decodeServerMessage', () => {
offset += 1; offset += 1;
offset = putString(view, offset, selfId); offset = putString(view, offset, selfId);
offset = putString(view, offset, partnerId); offset = putString(view, offset, partnerId);
putString(view, offset, topic); offset = putString(view, offset, topic);
view.setUint8(offset, PresenceSummonPhase.None);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'presence', type: 'presence',
@@ -387,6 +394,7 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'], talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport', talkTopicId: 'TopicSport',
summonPhase: PresenceSummonPhase.None,
}, },
], ],
}); });
@@ -404,7 +412,7 @@ describe('decodeServerMessage', () => {
const selfId = encoder.encode('f0.c0'); const selfId = encoder.encode('f0.c0');
const topic = encoder.encode('TopicSport'); const topic = encoder.encode('TopicSport');
const buffer = new ArrayBuffer( const buffer = new ArrayBuffer(
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length, 7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length + 1,
); );
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence); view.setUint8(0, MessageType.ServerPresence);
@@ -419,7 +427,8 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 1); view.setUint8(offset, 1);
offset += 1; offset += 1;
offset = putString(view, offset, selfId); offset = putString(view, offset, selfId);
putString(view, offset, topic); offset = putString(view, offset, topic);
view.setUint8(offset, PresenceSummonPhase.None);
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
const text = new TextDecoder().decode(bytes); const text = new TextDecoder().decode(bytes);
@@ -439,6 +448,53 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: ['f0.c0'], talkMemberIds: ['f0.c0'],
talkTopicId: 'TopicSport', talkTopicId: 'TopicSport',
summonPhase: PresenceSummonPhase.None,
},
],
});
});
it('reads a presence summon phase byte after the topic', () => {
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 buffer = new ArrayBuffer(
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + 1,
);
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, 0);
offset += 1;
view.setUint16(offset, 0, true);
offset += 2;
view.setUint8(offset, PresenceSummonPhase.Waiting);
expect(decodeServerMessage(buffer)).toEqual({
type: 'presence',
schoolId: 1,
nodes: [],
people: [
{
id: 'f0.c0',
nodeId: 'corridor-1',
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
summonPhase: PresenceSummonPhase.Waiting,
}, },
], ],
}); });
+14 -1
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 = 10; export const PROTOCOL_VERSION = 11;
export const MessageType = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -113,6 +113,14 @@ export const PresenceState = {
Walking: 2, Walking: 2,
} as const; } as const;
/** Auto director summons on the presence person. Matches server PresenceSummonPhase. */
export const PresenceSummonPhase = {
None: 0,
Going: 1,
Waiting: 2,
Hearing: 3,
} as const;
export interface PresenceNode { export interface PresenceNode {
readonly id: string; readonly id: string;
readonly count: number; readonly count: number;
@@ -126,6 +134,8 @@ export interface PresencePerson {
readonly state: number; readonly state: number;
readonly talkMemberIds: readonly string[]; readonly talkMemberIds: readonly string[];
readonly talkTopicId: string; readonly talkTopicId: string;
/** PresenceSummonPhase; 0 when not summoned. */
readonly summonPhase: number;
} }
export interface PresenceMessage { export interface PresenceMessage {
@@ -420,12 +430,15 @@ function decodePresence(view: DataView): PresenceMessage {
const topic = readString(view, offset); const topic = readString(view, offset);
offset = topic.next; offset = topic.next;
const summonPhase = readU8(view, offset);
offset += 1;
people.push({ people.push({
id: id.text, id: id.text,
nodeId: nodeId.text, nodeId: nodeId.text,
state, state,
talkMemberIds, talkMemberIds,
talkTopicId: topic.text, talkTopicId: topic.text,
summonPhase,
}); });
} }
@@ -266,6 +266,7 @@ describe('GameScreen location talk', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'], talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport', talkTopicId: 'TopicSport',
summonPhase: 0,
}, },
{ {
id: 'f0.c1', id: 'f0.c1',
@@ -273,6 +274,7 @@ describe('GameScreen location talk', () => {
state: PresenceState.Here, state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'], talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport', talkTopicId: 'TopicSport',
summonPhase: 0,
}, },
], ],
}; };
@@ -9,6 +9,7 @@ export function noticeLabel(defName: string): string {
case 'DayStarted': case 'DayStarted':
case 'LessonStarted': case 'LessonStarted':
case 'GenerationFailed': case 'GenerationFailed':
case 'DirectorSummoned':
return t(defName satisfies MessageKey); return t(defName satisfies MessageKey);
default: default:
return defName; return defName;
+1
View File
@@ -14,6 +14,7 @@ internal static class EventDefValidator
EventTriggers.DayStart, EventTriggers.DayStart,
EventTriggers.LessonStart, EventTriggers.LessonStart,
EventTriggers.GenerationFailed, EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
}; };
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal) private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
+1
View File
@@ -12,6 +12,7 @@ public static class EventTriggers
public const string DayStart = "dayStart"; public const string DayStart = "dayStart";
public const string LessonStart = "lessonStart"; public const string LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed"; public const string GenerationFailed = "generationFailed";
public const string DirectorSummon = "directorSummon";
} }
public static class EventActions public static class EventActions
+17 -2
View File
@@ -122,6 +122,19 @@ public static class PresenceState
public const byte Walking = 2; public const byte Walking = 2;
} }
/// <summary>
/// Auto director summons on the presence frame. Node + here/walking alone cannot tell a corridor
/// waiter from a passer-by, so this is a dedicated byte (protocol v11). Values match
/// <c>HSchool.Ai.DirectorSummons.Presence*</c>.
/// </summary>
public static class PresenceSummonPhase
{
public const byte None = 0;
public const byte Going = 1;
public const byte Waiting = 2;
public const byte Hearing = 3;
}
/// <summary>One occupied (or currently taught) map node in a presence frame.</summary> /// <summary>One occupied (or currently taught) map node in a presence frame.</summary>
public sealed record PresenceNode( public sealed record PresenceNode(
string Id, string Id,
@@ -132,16 +145,18 @@ public sealed record PresenceNode(
/// <summary> /// <summary>
/// One on-campus person. Names are resolved over HTTP, not on this frame. Talk members and /// 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. /// topic are ids; an empty list means the person is not in a circle.
/// <see cref="SummonPhase"/> is <see cref="PresenceSummonPhase"/> (0 when not summoned).
/// </summary> /// </summary>
public sealed record PresencePerson( public sealed record PresencePerson(
string Id, string Id,
string NodeId, string NodeId,
byte State, byte State,
IReadOnlyList<string> TalkMemberIds, IReadOnlyList<string> TalkMemberIds,
string TalkTopicId) string TalkTopicId,
byte SummonPhase = PresenceSummonPhase.None)
{ {
public PresencePerson(string id, string nodeId, byte state) public PresencePerson(string id, string nodeId, byte state)
: this(id, nodeId, state, [], "") : this(id, nodeId, state, [], "", PresenceSummonPhase.None)
{ {
} }
} }
+4 -1
View File
@@ -218,6 +218,7 @@ public static class ProtocolCodec
} }
size += StringSize(person.TalkTopicId ?? ""); size += StringSize(person.TalkTopicId ?? "");
size += sizeof(byte);
} }
return size; return size;
@@ -274,6 +275,7 @@ public static class ProtocolCodec
} }
writer.WriteString(person.TalkTopicId ?? ""); writer.WriteString(person.TalkTopicId ?? "");
writer.WriteByte(person.SummonPhase);
} }
return writer.Position; return writer.Position;
@@ -460,7 +462,8 @@ public static class ProtocolCodec
} }
var topicId = reader.ReadString(); var topicId = reader.ReadString();
people[i] = new PresencePerson(id, nodeId, state, members, topicId); var summonPhase = reader.ReadByte();
people[i] = new PresencePerson(id, nodeId, state, members, topicId, summonPhase);
} }
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 = 10; public const byte Version = 11;
/// <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;
+2 -1
View File
@@ -61,7 +61,8 @@ internal static partial class PersonCardReader
var needs = LiveNeeds(school.World, personId) ?? person.Needs; var needs = LiveNeeds(school.World, personId) ?? person.Needs;
var skills = LiveSkills(school.World, personId); var skills = LiveSkills(school.World, personId);
var activityId = LiveActivity(school.World, personId); var activityId = LiveActivity(school.World, personId)
?? school.DirectorSummonPresenceActionId(personId);
string? activityLabel = null; string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action)) if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
{ {
+3 -1
View File
@@ -26,12 +26,14 @@ 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); var circle = school.TalkCircleOf(row.PersonId);
var summonPhase = school.DirectorSummonPresencePhase(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?.MemberIds ?? [],
circle?.TopicId ?? "")); circle?.TopicId ?? "",
summonPhase));
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1; counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
} }
+1 -1
View File
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
continue; continue;
} }
TryEmitNotice(school, def); TryEmitNotice(school, def, fact.PersonKey);
} }
} }
} }
@@ -140,6 +140,21 @@
"roles": ["student"], "roles": ["student"],
"weight": 0, "weight": 0,
}, },
{
// Wire / card labels for summons presence (phase 71). Weight 0 — never started by the planner.
"defName": "GoingToPrincipal",
"room": "Corridor",
"minutes": 1,
"roles": ["student"],
"weight": 0,
},
{
"defName": "WaitForPrincipal",
"room": "Corridor",
"minutes": 1,
"roles": ["student"],
"weight": 0,
},
{ {
"defName": "ChangeClothesMale", "defName": "ChangeClothesMale",
"room": "MaleChangingRoom", "room": "MaleChangingRoom",
@@ -23,4 +23,12 @@
"trigger": "generationFailed", "trigger": "generationFailed",
"action": "generateImage", "action": "generateImage",
}, },
{
"defName": "DirectorSummoned",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "directorSummon",
"action": "none",
},
] ]
@@ -231,5 +231,8 @@
"DayStarted": "The day has started", "DayStarted": "The day has started",
"LessonStarted": "A lesson has started", "LessonStarted": "A lesson has started",
"GenerationFailed": "Portrait generation failed", "GenerationFailed": "Portrait generation failed",
"DirectorSummoned": "A pupil was summoned to the principal",
"GoingToPrincipal": "going to the principal",
"WaitForPrincipal": "waiting at the principal's office",
"core": "Core", "core": "Core",
} }
@@ -231,5 +231,8 @@
"DayStarted": "Начало дня", "DayStarted": "Начало дня",
"LessonStarted": "Начало урока", "LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет", "GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"core": "Базовая игра", "core": "Базовая игра",
} }
@@ -104,9 +104,73 @@ internal static class DirectorSummonSystem
school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false)); school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false));
PresenceSystem.Enqueue(school, personId); PresenceSystem.Enqueue(school, personId);
school.RaiseWorldEvent(new WorldEvent(EventTriggers.DirectorSummon, personId));
return true; return true;
} }
/// <summary>
/// Wire tag for the presence frame: going / waiting / hearing. Node + state alone cannot tell
/// a corridor waiter from a passer-by, so this is a dedicated byte (protocol bump).
/// </summary>
public static byte ResolvePresencePhase(School school, string personId)
{
var ticket = school.DirectorSummons.FirstOrDefault(row =>
row.PersonId.Equals(personId, StringComparison.Ordinal));
if (ticket is null)
{
return DirectorSummons.PresenceNone;
}
var walking = false;
var hearing = false;
string? nodeId = null;
school.World.Query(
in PresenceQuery,
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
{
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
nodeId = presence.NodeId;
walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|| (presence.DestinationId is not null
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
hearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
});
if (hearing)
{
return DirectorSummons.PresenceHearing;
}
if (walking)
{
return DirectorSummons.PresenceGoing;
}
var office = DirectorSummons.OfficeNodeId(school.Map!);
if (ticket.Admitted
&& office is not null
&& nodeId is not null
&& nodeId.Equals(office, StringComparison.Ordinal))
{
return DirectorSummons.PresenceHearing;
}
return DirectorSummons.PresenceWaiting;
}
/// <summary>ActionDef name for the person card when the live activity is idle but summoned.</summary>
public static string? PresenceActionId(byte phase) => phase switch
{
DirectorSummons.PresenceGoing => DirectorSummons.GoingAction,
DirectorSummons.PresenceWaiting => DirectorSummons.WaitingAction,
DirectorSummons.PresenceHearing => DirectorSummons.HearingAction,
_ => null,
};
public static void ClearAll(School school) public static void ClearAll(School school)
{ {
if (school.DirectorSummons.Count == 0) if (school.DirectorSummons.Count == 0)
+14
View File
@@ -426,6 +426,20 @@ public sealed class School : IDisposable
return copy; return copy;
} }
/// <summary>Queue a fact for the worker's notice board. Simulation never builds UI itself.</summary>
internal void RaiseWorldEvent(in WorldEvent fact) => _worldEvents.Add(fact);
/// <summary>
/// Presence wire tag for auto director summons. Values match <c>DirectorSummons.Presence*</c>
/// and <see cref="Protocol.PresenceSummonPhase"/>.
/// </summary>
public byte DirectorSummonPresencePhase(string personId) =>
DirectorSummonSystem.ResolvePresencePhase(this, personId);
/// <summary>ActionDef id for the person card when summoned but the live activity is idle.</summary>
public string? DirectorSummonPresenceActionId(string personId) =>
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
private void RecordWorldEvents(DateTime before, DateTime after) private void RecordWorldEvents(DateTime before, DateTime after)
{ {
_worldEvents.AddRange(EventSystem.Detect(this, before, after)); _worldEvents.AddRange(EventSystem.Detect(this, before, after));
+2 -1
View File
@@ -2,5 +2,6 @@ namespace HSchool.Simulation;
/// <summary> /// <summary>
/// A fact the school raised this step. Same seam as <c>AffinityEvent</c>: a list, no UI. /// A fact the school raised this step. Same seam as <c>AffinityEvent</c>: a list, no UI.
/// <see cref="PersonKey"/> is empty when the fact is school-wide (morning, lesson bell).
/// </summary> /// </summary>
public readonly record struct WorldEvent(string Trigger); public readonly record struct WorldEvent(string Trigger, string PersonKey = "");
@@ -33,6 +33,15 @@ public class EventDefTests
Assert.Equal(EventActions.GenerateImage, catalog.Events["GenerationFailed"].Action); Assert.Equal(EventActions.GenerateImage, catalog.Events["GenerationFailed"].Action);
Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"])); Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"]));
Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"])); Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"]));
Assert.True(catalog.Events.ContainsKey("DirectorSummoned"));
Assert.Equal(EventSeverities.Info, catalog.Events["DirectorSummoned"].Severity);
Assert.False(catalog.Events["DirectorSummoned"].Pause);
Assert.Equal(8000, catalog.Events["DirectorSummoned"].TtlMs);
Assert.Equal(EventTriggers.DirectorSummon, catalog.Events["DirectorSummoned"].Trigger);
Assert.Equal(EventActions.None, catalog.Events["DirectorSummoned"].Action);
Assert.Equal("Ученика вызвали к директору", catalog.Label("ru", catalog.Events["DirectorSummoned"]));
Assert.Equal("A pupil was summoned to the principal", catalog.Label("en", catalog.Events["DirectorSummoned"]));
} }
[Fact] [Fact]
@@ -285,8 +285,10 @@ public class ProtocolCodecTests
Assert.Equal(PresenceState.Here, read.People[0].State); Assert.Equal(PresenceState.Here, read.People[0].State);
Assert.Empty(read.People[0].TalkMemberIds); Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId); Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
Assert.Equal(PresenceState.Walking, read.People[1].State); Assert.Equal(PresenceState.Walking, read.People[1].State);
Assert.Empty(read.People[1].TalkMemberIds); Assert.Empty(read.People[1].TalkMemberIds);
Assert.Equal(PresenceSummonPhase.None, read.People[1].SummonPhase);
} }
[Fact] [Fact]
@@ -313,12 +315,15 @@ public class ProtocolCodecTests
Assert.Equal(0, buffer[offset]); Assert.Equal(0, buffer[offset]);
offset += 1; offset += 1;
offset = AssertWireString(buffer, offset, ""); offset = AssertWireString(buffer, offset, "");
Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
offset += 1;
Assert.Equal(length, offset); Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message)); Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length)); var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Empty(read.People[0].TalkMemberIds); Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId); Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
Assert.Equal(message.People[0].Id, read.People[0].Id); 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].NodeId, read.People[0].NodeId);
Assert.Equal(message.People[0].State, read.People[0].State); Assert.Equal(message.People[0].State, read.People[0].State);
@@ -357,16 +362,52 @@ public class ProtocolCodecTests
offset = AssertWireString(buffer, offset, "f0.c0"); offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "f0.c1"); offset = AssertWireString(buffer, offset, "f0.c1");
offset = AssertWireString(buffer, offset, "TopicSport"); offset = AssertWireString(buffer, offset, "TopicSport");
Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
offset += 1;
Assert.Equal(length, offset); Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message)); Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length)); var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds); Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds);
Assert.Equal("TopicSport", read.People[0].TalkTopicId); Assert.Equal("TopicSport", read.People[0].TalkTopicId);
Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
Assert.Equal(message.SchoolId, read.SchoolId); Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(message.People[0].Id, read.People[0].Id); Assert.Equal(message.People[0].Id, read.People[0].Id);
} }
[Fact]
public void Presence_SummonPhase_RoundTripsAndMatchesByteLayout()
{
var message = new ServerPresenceMessage(
1,
[],
[new PresencePerson(
"f0.c0",
"corridor-1",
PresenceState.Here,
[],
"",
PresenceSummonPhase.Waiting)]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(buffer, message);
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(PresenceSummonPhase.Waiting, buffer[offset]);
offset += 1;
Assert.Equal(length, offset);
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Equal(PresenceSummonPhase.Waiting, Assert.Single(read.People).SummonPhase);
}
[Fact] [Fact]
public void Presence_DoesNotContainPersonDisplayName() public void Presence_DoesNotContainPersonDisplayName()
{ {
@@ -95,6 +95,18 @@ public class NoticeBoardTests
Assert.Equal("avatar", sticky.Kind); Assert.Equal("avatar", sticky.Kind);
} }
[Fact]
public void DirectorSummonedInfo_IsNotStickyInSave()
{
var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(DirectorSummoned(), personKey: "a0.p0", kind: "", out var info));
Assert.Equal("DirectorSummoned", info.DefName);
Assert.False(info.Pause);
Assert.Empty(board.Sticky);
Assert.DoesNotContain(board.ToSave(), row => row.DefName == "DirectorSummoned");
}
private static EventDef Info() => new() private static EventDef Info() => new()
{ {
DefName = "DayStarted", DefName = "DayStarted",
@@ -105,6 +117,16 @@ public class NoticeBoardTests
Action = EventActions.None, Action = EventActions.None,
}; };
private static EventDef DirectorSummoned() => new()
{
DefName = "DirectorSummoned",
Severity = EventSeverities.Info,
Pause = false,
TtlMs = 8000,
Trigger = EventTriggers.DirectorSummon,
Action = EventActions.None,
};
private static EventDef Pausing() => new() private static EventDef Pausing() => new()
{ {
DefName = "GenerationFailed", DefName = "GenerationFailed",
@@ -112,7 +112,16 @@ public class DirectorSummonTests
name => name.Contains("Summon", StringComparison.OrdinalIgnoreCase)); name => name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain( Assert.DoesNotContain(
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public), typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
method => method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase)); method => method.Name.StartsWith("Set", StringComparison.Ordinal)
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
method => method.Name.StartsWith("Send", StringComparison.Ordinal)
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
method => method.Name.StartsWith("Enqueue", StringComparison.Ordinal)
&& method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
} }
[Fact] [Fact]
@@ -123,6 +132,67 @@ public class DirectorSummonTests
Assert.Equal("corridor-1", DirectorSummons.WaitNodeId(map)); Assert.Equal("corridor-1", DirectorSummons.WaitNodeId(map));
} }
[Fact]
public void QualifyingSummon_RaisesDirectorSummonWorldEvent()
{
var (school, first, _, _) = TwoPupilsPrincipalAndTeacherOnBreak();
using (school)
{
school.DrainWorldEvents();
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
var facts = school.DrainWorldEvents();
var fact = Assert.Single(facts, row => row.Trigger.Equals(EventTriggers.DirectorSummon, StringComparison.Ordinal));
Assert.Equal(first.Id, fact.PersonKey);
}
}
[Fact]
public void QueueAdvancesWithoutOpenClient()
{
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
using (school)
{
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
DirectorSummonSystem.Apply(school);
var head = school.DirectorSummons.OrderBy(row => row.Order).First();
var next = school.DirectorSummons.OrderBy(row => row.Order).Skip(1).First();
PlaceAt(school, head.PersonId, "principals-office");
DirectorSummonSystem.Apply(school);
Assert.True(ActivitySystem.IsPrincipalHearing(ActivityOf(school, head.PersonId)));
DirectorSummonSystem.CompleteHearing(school, head.PersonId);
DirectorSummonSystem.Apply(school);
Assert.False(DirectorSummonSystem.IsSummoned(school, head.PersonId));
Assert.True(DirectorSummonSystem.IsSummoned(school, next.PersonId));
PlaceAt(school, next.PersonId, "corridor-1");
Assert.Equal(DirectorSummons.PresenceWaiting, school.DirectorSummonPresencePhase(next.PersonId));
}
}
[Fact]
public void SummonPresencePhase_WaitingInCorridor_HearingInOffice()
{
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
using (school)
{
Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
DirectorSummonSystem.Apply(school);
var ordered = school.DirectorSummons.OrderBy(row => row.Order).ToArray();
PlaceAt(school, ordered[0].PersonId, "principals-office");
PlaceAt(school, ordered[1].PersonId, "corridor-1");
DirectorSummonSystem.Apply(school);
Assert.Equal(DirectorSummons.PresenceHearing, school.DirectorSummonPresencePhase(ordered[0].PersonId));
Assert.Equal(DirectorSummons.PresenceWaiting, school.DirectorSummonPresencePhase(ordered[1].PersonId));
}
}
private static IReadOnlyList<string> QueueOrderFromFight(int seed) private static IReadOnlyList<string> QueueOrderFromFight(int seed)
{ {
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed); var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed);