Add EventDef notices, morning and lesson bells, and info toasts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 20:46:33 +03:00
co-authored by Cursor
parent ea3f5c7bf1
commit a554738084
38 changed files with 952 additions and 22 deletions
+6
View File
@@ -383,6 +383,9 @@ const ru = {
timetableErrorNoTeacher: 'Некому вести этот предмет.',
timetableErrorUnknown: 'Не удалось изменить урок.',
timetableErrorLesson: 'Этого закрепления уже нет.',
DayStarted: 'Начало дня',
LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет',
} as const;
type Messages = { [K in keyof typeof ru]: string };
@@ -770,6 +773,9 @@ const en: Messages = {
timetableErrorNoTeacher: 'Nobody is assigned that subject.',
timetableErrorUnknown: 'Could not change the lesson.',
timetableErrorLesson: 'That pinned lesson is gone.',
DayStarted: 'The day has started',
LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed',
};
const catalogs: Record<Locale, Messages> = { ru, en };
+6
View File
@@ -43,6 +43,7 @@ async function bootstrap(): Promise<void> {
onSetRunning: (running) => connection.setRunning(running),
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
onSkip: () => connection.skipEmpty(),
onDismissNotice: (id) => connection.dismissNotice(id),
});
const connection = new GameConnection(gameSocketUrl(), {
@@ -65,6 +66,11 @@ async function bootstrap(): Promise<void> {
game.applyPresence(presence.schoolId, presence);
}
},
onNotice: (notice) => {
if (openSchool !== null) {
game.applyNotice(notice);
}
},
onSchoolGone: (schoolId) => {
// Deleted from another tab while we were inside it.
if (openSchool?.id === schoolId) {
+10
View File
@@ -7,9 +7,11 @@ import {
encodeSetRunning,
encodeSetSpeed,
encodeSkipEmpty,
encodeDismissNotice,
ProtocolError,
type ClockMessage,
type MapSnapshotMessage,
type NoticeMessage,
type PresenceMessage,
type ServerMessage,
type WelcomeMessage,
@@ -23,6 +25,7 @@ export interface ConnectionHandlers {
onClock?(message: ClockMessage): void;
onMapSnapshot?(message: MapSnapshotMessage): void;
onPresence?(message: PresenceMessage): void;
onNotice?(message: NoticeMessage): void;
/** The open school was deleted elsewhere; the UI has to leave it. */
onSchoolGone?(schoolId: number): void;
/** Round-trip time in milliseconds. */
@@ -125,6 +128,10 @@ export class GameConnection {
this.send(encodeSkipEmpty());
}
dismissNotice(id: number): void {
this.send(encodeDismissNotice(id));
}
close(): void {
this.closedByUs = true;
this.stopTimers();
@@ -172,6 +179,9 @@ export class GameConnection {
case 'presence':
this.handlers.onPresence?.(message);
break;
case 'notice':
this.handlers.onNotice?.(message);
break;
case 'school-gone':
if (this.openSchoolId === message.schoolId) {
this.openSchoolId = null;
@@ -9,6 +9,7 @@ import {
encodeSetRunning,
encodeSetSpeed,
encodeSkipEmpty,
encodeDismissNotice,
MessageType,
PresenceState,
ProtocolError,
@@ -75,6 +76,15 @@ describe('client encoders', () => {
expect(view.byteLength).toBe(1);
expect(view.getUint8(0)).toBe(MessageType.ClientSkipEmpty);
});
it('writes dismiss as five little-endian bytes', () => {
const buffer = encodeDismissNotice(0x01020304);
const view = new DataView(buffer);
expect(view.byteLength).toBe(5);
expect(view.getUint8(0)).toBe(MessageType.ClientDismissNotice);
expect([...new Uint8Array(buffer, 1)]).toEqual([0x04, 0x03, 0x02, 0x01]);
});
});
describe('decodeServerMessage', () => {
@@ -434,6 +444,32 @@ describe('decodeServerMessage', () => {
});
});
it('reads a notice frame by the documented offsets', () => {
const defName = 'DayStarted';
const encoded = new TextEncoder().encode(defName);
const buffer = new ArrayBuffer(1 + 4 + 2 + encoded.length + 1 + 1 + 4 + 4);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerNotice);
view.setUint32(1, 0x0a0b0c0d, true);
view.setUint16(5, encoded.length, true);
new Uint8Array(buffer).set(encoded, 7);
const afterName = 7 + encoded.length;
view.setUint8(afterName, 0);
view.setUint8(afterName + 1, 0);
view.setUint32(afterName + 2, 8000, true);
view.setUint32(afterName + 6, 0, true);
expect(decodeServerMessage(buffer)).toEqual({
type: 'notice',
id: 0x0a0b0c0d,
defName: 'DayStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
});
it('ignores unknown message ids so new ones stay backwards compatible', () => {
const buffer = new Uint8Array([0xf0, 0x00]).buffer;
+57 -2
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
export const PROTOCOL_VERSION = 9;
export const PROTOCOL_VERSION = 10;
export const MessageType = {
ClientHello: 0x01,
@@ -15,12 +15,14 @@ export const MessageType = {
ClientSetRunning: 0x05,
ClientSetSpeed: 0x06,
ClientSkipEmpty: 0x07,
ClientDismissNotice: 0x08,
ServerWelcome: 0x81,
ServerPong: 0x82,
ServerClock: 0x83,
ServerSchoolGone: 0x84,
ServerMapSnapshot: 0x85,
ServerPresence: 0x86,
ServerNotice: 0x87,
} as const;
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
@@ -133,13 +135,31 @@ export interface PresenceMessage {
readonly people: readonly PresencePerson[];
}
export const NoticeSeverity = {
Info: 0,
Warning: 1,
Error: 2,
} as const;
export interface NoticeMessage {
readonly type: 'notice';
readonly id: number;
readonly defName: string;
readonly severity: number;
readonly pause: boolean;
readonly ttlMs: number;
/** 0 when nobody is in frame. */
readonly personId: number;
}
export type ServerMessage =
| WelcomeMessage
| PongMessage
| ClockMessage
| SchoolGoneMessage
| MapSnapshotMessage
| PresenceMessage;
| PresenceMessage
| NoticeMessage;
/** Thrown when a frame is truncated or carries an unexpected message id. */
export class ProtocolError extends Error {}
@@ -212,6 +232,14 @@ export function encodeSkipEmpty(): ArrayBuffer {
return buffer;
}
export function encodeDismissNotice(id: number): ArrayBuffer {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientDismissNotice);
view.setUint32(1, id >>> 0, true);
return buffer;
}
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
if (data.byteLength === 0) {
@@ -233,6 +261,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
return decodeMapSnapshot(view);
case MessageType.ServerPresence:
return decodePresence(view);
case MessageType.ServerNotice:
return decodeNotice(view);
default:
return null;
}
@@ -400,6 +430,31 @@ function decodePresence(view: DataView): PresenceMessage {
return { type: 'presence', schoolId, nodes, people };
}
function decodeNotice(view: DataView): NoticeMessage {
ensure(view, 5);
const id = view.getUint32(1, true);
const defName = readString(view, 5);
let offset = defName.next;
const severity = readU8(view, offset);
offset += 1;
const pause = readU8(view, offset);
offset += 1;
ensure(view, offset + 8);
const ttlMs = view.getUint32(offset, true);
offset += 4;
const personId = view.getUint32(offset, true);
return {
type: 'notice',
id,
defName: defName.text,
severity,
pause: pause !== 0,
ttlMs,
personId,
};
}
function readU8(view: DataView, offset: number): number {
ensure(view, offset + 1);
return view.getUint8(offset);
+33
View File
@@ -99,6 +99,7 @@ body {
*/
.screen.game {
max-width: none;
position: relative;
}
#status {
@@ -1601,3 +1602,35 @@ body {
gap: 8px;
font-size: 13px;
}
.notice-toasts {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100% - 32px));
pointer-events: none;
}
.notice-toast {
pointer-events: auto;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text);
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
}
.notice-toast:hover {
border-color: var(--accent);
}
+16
View File
@@ -4,6 +4,7 @@ import {
type ClockMessage,
type MapSnapshotItem,
type MapSnapshotNode,
type NoticeMessage,
type PresenceMessage,
type PresenceNode,
} from '../net/protocol.ts';
@@ -14,6 +15,7 @@ import { t } from '../i18n/strings.ts';
import { fetchDirectory, type School } from '../net/api.ts';
import { clear, el } from './dom.ts';
import { ManagementPanel } from './managementPanel.ts';
import { NoticeToasts } from './noticeToasts.ts';
import { PeoplePanel } from './peoplePanel.ts';
import { locationPersonLine } from '../format/talkCircle.ts';
import { formatPersonPlace } from './personCard.ts';
@@ -33,6 +35,7 @@ interface GameScreenOptions {
readonly onSetRunning: (running: boolean) => void;
readonly onSetSpeed: (speedIndex: number) => void;
readonly onSkip: () => void;
readonly onDismissNotice?: (id: number) => void;
}
const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10'];
@@ -83,6 +86,7 @@ export class GameScreen {
this.syncRoute();
},
});
private readonly notices: NoticeToasts;
private readonly management = new ManagementPanel();
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' });
@@ -112,6 +116,7 @@ export class GameScreen {
private leftTab: LeftTab = 'map';
constructor(options: GameScreenOptions) {
this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id));
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', {
class: 'button button--small',
@@ -142,6 +147,7 @@ export class GameScreen {
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
this.overview,
this.manage,
this.notices.element,
);
this.overview.append(
@@ -204,6 +210,7 @@ export class GameScreen {
this.people.localize();
this.management.localize();
this.notices.localize();
this.paintSelection();
if (this.lastGameTime !== null) {
@@ -244,6 +251,7 @@ export class GameScreen {
this.directory = new Map();
this.skipAllowed = false;
this.skipTarget = null;
this.notices.clear();
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
@@ -303,6 +311,14 @@ export class GameScreen {
}
}
applyNotice(message: NoticeMessage): void {
if (this.schoolId === null) {
return;
}
this.notices.show(message);
}
/** Names for the presence stream. The frame itself never carries display names. */
applyDirectory(people: readonly { id: string; fullName: string }[]): void {
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
@@ -0,0 +1,62 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { NoticeToasts } from './noticeToasts.ts';
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
setLocale('ru');
});
describe('NoticeToasts', () => {
it('shows the localized defName and click sends dismiss', () => {
setLocale('ru');
const onDismiss = vi.fn();
const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element);
toasts.show({
type: 'notice',
id: 7,
defName: 'DayStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
const toast = toasts.element.querySelector('.notice-toast');
expect(toast?.textContent).toBe(t('DayStarted'));
expect(document.querySelector('.events-column')).toBeNull();
expect(document.querySelector('[data-events-column]')).toBeNull();
toast?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onDismiss).toHaveBeenCalledWith(7);
expect(toasts.element.querySelector('.notice-toast')).toBeNull();
});
it('hides on TTL without sending dismiss', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element);
toasts.show({
type: 'notice',
id: 3,
defName: 'LessonStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
vi.advanceTimersByTime(8000);
expect(onDismiss).not.toHaveBeenCalled();
expect(toasts.element.querySelector('.notice-toast')).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { NoticeMessage } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
const MAX_INFO_TOASTS = 8;
function label(defName: string): string {
switch (defName) {
case 'DayStarted':
case 'LessonStarted':
case 'GenerationFailed':
return t(defName satisfies MessageKey);
default:
return defName;
}
}
/**
* Info toasts over the school screen. Not a column: a stack in the corner that TTL and click
* dismiss. Click tells the server; TTL only hides locally.
*/
export class NoticeToasts {
readonly element = el('div', { class: 'notice-toasts' });
private readonly timers = new Map<number, ReturnType<typeof setTimeout>>();
constructor(private readonly onDismiss: (id: number) => void) {}
show(notice: NoticeMessage): void {
this.remove(notice.id);
while (this.element.childElementCount >= MAX_INFO_TOASTS) {
const oldest = this.element.firstElementChild;
if (!(oldest instanceof HTMLElement)) {
break;
}
const oldestId = Number.parseInt(oldest.dataset.noticeId ?? '', 10);
this.remove(Number.isFinite(oldestId) ? oldestId : 0, false);
}
const toast = el(
'button',
{
class: 'notice-toast',
type: 'button',
text: label(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
onClick: () => this.remove(notice.id, true),
},
);
this.element.append(toast);
if (notice.ttlMs > 0) {
this.timers.set(
notice.id,
setTimeout(() => this.remove(notice.id, false), notice.ttlMs),
);
}
}
clear(): void {
for (const id of [...this.timers.keys()]) {
this.remove(id, false);
}
this.element.replaceChildren();
}
localize(): void {
for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) {
const defName = node.dataset.noticeDef;
if (defName !== undefined) {
node.textContent = label(defName);
}
}
}
private remove(id: number, send = false): void {
const timer = this.timers.get(id);
if (timer !== undefined) {
clearTimeout(timer);
this.timers.delete(id);
}
this.element.querySelector(`[data-notice-id="${id}"]`)?.remove();
if (send) {
this.onDismiss(id);
}
}
}
+8 -1
View File
@@ -318,6 +318,7 @@ public sealed class CatalogLoader
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -392,6 +393,9 @@ public sealed class CatalogLoader
case DefKind.AffinityRules:
affinity[key.Name] = Jsonc.Deserialize<AffinityRulesDef>(json);
break;
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
break;
}
}
@@ -420,6 +424,7 @@ public sealed class CatalogLoader
topics,
orientations,
affinity,
events,
ru,
en);
}
@@ -534,6 +539,7 @@ public sealed class CatalogLoader
}
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
@@ -571,7 +577,8 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.Colors.Values))
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values));
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
+6
View File
@@ -31,6 +31,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, TopicDef> topics,
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, EventDef> events,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -58,6 +59,7 @@ public sealed class DefCatalog
Topics = topics;
Orientations = orientations;
Affinity = affinity;
Events = events;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -121,6 +123,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, AffinityRulesDef> Affinity { get; }
public IReadOnlyDictionary<string, EventDef> Events { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -163,6 +167,7 @@ public sealed class DefCatalog
DefKind.Topic => Topics.GetValueOrDefault(defName),
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
DefKind.Event => Events.GetValueOrDefault(defName),
_ => null,
};
@@ -246,6 +251,7 @@ public sealed class DefCatalog
TopicDef => DefKind.Topic,
OrientationDef => DefKind.Orientation,
AffinityRulesDef => DefKind.AffinityRules,
EventDef => DefKind.Event,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+1
View File
@@ -25,6 +25,7 @@ public enum DefKind
Topic,
Orientation,
AffinityRules,
Event,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+50
View File
@@ -0,0 +1,50 @@
namespace HSchool.Content;
internal static class EventDefValidator
{
private static readonly HashSet<string> Severities = new(StringComparer.Ordinal)
{
EventSeverities.Info,
EventSeverities.Warning,
EventSeverities.Error,
};
private static readonly HashSet<string> Triggers = new(StringComparer.Ordinal)
{
EventTriggers.DayStart,
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
};
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
{
EventActions.None,
EventActions.GenerateImage,
};
public static void Validate(DefCatalog catalog)
{
foreach (var def in catalog.Events.Values)
{
if (!Severities.Contains(def.Severity))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown severity '{def.Severity}'.");
}
if (!Triggers.Contains(def.Trigger))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown trigger '{def.Trigger}'.");
}
if (!Actions.Contains(def.Action))
{
throw new ContentLoadException($"EventDef '{def.DefName}' has unknown action '{def.Action}'.");
}
if (def.TtlMs < 0)
{
throw new ContentLoadException($"EventDef '{def.DefName}' ttlMs cannot be negative.");
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.Content;
public static class EventSeverities
{
public const string Info = "info";
public const string Warning = "warning";
public const string Error = "error";
}
public static class EventTriggers
{
public const string DayStart = "dayStart";
public const string LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed";
}
public static class EventActions
{
public const string None = "none";
public const string GenerateImage = "generateImage";
}
/// <summary>
/// A fact the world can raise. Systems emit a trigger; the worker matches this def and builds a notice.
/// </summary>
public sealed class EventDef : Def
{
public string Severity { get; init; } = EventSeverities.Info;
public bool Pause { get; init; }
/// <summary>Client toast lifetime in milliseconds. Zero means it stays until dismiss.</summary>
public int TtlMs { get; init; }
public string Trigger { get; init; } = "";
public string Action { get; init; } = EventActions.None;
}
+3
View File
@@ -138,6 +138,9 @@ internal static class PackPaths
case "affinity":
kind = DefKind.AffinityRules;
return true;
case "events":
kind = DefKind.Event;
return true;
default:
kind = default;
return false;
+2
View File
@@ -15,6 +15,7 @@ public enum MessageType : byte
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ClientSkipEmpty = 0x07,
ClientDismissNotice = 0x08,
ServerWelcome = 0x81,
ServerPong = 0x82,
@@ -22,4 +23,5 @@ public enum MessageType : byte
ServerSchoolGone = 0x84,
ServerMapSnapshot = 0x85,
ServerPresence = 0x86,
ServerNotice = 0x87,
}
+23
View File
@@ -84,6 +84,29 @@ public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSna
/// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary>
public readonly record struct ClientSkipEmptyMessage;
/// <summary>Closes one notice by id. Info is not stored; pausing dismiss is phase 65.</summary>
public readonly record struct ClientDismissNoticeMessage(uint Id);
/// <summary>Wire values for <see cref="ServerNoticeMessage.Severity"/>.</summary>
public static class NoticeSeverity
{
public const byte Info = 0;
public const byte Warning = 1;
public const byte Error = 2;
}
/// <summary>
/// One school event for open clients. <paramref name="PersonId"/> is 0 when nobody is in frame.
/// Info toasts are not replayed on OpenSchool.
/// </summary>
public readonly record struct ServerNoticeMessage(
uint Id,
string DefName,
byte Severity,
bool Pause,
uint TtlMs,
uint PersonId = 0);
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
public static class PresenceState
{
+44
View File
@@ -70,6 +70,14 @@ public static class ProtocolCodec
return writer.Position;
}
public static int WriteDismissNotice(Span<byte> destination, in ClientDismissNoticeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientDismissNotice);
writer.WriteUInt32(message.Id);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
@@ -271,6 +279,22 @@ public static class ProtocolCodec
return writer.Position;
}
public static int NoticeSize(in ServerNoticeMessage message) =>
sizeof(byte) + sizeof(uint) + StringSize(message.DefName) + sizeof(byte) + sizeof(byte) + sizeof(uint) + sizeof(uint);
public static int WriteNotice(Span<byte> destination, in ServerNoticeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerNotice);
writer.WriteUInt32(message.Id);
writer.WriteString(message.DefName);
writer.WriteByte(message.Severity);
writer.WriteByte(message.Pause ? (byte)1 : (byte)0);
writer.WriteUInt32(message.TtlMs);
writer.WriteUInt32(message.PersonId);
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -441,6 +465,26 @@ public static class ProtocolCodec
return new ServerPresenceMessage(schoolId, nodes, people);
}
public static ClientDismissNoticeMessage ReadDismissNotice(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientDismissNotice);
return new ClientDismissNoticeMessage(reader.ReadUInt32());
}
public static ServerNoticeMessage ReadNotice(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerNotice);
var id = reader.ReadUInt32();
var defName = reader.ReadString();
var severity = reader.ReadByte();
var pause = reader.ReadByte() != 0;
var ttlMs = reader.ReadUInt32();
var personId = reader.ReadUInt32();
return new ServerNoticeMessage(id, defName, severity, pause, ttlMs, personId);
}
private static bool HasPresenceActivity(PresenceNode node) =>
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 9;
public const byte Version = 10;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;
+2
View File
@@ -42,6 +42,8 @@ internal abstract record GameCommand
internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : GameCommand;
internal sealed record DismissNotice(uint PlayerId, uint NoticeId) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
@@ -225,6 +225,10 @@ internal sealed class GameLoopService(
HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty());
break;
case GameCommand.DismissNotice dismiss:
RouteOpenSchool(dismiss.PlayerId, new WorkerCommand.DismissNotice(dismiss.NoticeId));
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
@@ -61,6 +61,10 @@ internal sealed partial class SchoolWorker
ApplySkip(school);
break;
case WorkerCommand.DismissNotice:
// Info is not stored; pausing dismiss is phase 65.
break;
case WorkerCommand.Dump dump:
dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
break;
@@ -165,6 +165,7 @@ internal sealed partial class SchoolWorker
{
PublishSnapshot();
BroadcastClock();
EmitWorldEvents(school);
MaybeBroadcastPresence(school);
}
@@ -244,6 +245,7 @@ internal sealed partial class SchoolWorker
PublishSnapshot();
Persist();
BroadcastClock();
EmitWorldEvents(school);
BroadcastPresence();
_presenceAge = 0;
}
@@ -489,4 +491,48 @@ internal sealed partial class SchoolWorker
var length = ProtocolCodec.WritePresence(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
private void EmitWorldEvents(School school)
{
var catalog = school.Catalog;
if (catalog is null)
{
school.DrainWorldEvents();
return;
}
foreach (var fact in school.DrainWorldEvents())
{
foreach (var def in catalog.Events.Values)
{
if (def.Abstract
|| !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal)
|| !def.Severity.Equals(EventSeverities.Info, StringComparison.Ordinal))
{
continue;
}
BroadcastNotice(new ServerNoticeMessage(
++_nextNoticeId,
def.DefName,
NoticeSeverity.Info,
def.Pause,
(uint)Math.Max(0, def.TtlMs)));
}
}
}
private void BroadcastNotice(in ServerNoticeMessage message)
{
var frame = new byte[ProtocolCodec.NoticeSize(message)];
var length = ProtocolCodec.WriteNotice(frame, message);
var payload = frame.AsMemory(0, length);
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
client.TrySendReliable(payload);
}
}
}
}
+1
View File
@@ -53,6 +53,7 @@ internal sealed partial class SchoolWorker
private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot;
private int _presenceAge;
private uint _nextNoticeId;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
+2
View File
@@ -22,6 +22,8 @@ internal abstract record WorkerCommand
internal sealed record SkipEmpty : WorkerCommand;
internal sealed record DismissNotice(uint Id) : WorkerCommand;
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
internal sealed record GetPerson(
@@ -180,6 +180,11 @@ internal sealed class GameSocketHandler(
break;
case MessageType.ClientDismissNotice:
var dismiss = ProtocolCodec.ReadDismissNotice(frame);
commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id));
break;
default:
logger.LogDebug(
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
@@ -0,0 +1,26 @@
[
{
"defName": "DayStarted",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "dayStart",
"action": "none",
},
{
"defName": "LessonStarted",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "lessonStart",
"action": "none",
},
{
"defName": "GenerationFailed",
"severity": "error",
"pause": true,
"ttlMs": 0,
"trigger": "generationFailed",
"action": "none",
},
]
@@ -224,5 +224,8 @@
"LessonNoTeacher": "lesson without a teacher: {0}",
"LessonCold": "too cold in class: {0}",
"LessonNoTextbook": "no textbook: {0}",
"DayStarted": "The day has started",
"LessonStarted": "A lesson has started",
"GenerationFailed": "Portrait generation failed",
"core": "Core",
}
@@ -224,5 +224,8 @@
"LessonNoTeacher": "урок без учителя: {0}",
"LessonCold": "замёрз на уроке: {0}",
"LessonNoTextbook": "нет учебника: {0}",
"DayStarted": "Начало дня",
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
"core": "Базовая игра",
}
+48
View File
@@ -0,0 +1,48 @@
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Turns clock edges into world facts. One dayStart per morning crossing, one lessonStart per
/// school when the bell enters a lesson period — not one per class.
/// </summary>
internal static class EventSystem
{
public static IReadOnlyList<WorldEvent> Detect(School school, DateTime before, DateTime after)
{
if (school.Catalog is null || after <= before)
{
return [];
}
var facts = new List<WorldEvent>(2);
if (CrossedWorkMorning(school.Catalog, before, after, school.SchoolWeekDays))
{
facts.Add(new WorldEvent(EventTriggers.DayStart));
}
if (EnteredLessonPeriod(school.Catalog, before, after, school.SchoolWeekDays))
{
facts.Add(new WorldEvent(EventTriggers.LessonStart));
}
return facts;
}
private static bool CrossedWorkMorning(DefCatalog catalog, DateTime before, DateTime after, int weekDays)
{
if (!PersonDayLog.CrossedDayStart(before, after))
{
return false;
}
return SchoolDay.IsWorkday(catalog, after, weekDays);
}
private static bool EnteredLessonPeriod(DefCatalog catalog, DateTime before, DateTime after, int weekDays)
{
var beforeSlot = SchoolDay.At(catalog, before, weekDays);
var afterSlot = SchoolDay.At(catalog, after, weekDays);
return afterSlot.Kind == DaySlotKind.Lesson && beforeSlot != afterSlot;
}
}
+22
View File
@@ -18,6 +18,7 @@ public sealed class School : IDisposable
private bool _disposed;
private readonly List<PersonLogEvent> _dayLog = [];
private readonly HashSet<string> _lessonLogOnce = new(StringComparer.Ordinal);
private readonly List<WorldEvent> _worldEvents = [];
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
{
@@ -297,6 +298,7 @@ public sealed class School : IDisposable
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before);
SyncWeather(force: true);
RecordWorldEvents(before, next.Value);
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
@@ -368,6 +370,8 @@ public sealed class School : IDisposable
var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes);
peopleChanged |= ApplyHeavySystems(heavyMinutes);
}
RecordWorldEvents(before, Clock.Time);
}
else
{
@@ -377,6 +381,24 @@ public sealed class School : IDisposable
return peopleChanged;
}
/// <summary>Facts raised since the last drain. The worker maps them to notices; simulation has no UI.</summary>
public IReadOnlyList<WorldEvent> DrainWorldEvents()
{
if (_worldEvents.Count == 0)
{
return [];
}
var copy = _worldEvents.ToArray();
_worldEvents.Clear();
return copy;
}
private void RecordWorldEvents(DateTime before, DateTime after)
{
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
}
private bool ApplyHeavySystems(double gameMinutes)
{
HeavySystemsInvocations++;
+6
View File
@@ -0,0 +1,6 @@
namespace HSchool.Simulation;
/// <summary>
/// A fact the school raised this step. Same seam as <c>AffinityEvent</c>: a list, no UI.
/// </summary>
public readonly record struct WorldEvent(string Trigger);