Merge branch 'phase/64-event-defs'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 22:15:33 +03:00
co-authored by Cursor
39 changed files with 953 additions and 23 deletions
+13 -13
View File
@@ -12,28 +12,28 @@
## Задачи
- [ ] `EventDef`: id, `severity` (`info` / `warning` / `error`), `pause`, `ttlMs` (0 = не гаснуть),
- [x] `EventDef`: id, `severity` (`info` / `warning` / `error`), `pause`, `ttlMs` (0 = не гаснуть),
`trigger` (`dayStart` / `lessonStart` / `generationFailed`), `action` (`none` в этой фазе).
Локаль как у других def. Валидатор каталога
- [ ] Ваниль `DayStarted` и `LessonStarted`: info, без паузы, TTL 8000, `action` none
- [ ] Симуляция на крае рабочего утра и на звонке (смена периода урока, один факт на школу)
- [x] Ваниль `DayStarted` и `LessonStarted`: info, без паузы, TTL 8000, `action` none
- [x] Симуляция на крае рабочего утра и на звонке (смена периода урока, один факт на школу)
отдаёт факт, без UI. Работник сопоставляет def и кладёт info-`Notice`
- [ ] Новый кадр S→C (уведомление: id, defName, severity, pause, ttlMs, optional personId = 0).
- [x] Новый кадр S→C (уведомление: id, defName, severity, pause, ttlMs, optional personId = 0).
Новый кадр C→S dismiss по id. `ProtocolCodec.cs`, `protocol.ts`, [`protocol.md`](../../protocol.md)
в одном коммите. Версия протокола +1
- [ ] Клиент: тост поверх школы, текст из каталога/`t` по defName, клик шлёт dismiss, TTL
- [x] Клиент: тост поверх школы, текст из каталога/`t` по defName, клик шлёт dismiss, TTL
гасит локально. Строки через `t(...)`. Не колонка «События»
- [ ] Info не писать в сейв. Открытие школы info не повторяет. Гость тост видит
- [ ] `generationFailed` в каталоге можно завести, но не эмитить — фаза 67 / 65
- [x] Info не писать в сейв. Открытие школы info не повторяет. Гость тост видит
- [x] `generationFailed` в каталоге можно завести, но не эмитить — фаза 67 / 65
## Тесты, без которых фаза не закрыта
- [ ] Каталог грузит `DayStarted` / `LessonStarted`; неизвестный trigger — ошибка загрузки
- [ ] Переход через край рабочего утра даёт один факт `dayStart`; повторный тик в ту же минуту — нет
- [ ] Смена периода на урок даёт один `lessonStart` на школу, не по числу классов
- [ ] Круглый трип и байтовая раскладка кадра уведомления и dismiss — на обеих сторонах
- [ ] Клиентский тест: тост показывает локализованный текст, клик вызывает dismiss, без монтирования колонки
- [ ] Хостовый: открытая школа после утра получает кадр; после Close/Open info-тост не висит из сейва
- [x] Каталог грузит `DayStarted` / `LessonStarted`; неизвестный trigger — ошибка загрузки
- [x] Переход через край рабочего утра даёт один факт `dayStart`; повторный тик в ту же минуту — нет
- [x] Смена периода на урок даёт один `lessonStart` на школу, не по числу классов
- [x] Круглый трип и байтовая раскладка кадра уведомления и dismiss — на обеих сторонах
- [x] Клиентский тест: тост показывает локализованный текст, клик вызывает dismiss, без монтирования колонки
- [x] Хостовый: открытая школа после утра получает кадр; после Close/Open info-тост не висит из сейва
## Критерий готовности
+1 -1
View File
@@ -18,7 +18,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [64. События и info-тост](64-event-defs.md) | 🔄 | `EventDef`, утро и звонок, кадр на сокете, тост |
| [64. События и info-тост](64-event-defs.md) | | `EventDef`, утро и звонок, кадр на сокете, тост |
| [65. Варнинг и пауза](65-warning-pause.md) | ⬜ | sticky, Play игнор, сейв, dismiss хозяина |
63 независима. 65 стоит на 64.
+34 -5
View File
@@ -1,4 +1,4 @@
# Wire protocol v9
# Wire protocol v10
The client talks to the server two ways:
@@ -7,7 +7,8 @@ The client talks to the server two ways:
roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response
by nature, so they are plain REST.
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot
sent once when a school is opened, and a presence stream (~2 Hz) of who is where.
sent once when a school is opened, a presence stream (~2 Hz) of who is where, and one-shot
notice frames when the world raises an event.
This document covers both. One protocol message per WebSocket frame, no framing header beyond the
message id. **All multi-byte numbers are little-endian.**
@@ -861,12 +862,14 @@ frame is obvious at a glance.
| `0x05` | C → S | SetRunning |
| `0x06` | C → S | SetSpeed |
| `0x07` | C → S | SkipEmpty |
| `0x08` | C → S | DismissNotice |
| `0x81` | S → C | Welcome |
| `0x82` | S → C | Pong |
| `0x83` | S → C | Clock |
| `0x84` | S → C | SchoolGone |
| `0x85` | S → C | MapSnapshot |
| `0x86` | S → C | Presence |
| `0x87` | S → C | Notice |
## Client → server
@@ -935,6 +938,16 @@ when the skip is not legal; the calendar does not move.
Running, speed and skip are **separate messages on purpose**. A button that also resent a
neighbouring field would clobber it with a stale client copy.
### `0x08` DismissNotice — 5 bytes
Closes one notice by id. Info toasts are not stored on the server; the frame still travels so a
click is one intent. Pausing dismiss (owner-only) is a later phase.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x08` |
| 1 | `u32` | notice id |
## Server → client
### `0x81` Welcome — 4 bytes
@@ -1053,6 +1066,22 @@ Member ids are the live circle, including self, sorted by id. Count `0` and an e
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.
### `0x87` Notice — variable
One school event for every connection that has that school open. Not glued to the clock frame.
Info is not written to the save and is not resent on OpenSchool. `personId` is `0` when nobody
is in frame (the generate-image button is a later phase).
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x87` |
| 1 | `u32` | notice id |
| 5 | string | `defName` |
| … | `u8` | severity: `0` info, `1` warning, `2` error |
| … | `u8` | `1` pause (later phase), `0` clocks keep running |
| … | `u32` | `ttlMs`; `0` stays until dismiss |
| … | `u32` | `personId`; `0` = none |
## Guarantees and limits
- **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That
@@ -1064,9 +1093,9 @@ on this frame; the client builds «говорит с Машей о футбол
older clients within the same protocol version.
- Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops
the oldest, because a stale clock is worthless once a newer one exists.
- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out.
- The map snapshot, presence and notices use a separate reliable queue so ticks cannot crowd them out.
## Not in v9 yet
## Not in v10 yet
Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server —
Authentication, Sit orders, a year-long event log, walk animation, and `OpenLocation` on the server —
the tree and the location panel are filtered on the client from the snapshot plus presence.
+6
View File
@@ -383,6 +383,9 @@ const ru = {
timetableErrorNoTeacher: 'Некому вести этот предмет.',
timetableErrorUnknown: 'Не удалось изменить урок.',
timetableErrorLesson: 'Этого закрепления уже нет.',
DayStarted: 'Начало дня',
LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет',
} as const;
type Messages = { [K in keyof typeof ru]: string };
@@ -770,6 +773,9 @@ const en: Messages = {
timetableErrorNoTeacher: 'Nobody is assigned that subject.',
timetableErrorUnknown: 'Could not change the lesson.',
timetableErrorLesson: 'That pinned lesson is gone.',
DayStarted: 'The day has started',
LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed',
};
const catalogs: Record<Locale, Messages> = { ru, en };
+6
View File
@@ -43,6 +43,7 @@ async function bootstrap(): Promise<void> {
onSetRunning: (running) => connection.setRunning(running),
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
onSkip: () => connection.skipEmpty(),
onDismissNotice: (id) => connection.dismissNotice(id),
});
const connection = new GameConnection(gameSocketUrl(), {
@@ -65,6 +66,11 @@ async function bootstrap(): Promise<void> {
game.applyPresence(presence.schoolId, presence);
}
},
onNotice: (notice) => {
if (openSchool !== null) {
game.applyNotice(notice);
}
},
onSchoolGone: (schoolId) => {
// Deleted from another tab while we were inside it.
if (openSchool?.id === schoolId) {
+10
View File
@@ -7,9 +7,11 @@ import {
encodeSetRunning,
encodeSetSpeed,
encodeSkipEmpty,
encodeDismissNotice,
ProtocolError,
type ClockMessage,
type MapSnapshotMessage,
type NoticeMessage,
type PresenceMessage,
type ServerMessage,
type WelcomeMessage,
@@ -23,6 +25,7 @@ export interface ConnectionHandlers {
onClock?(message: ClockMessage): void;
onMapSnapshot?(message: MapSnapshotMessage): void;
onPresence?(message: PresenceMessage): void;
onNotice?(message: NoticeMessage): void;
/** The open school was deleted elsewhere; the UI has to leave it. */
onSchoolGone?(schoolId: number): void;
/** Round-trip time in milliseconds. */
@@ -125,6 +128,10 @@ export class GameConnection {
this.send(encodeSkipEmpty());
}
dismissNotice(id: number): void {
this.send(encodeDismissNotice(id));
}
close(): void {
this.closedByUs = true;
this.stopTimers();
@@ -172,6 +179,9 @@ export class GameConnection {
case 'presence':
this.handlers.onPresence?.(message);
break;
case 'notice':
this.handlers.onNotice?.(message);
break;
case 'school-gone':
if (this.openSchoolId === message.schoolId) {
this.openSchoolId = null;
@@ -9,6 +9,7 @@ import {
encodeSetRunning,
encodeSetSpeed,
encodeSkipEmpty,
encodeDismissNotice,
MessageType,
PresenceState,
ProtocolError,
@@ -75,6 +76,15 @@ describe('client encoders', () => {
expect(view.byteLength).toBe(1);
expect(view.getUint8(0)).toBe(MessageType.ClientSkipEmpty);
});
it('writes dismiss as five little-endian bytes', () => {
const buffer = encodeDismissNotice(0x01020304);
const view = new DataView(buffer);
expect(view.byteLength).toBe(5);
expect(view.getUint8(0)).toBe(MessageType.ClientDismissNotice);
expect([...new Uint8Array(buffer, 1)]).toEqual([0x04, 0x03, 0x02, 0x01]);
});
});
describe('decodeServerMessage', () => {
@@ -434,6 +444,32 @@ describe('decodeServerMessage', () => {
});
});
it('reads a notice frame by the documented offsets', () => {
const defName = 'DayStarted';
const encoded = new TextEncoder().encode(defName);
const buffer = new ArrayBuffer(1 + 4 + 2 + encoded.length + 1 + 1 + 4 + 4);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerNotice);
view.setUint32(1, 0x0a0b0c0d, true);
view.setUint16(5, encoded.length, true);
new Uint8Array(buffer).set(encoded, 7);
const afterName = 7 + encoded.length;
view.setUint8(afterName, 0);
view.setUint8(afterName + 1, 0);
view.setUint32(afterName + 2, 8000, true);
view.setUint32(afterName + 6, 0, true);
expect(decodeServerMessage(buffer)).toEqual({
type: 'notice',
id: 0x0a0b0c0d,
defName: 'DayStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
});
it('ignores unknown message ids so new ones stay backwards compatible', () => {
const buffer = new Uint8Array([0xf0, 0x00]).buffer;
+57 -2
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
export const PROTOCOL_VERSION = 9;
export const PROTOCOL_VERSION = 10;
export const MessageType = {
ClientHello: 0x01,
@@ -15,12 +15,14 @@ export const MessageType = {
ClientSetRunning: 0x05,
ClientSetSpeed: 0x06,
ClientSkipEmpty: 0x07,
ClientDismissNotice: 0x08,
ServerWelcome: 0x81,
ServerPong: 0x82,
ServerClock: 0x83,
ServerSchoolGone: 0x84,
ServerMapSnapshot: 0x85,
ServerPresence: 0x86,
ServerNotice: 0x87,
} as const;
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
@@ -133,13 +135,31 @@ export interface PresenceMessage {
readonly people: readonly PresencePerson[];
}
export const NoticeSeverity = {
Info: 0,
Warning: 1,
Error: 2,
} as const;
export interface NoticeMessage {
readonly type: 'notice';
readonly id: number;
readonly defName: string;
readonly severity: number;
readonly pause: boolean;
readonly ttlMs: number;
/** 0 when nobody is in frame. */
readonly personId: number;
}
export type ServerMessage =
| WelcomeMessage
| PongMessage
| ClockMessage
| SchoolGoneMessage
| MapSnapshotMessage
| PresenceMessage;
| PresenceMessage
| NoticeMessage;
/** Thrown when a frame is truncated or carries an unexpected message id. */
export class ProtocolError extends Error {}
@@ -212,6 +232,14 @@ export function encodeSkipEmpty(): ArrayBuffer {
return buffer;
}
export function encodeDismissNotice(id: number): ArrayBuffer {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientDismissNotice);
view.setUint32(1, id >>> 0, true);
return buffer;
}
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
if (data.byteLength === 0) {
@@ -233,6 +261,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
return decodeMapSnapshot(view);
case MessageType.ServerPresence:
return decodePresence(view);
case MessageType.ServerNotice:
return decodeNotice(view);
default:
return null;
}
@@ -400,6 +430,31 @@ function decodePresence(view: DataView): PresenceMessage {
return { type: 'presence', schoolId, nodes, people };
}
function decodeNotice(view: DataView): NoticeMessage {
ensure(view, 5);
const id = view.getUint32(1, true);
const defName = readString(view, 5);
let offset = defName.next;
const severity = readU8(view, offset);
offset += 1;
const pause = readU8(view, offset);
offset += 1;
ensure(view, offset + 8);
const ttlMs = view.getUint32(offset, true);
offset += 4;
const personId = view.getUint32(offset, true);
return {
type: 'notice',
id,
defName: defName.text,
severity,
pause: pause !== 0,
ttlMs,
personId,
};
}
function readU8(view: DataView, offset: number): number {
ensure(view, offset + 1);
return view.getUint8(offset);
+33
View File
@@ -99,6 +99,7 @@ body {
*/
.screen.game {
max-width: none;
position: relative;
}
#status {
@@ -1601,3 +1602,35 @@ body {
gap: 8px;
font-size: 13px;
}
.notice-toasts {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100% - 32px));
pointer-events: none;
}
.notice-toast {
pointer-events: auto;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text);
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
}
.notice-toast:hover {
border-color: var(--accent);
}
+16
View File
@@ -4,6 +4,7 @@ import {
type ClockMessage,
type MapSnapshotItem,
type MapSnapshotNode,
type NoticeMessage,
type PresenceMessage,
type PresenceNode,
} from '../net/protocol.ts';
@@ -14,6 +15,7 @@ import { t } from '../i18n/strings.ts';
import { fetchDirectory, type School } from '../net/api.ts';
import { clear, el } from './dom.ts';
import { ManagementPanel } from './managementPanel.ts';
import { NoticeToasts } from './noticeToasts.ts';
import { PeoplePanel } from './peoplePanel.ts';
import { locationPersonLine } from '../format/talkCircle.ts';
import { formatPersonPlace } from './personCard.ts';
@@ -33,6 +35,7 @@ interface GameScreenOptions {
readonly onSetRunning: (running: boolean) => void;
readonly onSetSpeed: (speedIndex: number) => void;
readonly onSkip: () => void;
readonly onDismissNotice?: (id: number) => void;
}
const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10'];
@@ -83,6 +86,7 @@ export class GameScreen {
this.syncRoute();
},
});
private readonly notices: NoticeToasts;
private readonly management = new ManagementPanel();
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' });
@@ -112,6 +116,7 @@ export class GameScreen {
private leftTab: LeftTab = 'map';
constructor(options: GameScreenOptions) {
this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id));
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', {
class: 'button button--small',
@@ -142,6 +147,7 @@ export class GameScreen {
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
this.overview,
this.manage,
this.notices.element,
);
this.overview.append(
@@ -204,6 +210,7 @@ export class GameScreen {
this.people.localize();
this.management.localize();
this.notices.localize();
this.paintSelection();
if (this.lastGameTime !== null) {
@@ -244,6 +251,7 @@ export class GameScreen {
this.directory = new Map();
this.skipAllowed = false;
this.skipTarget = null;
this.notices.clear();
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
@@ -303,6 +311,14 @@ export class GameScreen {
}
}
applyNotice(message: NoticeMessage): void {
if (this.schoolId === null) {
return;
}
this.notices.show(message);
}
/** Names for the presence stream. The frame itself never carries display names. */
applyDirectory(people: readonly { id: string; fullName: string }[]): void {
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
@@ -0,0 +1,62 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { NoticeToasts } from './noticeToasts.ts';
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
setLocale('ru');
});
describe('NoticeToasts', () => {
it('shows the localized defName and click sends dismiss', () => {
setLocale('ru');
const onDismiss = vi.fn();
const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element);
toasts.show({
type: 'notice',
id: 7,
defName: 'DayStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
const toast = toasts.element.querySelector('.notice-toast');
expect(toast?.textContent).toBe(t('DayStarted'));
expect(document.querySelector('.events-column')).toBeNull();
expect(document.querySelector('[data-events-column]')).toBeNull();
toast?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onDismiss).toHaveBeenCalledWith(7);
expect(toasts.element.querySelector('.notice-toast')).toBeNull();
});
it('hides on TTL without sending dismiss', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element);
toasts.show({
type: 'notice',
id: 3,
defName: 'LessonStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
vi.advanceTimersByTime(8000);
expect(onDismiss).not.toHaveBeenCalled();
expect(toasts.element.querySelector('.notice-toast')).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { NoticeMessage } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
const MAX_INFO_TOASTS = 8;
function label(defName: string): string {
switch (defName) {
case 'DayStarted':
case 'LessonStarted':
case 'GenerationFailed':
return t(defName satisfies MessageKey);
default:
return defName;
}
}
/**
* Info toasts over the school screen. Not a column: a stack in the corner that TTL and click
* dismiss. Click tells the server; TTL only hides locally.
*/
export class NoticeToasts {
readonly element = el('div', { class: 'notice-toasts' });
private readonly timers = new Map<number, ReturnType<typeof setTimeout>>();
constructor(private readonly onDismiss: (id: number) => void) {}
show(notice: NoticeMessage): void {
this.remove(notice.id);
while (this.element.childElementCount >= MAX_INFO_TOASTS) {
const oldest = this.element.firstElementChild;
if (!(oldest instanceof HTMLElement)) {
break;
}
const oldestId = Number.parseInt(oldest.dataset.noticeId ?? '', 10);
this.remove(Number.isFinite(oldestId) ? oldestId : 0, false);
}
const toast = el(
'button',
{
class: 'notice-toast',
type: 'button',
text: label(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
onClick: () => this.remove(notice.id, true),
},
);
this.element.append(toast);
if (notice.ttlMs > 0) {
this.timers.set(
notice.id,
setTimeout(() => this.remove(notice.id, false), notice.ttlMs),
);
}
}
clear(): void {
for (const id of [...this.timers.keys()]) {
this.remove(id, false);
}
this.element.replaceChildren();
}
localize(): void {
for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) {
const defName = node.dataset.noticeDef;
if (defName !== undefined) {
node.textContent = label(defName);
}
}
}
private remove(id: number, send = false): void {
const timer = this.timers.get(id);
if (timer !== undefined) {
clearTimeout(timer);
this.timers.delete(id);
}
this.element.querySelector(`[data-notice-id="${id}"]`)?.remove();
if (send) {
this.onDismiss(id);
}
}
}
+8 -1
View File
@@ -318,6 +318,7 @@ public sealed class CatalogLoader
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -392,6 +393,9 @@ public sealed class CatalogLoader
case DefKind.AffinityRules:
affinity[key.Name] = Jsonc.Deserialize<AffinityRulesDef>(json);
break;
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
break;
}
}
@@ -420,6 +424,7 @@ public sealed class CatalogLoader
topics,
orientations,
affinity,
events,
ru,
en);
}
@@ -534,6 +539,7 @@ public sealed class CatalogLoader
}
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
@@ -571,7 +577,8 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.Colors.Values))
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values));
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
+6
View File
@@ -31,6 +31,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, TopicDef> topics,
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, EventDef> events,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -58,6 +59,7 @@ public sealed class DefCatalog
Topics = topics;
Orientations = orientations;
Affinity = affinity;
Events = events;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -121,6 +123,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, AffinityRulesDef> Affinity { get; }
public IReadOnlyDictionary<string, EventDef> Events { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -163,6 +167,7 @@ public sealed class DefCatalog
DefKind.Topic => Topics.GetValueOrDefault(defName),
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
DefKind.Event => Events.GetValueOrDefault(defName),
_ => null,
};
@@ -246,6 +251,7 @@ public sealed class DefCatalog
TopicDef => DefKind.Topic,
OrientationDef => DefKind.Orientation,
AffinityRulesDef => DefKind.AffinityRules,
EventDef => DefKind.Event,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+1
View File
@@ -25,6 +25,7 @@ public enum DefKind
Topic,
Orientation,
AffinityRules,
Event,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+50
View File
@@ -0,0 +1,50 @@
namespace HSchool.Content;
internal static class EventDefValidator
{
private static readonly HashSet<string> Severities = new(StringComparer.Ordinal)
{
EventSeverities.Info,
EventSeverities.Warning,
EventSeverities.Error,
};
private static readonly HashSet<string> Triggers = new(StringComparer.Ordinal)
{
EventTriggers.DayStart,
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
};
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
{
EventActions.None,
EventActions.GenerateImage,
};
public static void Validate(DefCatalog catalog)
{
foreach (var def in catalog.Events.Values)
{
if (!Severities.Contains(def.Severity))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown severity '{def.Severity}'.");
}
if (!Triggers.Contains(def.Trigger))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown trigger '{def.Trigger}'.");
}
if (!Actions.Contains(def.Action))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown action '{def.Action}'.");
}
if (def.TtlMs < 0)
{
throw new ContentLoadException($"EventDef '{def.DefName}' ttlMs cannot be negative.");
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.Content;
public static class EventSeverities
{
public const string Info = "info";
public const string Warning = "warning";
public const string Error = "error";
}
public static class EventTriggers
{
public const string DayStart = "dayStart";
public const string LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed";
}
public static class EventActions
{
public const string None = "none";
public const string GenerateImage = "generateImage";
}
/// <summary>
/// A fact the world can raise. Systems emit a trigger; the worker matches this def and builds a notice.
/// </summary>
public sealed class EventDef : Def
{
public string Severity { get; init; } = EventSeverities.Info;
public bool Pause { get; init; }
/// <summary>Client toast lifetime in milliseconds. Zero means it stays until dismiss.</summary>
public int TtlMs { get; init; }
public string Trigger { get; init; } = "";
public string Action { get; init; } = EventActions.None;
}
+3
View File
@@ -138,6 +138,9 @@ internal static class PackPaths
case "affinity":
kind = DefKind.AffinityRules;
return true;
case "events":
kind = DefKind.Event;
return true;
default:
kind = default;
return false;
+2
View File
@@ -15,6 +15,7 @@ public enum MessageType : byte
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ClientSkipEmpty = 0x07,
ClientDismissNotice = 0x08,
ServerWelcome = 0x81,
ServerPong = 0x82,
@@ -22,4 +23,5 @@ public enum MessageType : byte
ServerSchoolGone = 0x84,
ServerMapSnapshot = 0x85,
ServerPresence = 0x86,
ServerNotice = 0x87,
}
+23
View File
@@ -84,6 +84,29 @@ public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSna
/// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary>
public readonly record struct ClientSkipEmptyMessage;
/// <summary>Closes one notice by id. Info is not stored; pausing dismiss is phase 65.</summary>
public readonly record struct ClientDismissNoticeMessage(uint Id);
/// <summary>Wire values for <see cref="ServerNoticeMessage.Severity"/>.</summary>
public static class NoticeSeverity
{
public const byte Info = 0;
public const byte Warning = 1;
public const byte Error = 2;
}
/// <summary>
/// One school event for open clients. <paramref name="PersonId"/> is 0 when nobody is in frame.
/// Info toasts are not replayed on OpenSchool.
/// </summary>
public readonly record struct ServerNoticeMessage(
uint Id,
string DefName,
byte Severity,
bool Pause,
uint TtlMs,
uint PersonId = 0);
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
public static class PresenceState
{
+44
View File
@@ -70,6 +70,14 @@ public static class ProtocolCodec
return writer.Position;
}
public static int WriteDismissNotice(Span<byte> destination, in ClientDismissNoticeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientDismissNotice);
writer.WriteUInt32(message.Id);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
@@ -271,6 +279,22 @@ public static class ProtocolCodec
return writer.Position;
}
public static int NoticeSize(in ServerNoticeMessage message) =>
sizeof(byte) + sizeof(uint) + StringSize(message.DefName) + sizeof(byte) + sizeof(byte) + sizeof(uint) + sizeof(uint);
public static int WriteNotice(Span<byte> destination, in ServerNoticeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerNotice);
writer.WriteUInt32(message.Id);
writer.WriteString(message.DefName);
writer.WriteByte(message.Severity);
writer.WriteByte(message.Pause ? (byte)1 : (byte)0);
writer.WriteUInt32(message.TtlMs);
writer.WriteUInt32(message.PersonId);
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -441,6 +465,26 @@ public static class ProtocolCodec
return new ServerPresenceMessage(schoolId, nodes, people);
}
public static ClientDismissNoticeMessage ReadDismissNotice(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientDismissNotice);
return new ClientDismissNoticeMessage(reader.ReadUInt32());
}
public static ServerNoticeMessage ReadNotice(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerNotice);
var id = reader.ReadUInt32();
var defName = reader.ReadString();
var severity = reader.ReadByte();
var pause = reader.ReadByte() != 0;
var ttlMs = reader.ReadUInt32();
var personId = reader.ReadUInt32();
return new ServerNoticeMessage(id, defName, severity, pause, ttlMs, personId);
}
private static bool HasPresenceActivity(PresenceNode node) =>
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
+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 = 9;
public const byte Version = 10;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;
+2
View File
@@ -42,6 +42,8 @@ internal abstract record GameCommand
internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : GameCommand;
internal sealed record DismissNotice(uint PlayerId, uint NoticeId) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
@@ -225,6 +225,10 @@ internal sealed class GameLoopService(
HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty());
break;
case GameCommand.DismissNotice dismiss:
RouteOpenSchool(dismiss.PlayerId, new WorkerCommand.DismissNotice(dismiss.NoticeId));
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
@@ -61,6 +61,10 @@ internal sealed partial class SchoolWorker
ApplySkip(school);
break;
case WorkerCommand.DismissNotice:
// Info is not stored; pausing dismiss is phase 65.
break;
case WorkerCommand.Dump dump:
dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
break;
@@ -165,6 +165,7 @@ internal sealed partial class SchoolWorker
{
PublishSnapshot();
BroadcastClock();
EmitWorldEvents(school);
MaybeBroadcastPresence(school);
}
@@ -244,6 +245,7 @@ internal sealed partial class SchoolWorker
PublishSnapshot();
Persist();
BroadcastClock();
EmitWorldEvents(school);
BroadcastPresence();
_presenceAge = 0;
}
@@ -489,4 +491,48 @@ internal sealed partial class SchoolWorker
var length = ProtocolCodec.WritePresence(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
private void EmitWorldEvents(School school)
{
var catalog = school.Catalog;
if (catalog is null)
{
school.DrainWorldEvents();
return;
}
foreach (var fact in school.DrainWorldEvents())
{
foreach (var def in catalog.Events.Values)
{
if (def.Abstract
|| !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal)
|| !def.Severity.Equals(EventSeverities.Info, StringComparison.Ordinal))
{
continue;
}
BroadcastNotice(new ServerNoticeMessage(
++_nextNoticeId,
def.DefName,
NoticeSeverity.Info,
def.Pause,
(uint)Math.Max(0, def.TtlMs)));
}
}
}
private void BroadcastNotice(in ServerNoticeMessage message)
{
var frame = new byte[ProtocolCodec.NoticeSize(message)];
var length = ProtocolCodec.WriteNotice(frame, message);
var payload = frame.AsMemory(0, length);
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
client.TrySendReliable(payload);
}
}
}
}
+1
View File
@@ -53,6 +53,7 @@ internal sealed partial class SchoolWorker
private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot;
private int _presenceAge;
private uint _nextNoticeId;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
+2
View File
@@ -22,6 +22,8 @@ internal abstract record WorkerCommand
internal sealed record SkipEmpty : WorkerCommand;
internal sealed record DismissNotice(uint Id) : WorkerCommand;
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
internal sealed record GetPerson(
@@ -180,6 +180,11 @@ internal sealed class GameSocketHandler(
break;
case MessageType.ClientDismissNotice:
var dismiss = ProtocolCodec.ReadDismissNotice(frame);
commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id));
break;
default:
logger.LogDebug(
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
@@ -0,0 +1,26 @@
[
{
"defName": "DayStarted",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "dayStart",
"action": "none",
},
{
"defName": "LessonStarted",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "lessonStart",
"action": "none",
},
{
"defName": "GenerationFailed",
"severity": "error",
"pause": true,
"ttlMs": 0,
"trigger": "generationFailed",
"action": "none",
},
]
@@ -224,5 +224,8 @@
"LessonNoTeacher": "lesson without a teacher: {0}",
"LessonCold": "too cold in class: {0}",
"LessonNoTextbook": "no textbook: {0}",
"DayStarted": "The day has started",
"LessonStarted": "A lesson has started",
"GenerationFailed": "Portrait generation failed",
"core": "Core",
}
@@ -224,5 +224,8 @@
"LessonNoTeacher": "урок без учителя: {0}",
"LessonCold": "замёрз на уроке: {0}",
"LessonNoTextbook": "нет учебника: {0}",
"DayStarted": "Начало дня",
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
"core": "Базовая игра",
}
+48
View File
@@ -0,0 +1,48 @@
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Turns clock edges into world facts. One dayStart per morning crossing, one lessonStart per
/// school when the bell enters a lesson period — not one per class.
/// </summary>
internal static class EventSystem
{
public static IReadOnlyList<WorldEvent> Detect(School school, DateTime before, DateTime after)
{
if (school.Catalog is null || after <= before)
{
return [];
}
var facts = new List<WorldEvent>(2);
if (CrossedWorkMorning(school.Catalog, before, after, school.SchoolWeekDays))
{
facts.Add(new WorldEvent(EventTriggers.DayStart));
}
if (EnteredLessonPeriod(school.Catalog, before, after, school.SchoolWeekDays))
{
facts.Add(new WorldEvent(EventTriggers.LessonStart));
}
return facts;
}
private static bool CrossedWorkMorning(DefCatalog catalog, DateTime before, DateTime after, int weekDays)
{
if (!PersonDayLog.CrossedDayStart(before, after))
{
return false;
}
return SchoolDay.IsWorkday(catalog, after, weekDays);
}
private static bool EnteredLessonPeriod(DefCatalog catalog, DateTime before, DateTime after, int weekDays)
{
var beforeSlot = SchoolDay.At(catalog, before, weekDays);
var afterSlot = SchoolDay.At(catalog, after, weekDays);
return afterSlot.Kind == DaySlotKind.Lesson && beforeSlot != afterSlot;
}
}
+22
View File
@@ -18,6 +18,7 @@ public sealed class School : IDisposable
private bool _disposed;
private readonly List<PersonLogEvent> _dayLog = [];
private readonly HashSet<string> _lessonLogOnce = new(StringComparer.Ordinal);
private readonly List<WorldEvent> _worldEvents = [];
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
{
@@ -297,6 +298,7 @@ public sealed class School : IDisposable
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before);
SyncWeather(force: true);
RecordWorldEvents(before, next.Value);
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
@@ -368,6 +370,8 @@ public sealed class School : IDisposable
var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes);
peopleChanged |= ApplyHeavySystems(heavyMinutes);
}
RecordWorldEvents(before, Clock.Time);
}
else
{
@@ -377,6 +381,24 @@ public sealed class School : IDisposable
return peopleChanged;
}
/// <summary>Facts raised since the last drain. The worker maps them to notices; simulation has no UI.</summary>
public IReadOnlyList<WorldEvent> DrainWorldEvents()
{
if (_worldEvents.Count == 0)
{
return [];
}
var copy = _worldEvents.ToArray();
_worldEvents.Clear();
return copy;
}
private void RecordWorldEvents(DateTime before, DateTime after)
{
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
}
private bool ApplyHeavySystems(double gameMinutes)
{
HeavySystemsInvocations++;
+6
View File
@@ -0,0 +1,6 @@
namespace HSchool.Simulation;
/// <summary>
/// A fact the school raised this step. Same seam as <c>AffinityEvent</c>: a list, no UI.
/// </summary>
public readonly record struct WorldEvent(string Trigger);
@@ -90,6 +90,37 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.InRange(elapsed.TotalMinutes, 0.5, 2);
}
[Fact]
public async Task OpenSchool_AfterMorning_GetsNotice_AndReopenDoesNotReplayInfo()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var beforeMorning = new DateTime(2012, 4, 3, 5, 55, 0, DateTimeKind.Utc);
var school = await SchoolApiTests.CreateAsync(client, "Утро тост", beforeMorning);
using var socket = await OpenSchoolAsync(school.Id, client);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 4)));
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(20)));
Assert.Equal("DayStarted", notice.DefName);
Assert.Equal(NoticeSeverity.Info, notice.Severity);
Assert.False(notice.Pause);
Assert.Equal(8000u, notice.TtlMs);
Assert.Equal(0u, notice.PersonId);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
var replayed = await TryReceiveNoticeAsync(socket, TimeSpan.FromSeconds(2));
Assert.Null(replayed);
}
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
@@ -707,6 +738,45 @@ public class GameSocketTests(AppHostFixture fixture)
}
}
private static async Task<ServerNoticeMessage?> TryReceiveNoticeAsync(WebSocket socket, TimeSpan duration)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(duration);
var buffer = new byte[64 * 1024];
var chunks = new List<byte>();
while (true)
{
WebSocketReceiveResult result;
try
{
result = await socket.ReceiveAsync(buffer, cts.Token);
}
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
{
return null;
}
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
chunks.AddRange(buffer.AsSpan(0, result.Count).ToArray());
if (!result.EndOfMessage)
{
continue;
}
var frame = chunks.ToArray();
chunks.Clear();
if (ProtocolCodec.PeekMessageType(frame) == MessageType.ServerNotice)
{
return ProtocolCodec.ReadNotice(frame);
}
}
}
private sealed record StaffingSnapshot(IReadOnlyList<ApplicantSnapshot> Applicants);
private sealed record ApplicantSnapshot(string Id, string FullName);
@@ -0,0 +1,57 @@
using HSchool.Content;
namespace HSchool.Content.Tests;
public class EventDefTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void VanillaCore_LoadsDayAndLessonStarted()
{
var catalog = LoadVanilla();
Assert.True(catalog.Events.ContainsKey("DayStarted"));
Assert.Equal(EventSeverities.Info, catalog.Events["DayStarted"].Severity);
Assert.False(catalog.Events["DayStarted"].Pause);
Assert.Equal(8000, catalog.Events["DayStarted"].TtlMs);
Assert.Equal(EventTriggers.DayStart, catalog.Events["DayStarted"].Trigger);
Assert.Equal(EventActions.None, catalog.Events["DayStarted"].Action);
Assert.True(catalog.Events.ContainsKey("LessonStarted"));
Assert.Equal(EventSeverities.Info, catalog.Events["LessonStarted"].Severity);
Assert.False(catalog.Events["LessonStarted"].Pause);
Assert.Equal(8000, catalog.Events["LessonStarted"].TtlMs);
Assert.Equal(EventTriggers.LessonStart, catalog.Events["LessonStarted"].Trigger);
Assert.Equal(EventActions.None, catalog.Events["LessonStarted"].Action);
Assert.True(catalog.Events.ContainsKey("GenerationFailed"));
Assert.Equal(EventTriggers.GenerationFailed, catalog.Events["GenerationFailed"].Trigger);
Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"]));
Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"]));
}
[Fact]
public void UnknownTrigger_FailsTheCatalog()
{
var documents = PackDocuments.FromDirectory(
CatalogLoader.CorePackId,
Path.Combine(AppContext.BaseDirectory, "vanilla"))
.Append(PackDocuments.Def(
CatalogLoader.CorePackId,
"events",
"bad",
"""{ "defName": "BadMoon", "severity": "info", "ttlMs": 1, "trigger": "fullMoon", "action": "none" }"""))
.ToList();
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load([CatalogLoader.CorePackId], documents));
Assert.Contains("trigger", ex.Message, StringComparison.Ordinal);
Assert.Contains("fullMoon", ex.Message, StringComparison.Ordinal);
}
private DefCatalog LoadVanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
}
}
@@ -96,6 +96,41 @@ public class ProtocolCodecTests
Assert.Equal(MessageType.ClientSkipEmpty, ProtocolCodec.PeekMessageType(buffer[..length]));
}
[Fact]
public void DismissNotice_RoundTripsAndIsFiveBytes()
{
var message = new ClientDismissNoticeMessage(0x01020304);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteDismissNotice(buffer, message);
Assert.Equal(5, length);
Assert.Equal((byte)MessageType.ClientDismissNotice, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(message, ProtocolCodec.ReadDismissNotice(buffer[..length]));
}
[Fact]
public void Notice_RoundTripsAndMatchesByteLayout()
{
var message = new ServerNoticeMessage(0x0A0B0C0D, "DayStarted", NoticeSeverity.Info, Pause: false, TtlMs: 8000, PersonId: 0);
var size = ProtocolCodec.NoticeSize(message);
Span<byte> buffer = stackalloc byte[size];
var length = ProtocolCodec.WriteNotice(buffer, message);
Assert.Equal(size, length);
Assert.Equal((byte)MessageType.ServerNotice, buffer[0]);
Assert.Equal(new byte[] { 0x0D, 0x0C, 0x0B, 0x0A }, buffer[1..5].ToArray());
Assert.Equal((ushort)10, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer[5..7]));
Assert.Equal("DayStarted"u8.ToArray(), buffer[7..17].ToArray());
Assert.Equal(NoticeSeverity.Info, buffer[17]);
Assert.Equal(0, buffer[18]);
Assert.Equal(8000u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[19..23]));
Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[23..27]));
Assert.Equal(message, ProtocolCodec.ReadNotice(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFourBytes()
{
@@ -0,0 +1,80 @@
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation.Tests;
public class WorldEventTests
{
private static readonly DateTime TuesdayNight = new(2012, 4, 3, 5, 59, 0, DateTimeKind.Utc);
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private static readonly DateTime BeforeFirstBell = new(2012, 4, 3, 8, 29, 0, DateTimeKind.Utc);
[Fact]
public void CrossingWorkMorning_YieldsOneDayStart_AndARepeatTickDoesNot()
{
using var school = Open(TuesdayNight);
school.Tick(0.2d, 5d);
var first = school.DrainWorldEvents();
Assert.Equal(TuesdayMorning, school.Clock.Time);
Assert.Equal([EventTriggers.DayStart], first.Select(row => row.Trigger));
school.Tick(0.2d, 1d);
Assert.Empty(school.DrainWorldEvents());
}
[Fact]
public void EnteringALessonPeriod_YieldsOneLessonStartForTheSchool()
{
using var school = OpenStaffed(BeforeFirstBell);
Assert.True(school.Roster!.Classes.Count > 1);
school.Tick(0.2d, 5d);
var first = school.DrainWorldEvents();
Assert.Equal(new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc), school.Clock.Time);
Assert.Equal([EventTriggers.LessonStart], first.Select(row => row.Trigger));
Assert.Single(first);
school.Tick(0.2d, 5d);
Assert.Empty(school.DrainWorldEvents());
}
private static School Open(DateTime start)
{
var (catalog, map) = Vanilla();
return School.Create(1, "Факты", start, catalog, map);
}
private static School OpenStaffed(DateTime start)
{
var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
var school = School.Create(1, "Звонок", start, catalog, map);
school.InstallPeople(roster, seed: 1, "Russia", pool);
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
return school;
}
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
}