Add EventDef notices, morning and lesson bells, and info toasts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user