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
- [ ] Тост не паузит часы; не в сейве 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] Без открытого клиента очередь в мире всё равно двигается (подписка не обязательна для сима)
## Критерий готовности
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -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.
+18
View File
@@ -10,6 +10,24 @@ public static class DirectorSummons
{
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 CorridorRoomDef = "Corridor";
@@ -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: 'Иванова Мария' }));
});
});
+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';
const TOPIC_KEYS: Record<string, MessageKey> = {
@@ -58,7 +58,25 @@ export function talkCircleText(
export function locationPersonLine(person: PresencePerson, names: ReadonlyMap<string, string>): 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 '';
}
}
+8
View File
@@ -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…',
+61 -5
View File
@@ -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,
},
],
});
+14 -1
View File
@@ -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,
});
}
@@ -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,
},
],
};
@@ -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;
+1
View File
@@ -14,6 +14,7 @@ internal static class EventDefValidator
EventTriggers.DayStart,
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
};
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 LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed";
public const string DirectorSummon = "directorSummon";
}
public static class EventActions
+17 -2
View File
@@ -122,6 +122,19 @@ public static class PresenceState
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>
public sealed record PresenceNode(
string Id,
@@ -132,16 +145,18 @@ public sealed record PresenceNode(
/// <summary>
/// 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.
/// <see cref="SummonPhase"/> is <see cref="PresenceSummonPhase"/> (0 when not summoned).
/// </summary>
public sealed record PresencePerson(
string Id,
string NodeId,
byte State,
IReadOnlyList<string> 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)
{
}
}
+4 -1
View File
@@ -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);
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// <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>
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 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))
{
+3 -1
View File
@@ -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;
}
+1 -1
View File
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
continue;
}
TryEmitNotice(school, def);
TryEmitNotice(school, def, fact.PersonKey);
}
}
}
@@ -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",
@@ -23,4 +23,12 @@
"trigger": "generationFailed",
"action": "generateImage",
},
{
"defName": "DirectorSummoned",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "directorSummon",
"action": "none",
},
]
@@ -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",
}
@@ -231,5 +231,8 @@
"DayStarted": "Начало дня",
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"core": "Базовая игра",
}
@@ -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;
}
/// <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)
{
if (school.DirectorSummons.Count == 0)
+14
View File
@@ -426,6 +426,20 @@ public sealed class School : IDisposable
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)
{
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
+2 -1
View File
@@ -2,5 +2,6 @@ namespace HSchool.Simulation;
/// <summary>
/// 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>
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("Начало дня", 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]
@@ -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()
{
@@ -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",
@@ -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<string> QueueOrderFromFight(int seed)
{
var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed);