diff --git a/docs/phases/12-office/71-summons-notices.md b/docs/phases/12-office/71-summons-notices.md
index 46174f3..88eb0c1 100644
--- a/docs/phases/12-office/71-summons-notices.md
+++ b/docs/phases/12-office/71-summons-notices.md
@@ -11,21 +11,21 @@
## Задачи
-- [ ] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений
+- [x] `EventDef` info на авто-вызов (ваниль в `core`); факт из симуляции → доска уведомлений
среза 11
-- [ ] Тост не паузит часы; не в сейве sticky
-- [ ] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме»
+- [x] Тост не паузит часы; не в сейве sticky
+- [x] В панели локации / присутствии понятно «идёт к директору» / «ждёт у кабинета» / «на приёме»
— через действие и узел; новый байт в кадре присутствия — только если иначе нельзя; тогда
протокол +1 в этом коммите (`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`)
-- [ ] Гость видит те же info; ничего не назначает
-- [ ] Локали RU/EN
+- [x] Гость видит те же info; ничего не назначает
+- [x] Локали RU/EN
## Тесты, без которых фаза не закрыта
-- [ ] Qualifying-вызов эмитит notice с ожидаемым `EventDef`
-- [ ] Info не пишет sticky в сейв
-- [ ] Если бамп протокола — round-trip и byte-layout на C# и TS
-- [ ] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
+- [x] Qualifying-вызов эмитит notice с ожидаемым `EventDef`
+- [x] Info не пишет sticky в сейв
+- [x] Если бамп протокола — round-trip и byte-layout на C# и TS
+- [x] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
## Критерий готовности
diff --git a/docs/phases/12-office/README.md b/docs/phases/12-office/README.md
index 2646a31..27c0b68 100644
--- a/docs/phases/12-office/README.md
+++ b/docs/phases/12-office/README.md
@@ -21,7 +21,7 @@
| --- | --- | --- |
| [69. Память проступков](69-offense-memory.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.
diff --git a/docs/protocol.md b/docs/protocol.md
index 3d21eab..3c21c9d 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -1,4 +1,4 @@
-# Wire protocol v10
+# Wire protocol v11
The client talks to the server two ways:
@@ -1159,10 +1159,13 @@ Each person:
| `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) |
+| `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
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.
+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
@@ -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 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 —
the tree and the location panel are filtered on the client from the snapshot plus presence.
diff --git a/src/HSchool.Ai/DirectorSummons.cs b/src/HSchool.Ai/DirectorSummons.cs
index 6804628..1465b1a 100644
--- a/src/HSchool.Ai/DirectorSummons.cs
+++ b/src/HSchool.Ai/DirectorSummons.cs
@@ -10,6 +10,24 @@ public static class DirectorSummons
{
public const string HearingAction = "PrincipalHearing";
+ /// Presence / card label while walking to the office or wait node. Not a planner pick.
+ public const string GoingAction = "GoingToPrincipal";
+
+ /// Presence / card label while queued in the corridor. Not a planner pick.
+ public const string WaitingAction = "WaitForPrincipal";
+
+ /// Presence frame byte when not under an auto summons.
+ public const byte PresenceNone = 0;
+
+ /// Walking toward the wait node or office.
+ public const byte PresenceGoing = 1;
+
+ /// Idle in the corridor queue.
+ public const byte PresenceWaiting = 2;
+
+ /// In the office hearing (or admitted and already there).
+ public const byte PresenceHearing = 3;
+
public const string OfficeRoomDef = "PrincipalsOffice";
public const string CorridorRoomDef = "Corridor";
diff --git a/src/HSchool.Client/src/format/talkCircle.test.ts b/src/HSchool.Client/src/format/talkCircle.test.ts
index e943590..7a10904 100644
--- a/src/HSchool.Client/src/format/talkCircle.test.ts
+++ b/src/HSchool.Client/src/format/talkCircle.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
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 { t } from '../i18n/strings.ts';
import { locationPersonLine, talkCircleText } from './talkCircle.ts';
@@ -20,6 +20,7 @@ describe('location talk line', () => {
state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport',
+ summonPhase: PresenceSummonPhase.None,
};
const names = new Map([
['f0.c0', 'Иванова Мария'],
@@ -42,10 +43,42 @@ describe('location talk line', () => {
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
+ summonPhase: PresenceSummonPhase.None,
};
const names = new Map([['f0.c0', 'Иванова Мария']]);
expect(locationPersonLine(person, 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: 'Иванова Мария' }));
+ });
});
diff --git a/src/HSchool.Client/src/format/talkCircle.ts b/src/HSchool.Client/src/format/talkCircle.ts
index 2e32453..cf52ae7 100644
--- a/src/HSchool.Client/src/format/talkCircle.ts
+++ b/src/HSchool.Client/src/format/talkCircle.ts
@@ -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';
const TOPIC_KEYS: Record = {
@@ -58,7 +58,25 @@ export function talkCircleText(
export function locationPersonLine(person: PresencePerson, names: ReadonlyMap): string {
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 talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names);
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 '';
+ }
+}
diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts
index 9142628..cc2df11 100644
--- a/src/HSchool.Client/src/i18n/strings.ts
+++ b/src/HSchool.Client/src/i18n/strings.ts
@@ -367,6 +367,9 @@ const ru = {
presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы',
locationWalking: '{name} (в пути)',
+ locationGoingToPrincipal: '{name} — идёт к директору',
+ locationWaitingForPrincipal: '{name} — ждёт у кабинета',
+ locationPrincipalHearing: '{name} — на приёме',
locationTalking: 'говорит с {partners} о {topic}',
talkPartnersJoin: '{head} и {last}',
talkTopicTopicStudy: 'учёбе',
@@ -398,6 +401,7 @@ const ru = {
DayStarted: 'Начало дня',
LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет',
+ DirectorSummoned: 'Ученика вызвали к директору',
noticeDismiss: 'Закрыть',
noticeGenerateImage: 'Создать картинку',
noticeGenerateImageBusy: 'Рисуем…',
@@ -773,6 +777,9 @@ const en: Messages = {
presenceWalking: 'walking ({name})',
presenceAway: 'off campus',
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}',
talkPartnersJoin: '{head} and {last}',
talkTopicTopicStudy: 'schoolwork',
@@ -804,6 +811,7 @@ const en: Messages = {
DayStarted: 'The day has started',
LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed',
+ DirectorSummoned: 'A pupil was summoned to the principal',
noticeDismiss: 'Close',
noticeGenerateImage: 'Create image',
noticeGenerateImageBusy: 'Drawing…',
diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts
index bb848f6..8b01db9 100644
--- a/src/HSchool.Client/src/net/protocol.test.ts
+++ b/src/HSchool.Client/src/net/protocol.test.ts
@@ -12,6 +12,7 @@ import {
encodeDismissNotice,
MessageType,
PresenceState,
+ PresenceSummonPhase,
ProtocolError,
PROTOCOL_VERSION,
} from './protocol.ts';
@@ -274,7 +275,8 @@ describe('decodeServerMessage', () => {
+ 2 + personNode.length
+ 1
+ 1
- + 2,
+ + 2
+ + 1,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence);
@@ -312,6 +314,8 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 0);
offset += 1;
view.setUint16(offset, 0, true);
+ offset += 2;
+ view.setUint8(offset, PresenceSummonPhase.None);
expect(decodeServerMessage(buffer)).toEqual({
type: 'presence',
@@ -331,6 +335,7 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
+ summonPhase: PresenceSummonPhase.None,
},
],
});
@@ -358,7 +363,8 @@ describe('decodeServerMessage', () => {
+ 1
+ 2 + selfId.length
+ 2 + partnerId.length
- + 2 + topic.length,
+ + 2 + topic.length
+ + 1,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence);
@@ -374,7 +380,8 @@ describe('decodeServerMessage', () => {
offset += 1;
offset = putString(view, offset, selfId);
offset = putString(view, offset, partnerId);
- putString(view, offset, topic);
+ offset = putString(view, offset, topic);
+ view.setUint8(offset, PresenceSummonPhase.None);
expect(decodeServerMessage(buffer)).toEqual({
type: 'presence',
@@ -387,6 +394,7 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport',
+ summonPhase: PresenceSummonPhase.None,
},
],
});
@@ -404,7 +412,7 @@ describe('decodeServerMessage', () => {
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,
+ 7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length + 1,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence);
@@ -419,7 +427,8 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 1);
offset += 1;
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 text = new TextDecoder().decode(bytes);
@@ -439,6 +448,53 @@ describe('decodeServerMessage', () => {
state: PresenceState.Here,
talkMemberIds: ['f0.c0'],
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,
},
],
});
diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts
index 6cd85bb..096d4fd 100644
--- a/src/HSchool.Client/src/net/protocol.ts
+++ b/src/HSchool.Client/src/net/protocol.ts
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
-export const PROTOCOL_VERSION = 10;
+export const PROTOCOL_VERSION = 11;
export const MessageType = {
ClientHello: 0x01,
@@ -113,6 +113,14 @@ export const PresenceState = {
Walking: 2,
} 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 {
readonly id: string;
readonly count: number;
@@ -126,6 +134,8 @@ export interface PresencePerson {
readonly state: number;
readonly talkMemberIds: readonly string[];
readonly talkTopicId: string;
+ /** PresenceSummonPhase; 0 when not summoned. */
+ readonly summonPhase: number;
}
export interface PresenceMessage {
@@ -420,12 +430,15 @@ function decodePresence(view: DataView): PresenceMessage {
const topic = readString(view, offset);
offset = topic.next;
+ const summonPhase = readU8(view, offset);
+ offset += 1;
people.push({
id: id.text,
nodeId: nodeId.text,
state,
talkMemberIds,
talkTopicId: topic.text,
+ summonPhase,
});
}
diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts
index 9a23b92..3b31c2e 100644
--- a/src/HSchool.Client/src/ui/gameScreen.test.ts
+++ b/src/HSchool.Client/src/ui/gameScreen.test.ts
@@ -266,6 +266,7 @@ describe('GameScreen location talk', () => {
state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport',
+ summonPhase: 0,
},
{
id: 'f0.c1',
@@ -273,6 +274,7 @@ describe('GameScreen location talk', () => {
state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport',
+ summonPhase: 0,
},
],
};
diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts
index 38c7065..1353c86 100644
--- a/src/HSchool.Client/src/ui/noticeToasts.ts
+++ b/src/HSchool.Client/src/ui/noticeToasts.ts
@@ -9,6 +9,7 @@ export function noticeLabel(defName: string): string {
case 'DayStarted':
case 'LessonStarted':
case 'GenerationFailed':
+ case 'DirectorSummoned':
return t(defName satisfies MessageKey);
default:
return defName;
diff --git a/src/HSchool.Content/EventDefValidator.cs b/src/HSchool.Content/EventDefValidator.cs
index 597cc3e..44da79b 100644
--- a/src/HSchool.Content/EventDefValidator.cs
+++ b/src/HSchool.Content/EventDefValidator.cs
@@ -14,6 +14,7 @@ internal static class EventDefValidator
EventTriggers.DayStart,
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
+ EventTriggers.DirectorSummon,
};
private static readonly HashSet Actions = new(StringComparer.Ordinal)
diff --git a/src/HSchool.Content/EventDefs.cs b/src/HSchool.Content/EventDefs.cs
index 41244c9..d21f653 100644
--- a/src/HSchool.Content/EventDefs.cs
+++ b/src/HSchool.Content/EventDefs.cs
@@ -12,6 +12,7 @@ public static class EventTriggers
public const string DayStart = "dayStart";
public const string LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed";
+ public const string DirectorSummon = "directorSummon";
}
public static class EventActions
diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs
index bbbd987..c5985c2 100644
--- a/src/HSchool.Protocol/Messages.cs
+++ b/src/HSchool.Protocol/Messages.cs
@@ -122,6 +122,19 @@ public static class PresenceState
public const byte Walking = 2;
}
+///
+/// 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
+/// HSchool.Ai.DirectorSummons.Presence*.
+///
+public static class PresenceSummonPhase
+{
+ public const byte None = 0;
+ public const byte Going = 1;
+ public const byte Waiting = 2;
+ public const byte Hearing = 3;
+}
+
/// One occupied (or currently taught) map node in a presence frame.
public sealed record PresenceNode(
string Id,
@@ -132,16 +145,18 @@ public sealed record PresenceNode(
///
/// 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.
+/// is (0 when not summoned).
///
public sealed record PresencePerson(
string Id,
string NodeId,
byte State,
IReadOnlyList TalkMemberIds,
- string TalkTopicId)
+ string TalkTopicId,
+ byte SummonPhase = PresenceSummonPhase.None)
{
public PresencePerson(string id, string nodeId, byte state)
- : this(id, nodeId, state, [], "")
+ : this(id, nodeId, state, [], "", PresenceSummonPhase.None)
{
}
}
diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs
index c174e14..13433a1 100644
--- a/src/HSchool.Protocol/ProtocolCodec.cs
+++ b/src/HSchool.Protocol/ProtocolCodec.cs
@@ -218,6 +218,7 @@ public static class ProtocolCodec
}
size += StringSize(person.TalkTopicId ?? "");
+ size += sizeof(byte);
}
return size;
@@ -274,6 +275,7 @@ public static class ProtocolCodec
}
writer.WriteString(person.TalkTopicId ?? "");
+ writer.WriteByte(person.SummonPhase);
}
return writer.Position;
@@ -460,7 +462,8 @@ public static class ProtocolCodec
}
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);
diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs
index 0db9598..49c6926 100644
--- a/src/HSchool.Protocol/ProtocolConstants.cs
+++ b/src/HSchool.Protocol/ProtocolConstants.cs
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// Bumped on every breaking change to the binary layout.
- public const byte Version = 10;
+ public const byte Version = 11;
/// Upper bound for a single WebSocket frame accepted by the server.
public const int MaxMessageSize = 8 * 1024;
diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs
index 2896478..841a966 100644
--- a/src/HSchool.Server/Game/PersonCardReader.cs
+++ b/src/HSchool.Server/Game/PersonCardReader.cs
@@ -61,7 +61,8 @@ internal static partial class PersonCardReader
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
var skills = LiveSkills(school.World, personId);
- var activityId = LiveActivity(school.World, personId);
+ var activityId = LiveActivity(school.World, personId)
+ ?? school.DirectorSummonPresenceActionId(personId);
string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
{
diff --git a/src/HSchool.Server/Game/PresenceFrame.cs b/src/HSchool.Server/Game/PresenceFrame.cs
index 4c33441..6bbafa1 100644
--- a/src/HSchool.Server/Game/PresenceFrame.cs
+++ b/src/HSchool.Server/Game/PresenceFrame.cs
@@ -26,12 +26,14 @@ internal static class PresenceFrame
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
var circle = school.TalkCircleOf(row.PersonId);
+ var summonPhase = school.DirectorSummonPresencePhase(row.PersonId);
people.Add(new PresencePerson(
row.PersonId,
row.NodeId,
walking ? PresenceState.Walking : PresenceState.Here,
circle?.MemberIds ?? [],
- circle?.TopicId ?? ""));
+ circle?.TopicId ?? "",
+ summonPhase));
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
}
diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs
index 7f817e2..7fcf815 100644
--- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
continue;
}
- TryEmitNotice(school, def);
+ TryEmitNotice(school, def, fact.PersonKey);
}
}
}
diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
index c338ec6..74a27a0 100644
--- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc
+++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
@@ -140,6 +140,21 @@
"roles": ["student"],
"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",
"room": "MaleChangingRoom",
diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
index e46876e..3139ba0 100644
--- a/src/HSchool.Server/mods/core/defs/events/notices.jsonc
+++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
@@ -23,4 +23,12 @@
"trigger": "generationFailed",
"action": "generateImage",
},
+ {
+ "defName": "DirectorSummoned",
+ "severity": "info",
+ "pause": false,
+ "ttlMs": 8000,
+ "trigger": "directorSummon",
+ "action": "none",
+ },
]
diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc
index a88d0be..a5df004 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -231,5 +231,8 @@
"DayStarted": "The day has started",
"LessonStarted": "A lesson has started",
"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",
}
diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc
index 9726d87..36bb33b 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -231,5 +231,8 @@
"DayStarted": "Начало дня",
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
+ "DirectorSummoned": "Ученика вызвали к директору",
+ "GoingToPrincipal": "идёт к директору",
+ "WaitForPrincipal": "ждёт у кабинета директора",
"core": "Базовая игра",
}
diff --git a/src/HSchool.Simulation/DirectorSummonSystem.cs b/src/HSchool.Simulation/DirectorSummonSystem.cs
index 2d40029..27ea0f4 100644
--- a/src/HSchool.Simulation/DirectorSummonSystem.cs
+++ b/src/HSchool.Simulation/DirectorSummonSystem.cs
@@ -104,9 +104,73 @@ internal static class DirectorSummonSystem
school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false));
PresenceSystem.Enqueue(school, personId);
+ school.RaiseWorldEvent(new WorldEvent(EventTriggers.DirectorSummon, personId));
return true;
}
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+
+ /// ActionDef name for the person card when the live activity is idle but summoned.
+ 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)
{
if (school.DirectorSummons.Count == 0)
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index abc2eb3..23cb9ea 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -426,6 +426,20 @@ public sealed class School : IDisposable
return copy;
}
+ /// Queue a fact for the worker's notice board. Simulation never builds UI itself.
+ internal void RaiseWorldEvent(in WorldEvent fact) => _worldEvents.Add(fact);
+
+ ///
+ /// Presence wire tag for auto director summons. Values match DirectorSummons.Presence*
+ /// and .
+ ///
+ public byte DirectorSummonPresencePhase(string personId) =>
+ DirectorSummonSystem.ResolvePresencePhase(this, personId);
+
+ /// ActionDef id for the person card when summoned but the live activity is idle.
+ public string? DirectorSummonPresenceActionId(string personId) =>
+ DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
+
private void RecordWorldEvents(DateTime before, DateTime after)
{
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
diff --git a/src/HSchool.Simulation/WorldEvent.cs b/src/HSchool.Simulation/WorldEvent.cs
index bf2307a..7c3ab5e 100644
--- a/src/HSchool.Simulation/WorldEvent.cs
+++ b/src/HSchool.Simulation/WorldEvent.cs
@@ -2,5 +2,6 @@ namespace HSchool.Simulation;
///
/// A fact the school raised this step. Same seam as AffinityEvent: a list, no UI.
+/// is empty when the fact is school-wide (morning, lesson bell).
///
-public readonly record struct WorldEvent(string Trigger);
+public readonly record struct WorldEvent(string Trigger, string PersonKey = "");
diff --git a/tests/HSchool.Content.Tests/EventDefTests.cs b/tests/HSchool.Content.Tests/EventDefTests.cs
index 5e78cf0..a28ed73 100644
--- a/tests/HSchool.Content.Tests/EventDefTests.cs
+++ b/tests/HSchool.Content.Tests/EventDefTests.cs
@@ -33,6 +33,15 @@ public class EventDefTests
Assert.Equal(EventActions.GenerateImage, catalog.Events["GenerationFailed"].Action);
Assert.Equal("Начало дня", catalog.Label("ru", 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]
diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
index 3778cc9..b5b0508 100644
--- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
+++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
@@ -285,8 +285,10 @@ public class ProtocolCodecTests
Assert.Equal(PresenceState.Here, read.People[0].State);
Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId);
+ Assert.Equal(PresenceSummonPhase.None, read.People[0].SummonPhase);
Assert.Equal(PresenceState.Walking, read.People[1].State);
Assert.Empty(read.People[1].TalkMemberIds);
+ Assert.Equal(PresenceSummonPhase.None, read.People[1].SummonPhase);
}
[Fact]
@@ -313,12 +315,15 @@ public class ProtocolCodecTests
Assert.Equal(0, buffer[offset]);
offset += 1;
offset = AssertWireString(buffer, offset, "");
+ Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
+ offset += 1;
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(PresenceSummonPhase.None, read.People[0].SummonPhase);
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);
@@ -357,16 +362,52 @@ public class ProtocolCodecTests
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "f0.c1");
offset = AssertWireString(buffer, offset, "TopicSport");
+ Assert.Equal(PresenceSummonPhase.None, buffer[offset]);
+ offset += 1;
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(PresenceSummonPhase.None, read.People[0].SummonPhase);
Assert.Equal(message.SchoolId, read.SchoolId);
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]
public void Presence_DoesNotContainPersonDisplayName()
{
diff --git a/tests/HSchool.Server.Tests/NoticeBoardTests.cs b/tests/HSchool.Server.Tests/NoticeBoardTests.cs
index 18488a1..f52e093 100644
--- a/tests/HSchool.Server.Tests/NoticeBoardTests.cs
+++ b/tests/HSchool.Server.Tests/NoticeBoardTests.cs
@@ -95,6 +95,18 @@ public class NoticeBoardTests
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()
{
DefName = "DayStarted",
@@ -105,6 +117,16 @@ public class NoticeBoardTests
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()
{
DefName = "GenerationFailed",
diff --git a/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs b/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs
index d819ce2..928c640 100644
--- a/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs
+++ b/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs
@@ -112,7 +112,16 @@ public class DirectorSummonTests
name => name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(
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]
@@ -123,6 +132,67 @@ public class DirectorSummonTests
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 QueueOrderFromFight(int seed)
{
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed);