Add EventDef notices, morning and lesson bells, and info toasts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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-тост не висит из сейва
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
+34
-5
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user