From 9e429d0cd704abecf7663a610396384b484f85fb Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:07:47 +0300 Subject: [PATCH 1/2] Show director summons as info toasts and presence queue labels. Corridor waiters need a summon-phase byte on presence (v11); EventDef DirectorSummoned is info-only so clocks keep running. --- docs/phases/12-office/71-summons-notices.md | 18 ++--- docs/protocol.md | 7 +- src/HSchool.Ai/DirectorSummons.cs | 18 +++++ .../src/format/talkCircle.test.ts | 35 ++++++++- src/HSchool.Client/src/format/talkCircle.ts | 20 +++++- src/HSchool.Client/src/i18n/strings.ts | 8 +++ src/HSchool.Client/src/net/protocol.test.ts | 66 +++++++++++++++-- src/HSchool.Client/src/net/protocol.ts | 15 +++- src/HSchool.Client/src/ui/gameScreen.test.ts | 2 + src/HSchool.Client/src/ui/noticeToasts.ts | 1 + src/HSchool.Content/EventDefValidator.cs | 1 + src/HSchool.Content/EventDefs.cs | 1 + src/HSchool.Protocol/Messages.cs | 19 ++++- src/HSchool.Protocol/ProtocolCodec.cs | 5 +- src/HSchool.Protocol/ProtocolConstants.cs | 2 +- src/HSchool.Server/Game/PersonCardReader.cs | 3 +- src/HSchool.Server/Game/PresenceFrame.cs | 4 +- src/HSchool.Server/Game/SchoolWorker.Tick.cs | 2 +- .../mods/core/defs/actions/living.jsonc | 15 ++++ .../mods/core/defs/events/notices.jsonc | 8 +++ .../mods/core/localizations/en.jsonc | 3 + .../mods/core/localizations/ru.jsonc | 3 + .../DirectorSummonSystem.cs | 64 +++++++++++++++++ src/HSchool.Simulation/School.cs | 14 ++++ src/HSchool.Simulation/WorldEvent.cs | 3 +- tests/HSchool.Content.Tests/EventDefTests.cs | 9 +++ .../ProtocolCodecTests.cs | 41 +++++++++++ .../HSchool.Server.Tests/NoticeBoardTests.cs | 22 ++++++ .../DirectorSummonTests.cs | 72 ++++++++++++++++++- 29 files changed, 453 insertions(+), 28 deletions(-) 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/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); From d7716064110bc344a06fa53c2ae812dba0151487 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:08:21 +0300 Subject: [PATCH 2/2] Mark phase 71 done. --- docs/phases/12-office/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/12-office/README.md b/docs/phases/12-office/README.md index b8aeca1..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.