From a55473808435bcb3039b272aa589ba9dc7b587b2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 20:46:33 +0300 Subject: [PATCH] Add EventDef notices, morning and lesson bells, and info toasts. Co-authored-by: Cursor --- docs/phases/11-events/64-event-defs.md | 26 +++--- docs/protocol.md | 39 ++++++-- src/HSchool.Client/src/i18n/strings.ts | 6 ++ src/HSchool.Client/src/main.ts | 6 ++ src/HSchool.Client/src/net/connection.ts | 10 +++ src/HSchool.Client/src/net/protocol.test.ts | 36 ++++++++ src/HSchool.Client/src/net/protocol.ts | 59 +++++++++++- src/HSchool.Client/src/style.css | 33 +++++++ src/HSchool.Client/src/ui/gameScreen.ts | 16 ++++ .../src/ui/noticeToasts.test.ts | 62 +++++++++++++ src/HSchool.Client/src/ui/noticeToasts.ts | 89 +++++++++++++++++++ src/HSchool.Content/CatalogLoader.cs | 9 +- src/HSchool.Content/DefCatalog.cs | 6 ++ src/HSchool.Content/Defs.cs | 1 + src/HSchool.Content/EventDefValidator.cs | 50 +++++++++++ src/HSchool.Content/EventDefs.cs | 38 ++++++++ src/HSchool.Content/PackPaths.cs | 3 + src/HSchool.Protocol/MessageType.cs | 2 + src/HSchool.Protocol/Messages.cs | 23 +++++ src/HSchool.Protocol/ProtocolCodec.cs | 44 +++++++++ src/HSchool.Protocol/ProtocolConstants.cs | 2 +- src/HSchool.Server/Game/GameCommand.cs | 2 + src/HSchool.Server/Game/GameLoopService.cs | 4 + .../Game/SchoolWorker.Mailbox.cs | 4 + src/HSchool.Server/Game/SchoolWorker.Tick.cs | 46 ++++++++++ src/HSchool.Server/Game/SchoolWorker.cs | 1 + src/HSchool.Server/Game/WorkerCommand.cs | 2 + src/HSchool.Server/Net/GameSocketHandler.cs | 5 ++ .../mods/core/defs/events/notices.jsonc | 26 ++++++ .../mods/core/localizations/en.jsonc | 3 + .../mods/core/localizations/ru.jsonc | 3 + src/HSchool.Simulation/EventSystem.cs | 48 ++++++++++ src/HSchool.Simulation/School.cs | 22 +++++ src/HSchool.Simulation/WorldEvent.cs | 6 ++ .../HSchool.AppHost.Tests/GameSocketTests.cs | 70 +++++++++++++++ tests/HSchool.Content.Tests/EventDefTests.cs | 57 ++++++++++++ .../ProtocolCodecTests.cs | 35 ++++++++ .../WorldEventTests.cs | 80 +++++++++++++++++ 38 files changed, 952 insertions(+), 22 deletions(-) create mode 100644 src/HSchool.Client/src/ui/noticeToasts.test.ts create mode 100644 src/HSchool.Client/src/ui/noticeToasts.ts create mode 100644 src/HSchool.Content/EventDefValidator.cs create mode 100644 src/HSchool.Content/EventDefs.cs create mode 100644 src/HSchool.Server/mods/core/defs/events/notices.jsonc create mode 100644 src/HSchool.Simulation/EventSystem.cs create mode 100644 src/HSchool.Simulation/WorldEvent.cs create mode 100644 tests/HSchool.Content.Tests/EventDefTests.cs create mode 100644 tests/HSchool.Simulation.Tests/WorldEventTests.cs diff --git a/docs/phases/11-events/64-event-defs.md b/docs/phases/11-events/64-event-defs.md index 8ed0b9d..a6ccc65 100644 --- a/docs/phases/11-events/64-event-defs.md +++ b/docs/phases/11-events/64-event-defs.md @@ -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-тост не висит из сейва ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index ee563dd..8fffa95 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 9b90d53..e43faba 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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 = { ru, en }; diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index 88d772e..9fcc5f4 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -43,6 +43,7 @@ async function bootstrap(): Promise { 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 { 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) { diff --git a/src/HSchool.Client/src/net/connection.ts b/src/HSchool.Client/src/net/connection.ts index 472f211..71858d1 100644 --- a/src/HSchool.Client/src/net/connection.ts +++ b/src/HSchool.Client/src/net/connection.ts @@ -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; diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index c054cba..7807b7e 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -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; diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 950da5e..6d574ad 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 = 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); diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index dd1886d..6ff9edd 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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); +} + diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index a1b84d0..c1adf8c 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -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])); diff --git a/src/HSchool.Client/src/ui/noticeToasts.test.ts b/src/HSchool.Client/src/ui/noticeToasts.test.ts new file mode 100644 index 0000000..ef340cd --- /dev/null +++ b/src/HSchool.Client/src/ui/noticeToasts.test.ts @@ -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(); + }); +}); diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts new file mode 100644 index 0000000..58aff04 --- /dev/null +++ b/src/HSchool.Client/src/ui/noticeToasts.ts @@ -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>(); + + 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('[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); + } + } +} diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index d53921b..11818f3 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -318,6 +318,7 @@ public sealed class CatalogLoader var topics = new Dictionary(StringComparer.Ordinal); var orientations = new Dictionary(StringComparer.Ordinal); var affinity = new Dictionary(StringComparer.Ordinal); + var events = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -392,6 +393,9 @@ public sealed class CatalogLoader case DefKind.AffinityRules: affinity[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Event: + events[key.Name] = Jsonc.Deserialize(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 Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index e239c61..4df7ce1 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -31,6 +31,7 @@ public sealed class DefCatalog IReadOnlyDictionary topics, IReadOnlyDictionary orientations, IReadOnlyDictionary affinity, + IReadOnlyDictionary events, IReadOnlyDictionary ru, IReadOnlyDictionary 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 Affinity { get; } + public IReadOnlyDictionary Events { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. 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)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index d4d4113..bc40272 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -25,6 +25,7 @@ public enum DefKind Topic, Orientation, AffinityRules, + Event, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/EventDefValidator.cs b/src/HSchool.Content/EventDefValidator.cs new file mode 100644 index 0000000..597cc3e --- /dev/null +++ b/src/HSchool.Content/EventDefValidator.cs @@ -0,0 +1,50 @@ +namespace HSchool.Content; + +internal static class EventDefValidator +{ + private static readonly HashSet Severities = new(StringComparer.Ordinal) + { + EventSeverities.Info, + EventSeverities.Warning, + EventSeverities.Error, + }; + + private static readonly HashSet Triggers = new(StringComparer.Ordinal) + { + EventTriggers.DayStart, + EventTriggers.LessonStart, + EventTriggers.GenerationFailed, + }; + + private static readonly HashSet 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."); + } + } + } +} diff --git a/src/HSchool.Content/EventDefs.cs b/src/HSchool.Content/EventDefs.cs new file mode 100644 index 0000000..41244c9 --- /dev/null +++ b/src/HSchool.Content/EventDefs.cs @@ -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"; +} + +/// +/// A fact the world can raise. Systems emit a trigger; the worker matches this def and builds a notice. +/// +public sealed class EventDef : Def +{ + public string Severity { get; init; } = EventSeverities.Info; + + public bool Pause { get; init; } + + /// Client toast lifetime in milliseconds. Zero means it stays until dismiss. + public int TtlMs { get; init; } + + public string Trigger { get; init; } = ""; + + public string Action { get; init; } = EventActions.None; +} diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index e5c02d2..45b04a0 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -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; diff --git a/src/HSchool.Protocol/MessageType.cs b/src/HSchool.Protocol/MessageType.cs index 92296fa..aff7305 100644 --- a/src/HSchool.Protocol/MessageType.cs +++ b/src/HSchool.Protocol/MessageType.cs @@ -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, } diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index 81939a5..b545d02 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -84,6 +84,29 @@ public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyListJump empty nights, weekends and holidays. The server re-checks the conditions. public readonly record struct ClientSkipEmptyMessage; +/// Closes one notice by id. Info is not stored; pausing dismiss is phase 65. +public readonly record struct ClientDismissNoticeMessage(uint Id); + +/// Wire values for . +public static class NoticeSeverity +{ + public const byte Info = 0; + public const byte Warning = 1; + public const byte Error = 2; +} + +/// +/// One school event for open clients. is 0 when nobody is in frame. +/// Info toasts are not replayed on OpenSchool. +/// +public readonly record struct ServerNoticeMessage( + uint Id, + string DefName, + byte Severity, + bool Pause, + uint TtlMs, + uint PersonId = 0); + /// Where people are: 1 in a node, 2 walking through it. Off campus is omitted. public static class PresenceState { diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index 5eae532..7dcaaa0 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -70,6 +70,14 @@ public static class ProtocolCodec return writer.Position; } + public static int WriteDismissNotice(Span 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 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 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 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 source) + { + var reader = new PacketReader(source); + Expect(ref reader, MessageType.ClientDismissNotice); + return new ClientDismissNoticeMessage(reader.ReadUInt32()); + } + + public static ServerNoticeMessage ReadNotice(ReadOnlySpan 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; diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index ba1f9d8..0db9598 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 = 9; + public const byte Version = 10; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index ce5cc13..3bc4d17 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -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; + /// Stops every worker, re-reads the save directory, starts workers from those files. internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index dc7c5cd..26d6684 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -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; diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs index 86387c7..69fdb9d 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -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; diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs index f11bce3..88caee6 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs @@ -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); + } + } + } } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index f547461..d169e1a 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -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; diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 4d39b3d..ae16b8d 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -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 Result) : WorkerCommand; internal sealed record GetPerson( diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index 5258709..be8a562 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -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}.", diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc new file mode 100644 index 0000000..477e7a9 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc @@ -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", + }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 902e086..64cbb61 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -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", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 99a16de..5fceaba 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -224,5 +224,8 @@ "LessonNoTeacher": "урок без учителя: {0}", "LessonCold": "замёрз на уроке: {0}", "LessonNoTextbook": "нет учебника: {0}", + "DayStarted": "Начало дня", + "LessonStarted": "Начало урока", + "GenerationFailed": "Не удалось нарисовать портрет", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/EventSystem.cs b/src/HSchool.Simulation/EventSystem.cs new file mode 100644 index 0000000..4a959ee --- /dev/null +++ b/src/HSchool.Simulation/EventSystem.cs @@ -0,0 +1,48 @@ +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// 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. +/// +internal static class EventSystem +{ + public static IReadOnlyList Detect(School school, DateTime before, DateTime after) + { + if (school.Catalog is null || after <= before) + { + return []; + } + + var facts = new List(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; + } +} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 98c1d4d..d9f96dc 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -18,6 +18,7 @@ public sealed class School : IDisposable private bool _disposed; private readonly List _dayLog = []; private readonly HashSet _lessonLogOnce = new(StringComparer.Ordinal); + private readonly List _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; } + /// Facts raised since the last drain. The worker maps them to notices; simulation has no UI. + public IReadOnlyList 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++; diff --git a/src/HSchool.Simulation/WorldEvent.cs b/src/HSchool.Simulation/WorldEvent.cs new file mode 100644 index 0000000..bf2307a --- /dev/null +++ b/src/HSchool.Simulation/WorldEvent.cs @@ -0,0 +1,6 @@ +namespace HSchool.Simulation; + +/// +/// A fact the school raised this step. Same seam as AffinityEvent: a list, no UI. +/// +public readonly record struct WorldEvent(string Trigger); diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 8eb0754..033cb48 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -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 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(); + + 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 Applicants); private sealed record ApplicantSnapshot(string Id, string FullName); diff --git a/tests/HSchool.Content.Tests/EventDefTests.cs b/tests/HSchool.Content.Tests/EventDefTests.cs new file mode 100644 index 0000000..bff8893 --- /dev/null +++ b/tests/HSchool.Content.Tests/EventDefTests.cs @@ -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(() => _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)); + } +} diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index aca3f99..bf47e91 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -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 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 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() { diff --git a/tests/HSchool.Simulation.Tests/WorldEventTests.cs b/tests/HSchool.Simulation.Tests/WorldEventTests.cs new file mode 100644 index 0000000..62b8a90 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/WorldEventTests.cs @@ -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(); + 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); + } +}