Pause the school on warning notices until the owner dismisses them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 22:33:38 +03:00
co-authored by Cursor
parent e22d9bae33
commit f36af269bb
27 changed files with 993 additions and 51 deletions
+4
View File
@@ -386,6 +386,8 @@ const ru = {
DayStarted: 'Начало дня',
LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет',
noticeDismiss: 'Закрыть',
noticePauseLocked: 'Закройте предупреждение, чтобы продолжить',
} as const;
type Messages = { [K in keyof typeof ru]: string };
@@ -776,6 +778,8 @@ const en: Messages = {
DayStarted: 'The day has started',
LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed',
noticeDismiss: 'Close',
noticePauseLocked: 'Close the warning to resume',
};
const catalogs: Record<Locale, Messages> = { ru, en };
+38
View File
@@ -1634,3 +1634,41 @@ body {
border-color: var(--accent);
}
.notice-modals {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: rgb(0 0 0 / 0.35);
}
.notice-modals[hidden] {
display: none !important;
}
.notice-modal {
pointer-events: auto;
display: flex;
flex-direction: column;
gap: 12px;
max-width: min(420px, calc(100% - 32px));
margin: 16px;
padding: 16px 18px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-raised);
box-shadow: 0 16px 40px rgb(0 0 0 / 0.45);
}
.notice-modal--error {
border-color: var(--danger);
}
.notice-modal__text {
margin: 0;
font-size: 15px;
}
+24
View File
@@ -16,6 +16,7 @@ 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 { NoticeModals } from './noticeModals.ts';
import { PeoplePanel } from './peoplePanel.ts';
import { locationPersonLine } from '../format/talkCircle.ts';
import { formatPersonPlace } from './personCard.ts';
@@ -87,6 +88,7 @@ export class GameScreen {
},
});
private readonly notices: NoticeToasts;
private readonly noticeModals: NoticeModals;
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' });
@@ -117,6 +119,11 @@ export class GameScreen {
constructor(options: GameScreenOptions) {
this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id));
this.noticeModals = new NoticeModals(
(id) => options.onDismissNotice?.(id),
() => this.canManage,
() => this.paintPlay(),
);
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', {
class: 'button button--small',
@@ -148,6 +155,7 @@ export class GameScreen {
this.overview,
this.manage,
this.notices.element,
this.noticeModals.element,
);
this.overview.append(
@@ -211,6 +219,7 @@ export class GameScreen {
this.people.localize();
this.management.localize();
this.notices.localize();
this.noticeModals.localize();
this.paintSelection();
if (this.lastGameTime !== null) {
@@ -252,6 +261,7 @@ export class GameScreen {
this.skipAllowed = false;
this.skipTarget = null;
this.notices.clear();
this.noticeModals.clear();
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
@@ -316,6 +326,11 @@ export class GameScreen {
return;
}
if (message.pause || message.severity !== 0) {
this.noticeModals.show(message);
return;
}
this.notices.show(message);
}
@@ -502,6 +517,7 @@ export class GameScreen {
this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? t('pause') : t('resume');
this.paintPlay();
this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex);
@@ -517,6 +533,14 @@ export class GameScreen {
: '';
}
private paintPlay(): void {
const blocked = this.noticeModals.blocksPlay;
this.playPauseButton.disabled = blocked;
if (blocked && !this.running) {
this.playPauseButton.title = t('noticePauseLocked');
}
}
private paintSeed(): void {
this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed });
}
@@ -0,0 +1,69 @@
/**
* @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 { NoticeModals } from './noticeModals.ts';
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
setLocale('ru');
});
function pausing(id: number, defName = 'GenerationFailed'): Parameters<NoticeModals['show']>[0] {
return {
type: 'notice',
id,
defName,
severity: 2,
pause: true,
ttlMs: 8000,
personId: 0,
};
}
describe('NoticeModals', () => {
it('does not hide on TTL', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const modals = new NoticeModals(onDismiss, () => true, () => {});
document.body.append(modals.element);
modals.show(pausing(4));
expect(modals.element.querySelector('.notice-modal__text')?.textContent).toBe(t('GenerationFailed'));
vi.advanceTimersByTime(8000);
expect(onDismiss).not.toHaveBeenCalled();
expect(modals.element.querySelector('.notice-modal')).not.toBeNull();
expect(modals.open).toBe(true);
});
it('shows the next queued notice after close', () => {
const onDismiss = vi.fn();
const modals = new NoticeModals(onDismiss, () => true, () => {});
document.body.append(modals.element);
modals.show(pausing(1, 'GenerationFailed'));
modals.show({ ...pausing(2, 'GenerationFailed') });
expect(modals.element.querySelectorAll('.notice-modal')).toHaveLength(1);
expect(modals.element.querySelector('[data-notice-id="1"]')).not.toBeNull();
modals.element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onDismiss).toHaveBeenCalledWith(1);
expect(modals.element.querySelector('[data-notice-id="2"]')).not.toBeNull();
expect(modals.element.querySelector('[data-notice-id="1"]')).toBeNull();
});
it('hides the close button when the viewer cannot dismiss', () => {
const onDismiss = vi.fn();
const modals = new NoticeModals(onDismiss, () => false, () => {});
document.body.append(modals.element);
modals.show(pausing(9));
expect(modals.element.querySelector('button')).toBeNull();
expect(modals.element.querySelector('.notice-modal')).not.toBeNull();
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { NoticeMessage } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { noticeLabel } from './noticeToasts.ts';
/**
* One pausing warning/error at a time. Not a toast: no TTL, a queue, close is dismiss.
* Guests see the card and have no close button — only the owner can clear the pause.
*/
export class NoticeModals {
readonly element = el('div', { class: 'notice-modals', hidden: true });
private readonly queue: NoticeMessage[] = [];
constructor(
private readonly onDismiss: (id: number) => void,
private readonly canDismiss: () => boolean,
private readonly onChange: () => void,
) {}
get open(): boolean {
return this.queue.length > 0;
}
get blocksPlay(): boolean {
return this.queue.some((notice) => notice.pause);
}
show(notice: NoticeMessage): void {
if (this.queue.some((item) => item.id === notice.id)) {
return;
}
this.queue.push(notice);
this.paint();
this.onChange();
}
clear(): void {
this.queue.length = 0;
this.paint();
this.onChange();
}
localize(): void {
this.paint();
}
private dismissCurrent(): void {
const current = this.queue.shift();
if (current === undefined) {
return;
}
this.onDismiss(current.id);
this.paint();
this.onChange();
}
private paint(): void {
const current = this.queue[0];
if (current === undefined) {
this.element.hidden = true;
this.element.replaceChildren();
return;
}
this.element.hidden = false;
const close = this.canDismiss()
? el('button', {
class: 'button',
type: 'button',
text: t('noticeDismiss' satisfies MessageKey),
onClick: () => this.dismissCurrent(),
})
: null;
const card = el(
'div',
{
class: current.severity === 2 ? 'notice-modal notice-modal--error' : 'notice-modal notice-modal--warning',
dataset: { noticeId: String(current.id), noticeDef: current.defName },
},
el('p', { class: 'notice-modal__text', text: noticeLabel(current.defName) }),
close,
);
this.element.replaceChildren(card);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { el } from './dom.ts';
const MAX_INFO_TOASTS = 8;
function label(defName: string): string {
export function noticeLabel(defName: string): string {
switch (defName) {
case 'DayStarted':
case 'LessonStarted':
@@ -42,7 +42,7 @@ export class NoticeToasts {
{
class: 'notice-toast',
type: 'button',
text: label(notice.defName),
text: noticeLabel(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
onClick: () => this.remove(notice.id, true),
},
@@ -69,7 +69,7 @@ export class NoticeToasts {
for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) {
const defName = node.dataset.noticeDef;
if (defName !== undefined) {
node.textContent = label(defName);
node.textContent = noticeLabel(defName);
}
}
}
+2 -2
View File
@@ -84,7 +84,7 @@ 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>
/// <summary>Closes one notice by id. Info is not stored; pausing dismiss is owner-only.</summary>
public readonly record struct ClientDismissNoticeMessage(uint Id);
/// <summary>Wire values for <see cref="ServerNoticeMessage.Severity"/>.</summary>
@@ -97,7 +97,7 @@ public static class NoticeSeverity
/// <summary>
/// One school event for open clients. <paramref name="PersonId"/> is 0 when nobody is in frame.
/// Info toasts are not replayed on OpenSchool.
/// Info toasts are not replayed on OpenSchool. Pausing notices are saved and resent on Open.
/// </summary>
public readonly record struct ServerNoticeMessage(
uint Id,
+39
View File
@@ -38,6 +38,43 @@ internal static class DevEndpoints
return Results.Ok(MapDump(published, live));
})
.WithName("DumpSchool");
builder.MapPost("/api/dev/schools/{id:int}/notices", async (
int id,
PostDevNoticeRequest? request,
GameLoopService loop,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
if (loop.FindSchool(id) is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
var defName = request?.DefName?.Trim() ?? "";
if (defName.Length == 0)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "defName is required.");
}
var command = new GameCommand.PostSchoolNotice(
id,
defName,
request?.PersonId ?? 0,
NewCompletion<PostedNotice?>());
commands.Enqueue(command);
var posted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
if (posted is null)
{
return Problem(
StatusCodes.Status409Conflict,
"notice-rejected",
"Unknown event def or the sticky notice ceiling is full.");
}
return Results.Ok(new { id = posted.Id, defName = posted.DefName, pause = posted.Pause });
})
.WithName("PostDevNotice");
}
private static SchoolDumpResponse MapDump(PublishedSchoolPeople published, SchoolLiveDump live)
@@ -115,3 +152,5 @@ internal sealed record SchoolDumpLessonResponse(
string RoomId,
int Day,
int Period);
internal sealed record PostDevNoticeRequest(string? DefName, uint PersonId);
+35
View File
@@ -700,6 +700,41 @@ internal static class SchoolEndpoints
return StaffingResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("UnassignSchoolSubject");
schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async (
int id,
long noticeId,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
{
return Results.Unauthorized();
}
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
if (denied is not null)
{
return denied;
}
if (noticeId is < 1 or > uint.MaxValue)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "Notice id is out of range.");
}
var command = new GameCommand.DismissSchoolNotice(
id,
(uint)noticeId,
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
commands.Enqueue(command);
await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return Results.NoContent();
})
.WithName("DismissSchoolNotice");
}
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
+9 -1
View File
@@ -42,7 +42,15 @@ internal abstract record GameCommand
internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : GameCommand;
internal sealed record DismissNotice(uint PlayerId, uint NoticeId) : GameCommand;
internal sealed record DismissNotice(uint PlayerId, uint NoticeId, string NormalizedUserName) : GameCommand;
internal sealed record DismissSchoolNotice(int SchoolId, uint NoticeId, TaskCompletionSource Result) : GameCommand;
internal sealed record PostSchoolNotice(
int SchoolId,
string DefName,
uint PersonId,
TaskCompletionSource<PostedNotice?> Result) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
+32 -14
View File
@@ -226,7 +226,15 @@ internal sealed class GameLoopService(
break;
case GameCommand.DismissNotice dismiss:
RouteOpenSchool(dismiss.PlayerId, new WorkerCommand.DismissNotice(dismiss.NoticeId));
HandleClockCommand(dismiss.PlayerId, dismiss.NormalizedUserName, new WorkerCommand.DismissNotice(dismiss.NoticeId));
break;
case GameCommand.DismissSchoolNotice dismissHttp:
HandleDismissHttp(dismissHttp);
break;
case GameCommand.PostSchoolNotice postNotice:
HandlePostNotice(postNotice);
break;
case GameCommand.ReloadSaves reload:
@@ -639,17 +647,6 @@ internal sealed class GameLoopService(
}
}
private void RouteOpenSchool(uint playerId, WorkerCommand command)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId)
{
return;
}
Route(schoolId, command);
}
private void HandleClockCommand(uint playerId, string normalizedUserName, WorkerCommand command)
{
var client = clients.Find(playerId);
@@ -667,6 +664,24 @@ internal sealed class GameLoopService(
Route(schoolId, command);
}
private void HandleDismissHttp(GameCommand.DismissSchoolNotice command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.DismissNoticeHttp(command.NoticeId, command.Result)))
{
command.Result.TrySetResult();
}
}
private void HandlePostNotice(GameCommand.PostSchoolNotice command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonId, command.Result)))
{
command.Result.TrySetResult(null);
}
}
private async Task StartWorkersFromDiskAsync()
{
var saves = store.LoadAll();
@@ -725,7 +740,8 @@ internal sealed class GameLoopService(
save.DressRules,
save.SpeechRules,
save.Owner,
save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings));
save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings),
save.Notices);
worker.Start();
try
@@ -784,7 +800,8 @@ internal sealed class GameLoopService(
SchoolDressRules? dressRules = null,
SchoolSpeechRules? speechRules = null,
string? owner = null,
SwarmUiConfigFile? portraitSettings = null) =>
SwarmUiConfigFile? portraitSettings = null,
IReadOnlyList<StickyNoticeSave>? notices = null) =>
new(
id,
name,
@@ -803,6 +820,7 @@ internal sealed class GameLoopService(
speechRules,
owner,
portraitSettings,
notices,
_options,
clients,
metrics,
+141
View File
@@ -0,0 +1,141 @@
using HSchool.Content;
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>Unclosed warning/error notices. Info is never stored here.</summary>
internal sealed class StickyNotice
{
public required uint Id { get; init; }
public required string DefName { get; init; }
public required byte Severity { get; init; }
public required bool Pause { get; init; }
public required uint TtlMs { get; init; }
public required uint PersonId { get; init; }
public ServerNoticeMessage ToMessage() => new(Id, DefName, Severity, Pause, TtlMs, PersonId);
public StickyNoticeSave ToSave() => new()
{
Id = Id,
DefName = DefName,
Severity = Severity,
Pause = Pause,
TtlMs = TtlMs,
PersonId = PersonId,
};
public static byte WireSeverity(string severity) => severity switch
{
EventSeverities.Warning => NoticeSeverity.Warning,
EventSeverities.Error => NoticeSeverity.Error,
_ => NoticeSeverity.Info,
};
}
/// <summary>
/// School-side queue of pausing notices. Ceiling is <see cref="Simulation.SimulationOptions.MaxStickyNotices"/>.
/// The ninth is not posted. Dismiss does not start the clock.
/// </summary>
internal sealed class NoticeBoard
{
private readonly List<StickyNotice> _sticky = [];
private uint _nextId;
public NoticeBoard(int maxSticky, IReadOnlyList<StickyNoticeSave>? saved = null)
{
MaxSticky = Math.Max(1, maxSticky);
if (saved is null)
{
return;
}
foreach (var row in saved)
{
if (!row.Pause || string.IsNullOrWhiteSpace(row.DefName) || row.Id == 0)
{
continue;
}
if (_sticky.Count >= MaxSticky)
{
break;
}
_sticky.Add(new StickyNotice
{
Id = row.Id,
DefName = row.DefName,
Severity = row.Severity,
Pause = true,
TtlMs = row.TtlMs,
PersonId = row.PersonId,
});
if (row.Id > _nextId)
{
_nextId = row.Id;
}
}
}
public int MaxSticky { get; }
public bool HasPausing => _sticky.Count > 0;
public IReadOnlyList<StickyNotice> Sticky => _sticky;
public IReadOnlyList<StickyNoticeSave> ToSave() => [.. _sticky.Select(notice => notice.ToSave())];
/// <summary>
/// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast
/// only. Returns <see langword="false"/> when the sticky ceiling would be exceeded.
/// </summary>
public bool TryPost(EventDef def, uint personId, out ServerNoticeMessage message)
{
message = default;
if (def.Pause && _sticky.Count >= MaxSticky)
{
return false;
}
var id = ++_nextId;
message = new ServerNoticeMessage(
id,
def.DefName,
StickyNotice.WireSeverity(def.Severity),
def.Pause,
(uint)Math.Max(0, def.TtlMs),
personId);
if (def.Pause)
{
_sticky.Add(new StickyNotice
{
Id = id,
DefName = def.DefName,
Severity = message.Severity,
Pause = true,
TtlMs = message.TtlMs,
PersonId = personId,
});
}
return true;
}
public bool TryDismiss(uint id)
{
var index = _sticky.FindIndex(notice => notice.Id == id);
if (index < 0)
{
return false;
}
_sticky.RemoveAt(index);
return true;
}
}
+21
View File
@@ -46,6 +46,25 @@ internal sealed class SchoolSave
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
public SwarmUiConfigFile? PortraitSettings { get; init; }
/// <summary>Unclosed pausing notices. Info is never written here.</summary>
public IReadOnlyList<StickyNoticeSave>? Notices { get; init; }
}
/// <summary>One sticky warning/error kept across F5 and worker restart.</summary>
internal sealed class StickyNoticeSave
{
public uint Id { get; init; }
public string DefName { get; init; } = "";
public byte Severity { get; init; }
public bool Pause { get; init; }
public uint TtlMs { get; init; }
public uint PersonId { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -193,6 +212,7 @@ internal sealed class SchoolStore
SpeechRules = save.SpeechRules,
Owner = save.Owner,
PortraitSettings = save.PortraitSettings,
Notices = save.Notices,
});
}
catch (Exception ex)
@@ -242,6 +262,7 @@ internal sealed class SchoolStore
SpeechRules = save.SpeechRules,
Owner = save.Owner,
PortraitSettings = save.PortraitSettings,
Notices = save.Notices,
};
}
@@ -1,7 +1,9 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation;
namespace HSchool.Server.Game;
@@ -36,6 +38,7 @@ internal sealed partial class SchoolWorker
SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school);
SendPresence(open.Client, school);
SendStickyNotices(open.Client);
break;
case WorkerCommand.Close close:
@@ -48,6 +51,11 @@ internal sealed partial class SchoolWorker
break;
case WorkerCommand.SetRunning setRunning:
if (setRunning.Running && _notices.HasPausing)
{
break;
}
school.Clock.IsRunning = setRunning.Running;
dirty = true;
break;
@@ -61,8 +69,36 @@ internal sealed partial class SchoolWorker
ApplySkip(school);
break;
case WorkerCommand.DismissNotice:
// Info is not stored; pausing dismiss is phase 65.
case WorkerCommand.DismissNotice dismiss:
if (_notices.TryDismiss(dismiss.Id))
{
Persist();
}
break;
case WorkerCommand.PostNotice post:
{
EventDef? def = null;
if (school.Catalog is not null)
{
school.Catalog.Events.TryGetValue(post.DefName, out def);
}
if (def is null || def.Abstract)
{
post.Result.TrySetResult(null);
break;
}
post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonId));
break;
}
case WorkerCommand.DismissNoticeHttp dismissHttp:
_notices.TryDismiss(dismissHttp.Id);
Persist();
dismissHttp.Result.TrySetResult();
break;
case WorkerCommand.Dump dump:
@@ -184,6 +220,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.Dump dump:
dump.Result.TrySetResult(null);
break;
case WorkerCommand.PostNotice post:
post.Result.TrySetResult(null);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetResult();
break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break;
@@ -227,6 +269,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.Dump dump:
dump.Result.TrySetException(exception);
break;
case WorkerCommand.PostNotice post:
post.Result.TrySetException(exception);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetException(exception);
break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception);
break;
@@ -380,4 +428,34 @@ internal sealed partial class SchoolWorker
ApplyTable(school, next, broadcast: true);
return TimetableOutcome.Ok(next);
}
private PostedNotice? TryEmitNotice(School school, EventDef def, uint personId)
{
if (!_notices.TryPost(def, personId, out var message))
{
return null;
}
BroadcastNotice(message);
if (def.Pause)
{
school.Clock.IsRunning = false;
PublishSnapshot();
Persist();
BroadcastClock();
}
return new PostedNotice(message.Id, message.DefName, message.Pause);
}
private void SendStickyNotices(GameClient client)
{
foreach (var notice in _notices.Sticky)
{
var message = notice.ToMessage();
var frame = new byte[ProtocolCodec.NoticeSize(message)];
var length = ProtocolCodec.WriteNotice(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
}
}
@@ -210,6 +210,7 @@ internal sealed partial class SchoolWorker
SpeechRules = school.SpeechRules,
Owner = _owner,
PortraitSettings = _portraitSettings,
Notices = _notices.ToSave(),
});
}
catch (Exception ex)
+7 -9
View File
@@ -95,6 +95,11 @@ internal sealed partial class SchoolWorker
_school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
if (_notices.HasPausing)
{
school.Clock.IsRunning = false;
}
PublishSnapshot();
if (_isNew)
@@ -505,19 +510,12 @@ internal sealed partial class SchoolWorker
{
foreach (var def in catalog.Events.Values)
{
if (def.Abstract
|| !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal)
|| !def.Severity.Equals(EventSeverities.Info, StringComparison.Ordinal))
if (def.Abstract || !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal))
{
continue;
}
BroadcastNotice(new ServerNoticeMessage(
++_nextNoticeId,
def.DefName,
NoticeSeverity.Info,
def.Pause,
(uint)Math.Max(0, def.TtlMs)));
TryEmitNotice(school, def, personId: 0);
}
}
}
+4 -2
View File
@@ -53,7 +53,7 @@ internal sealed partial class SchoolWorker
private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot;
private int _presenceAge;
private uint _nextNoticeId;
private readonly NoticeBoard _notices;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
@@ -78,6 +78,7 @@ internal sealed partial class SchoolWorker
SchoolSpeechRules? savedSpeechRules,
string? owner,
SwarmUiConfigFile? portraitSettings,
IReadOnlyList<StickyNoticeSave>? savedNotices,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -110,7 +111,8 @@ internal sealed partial class SchoolWorker
_mods = mods;
_onFailed = onFailed;
_logger = logger;
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? [], createSeed ?? 0, _owner);
_notices = new NoticeBoard(options.MaxStickyNotices, savedNotices);
_snapshot = new SchoolState(id, name, time, running && !_notices.HasPausing, (byte)speedIndex, modIds ?? [], createSeed ?? 0, _owner);
}
public int Id => _id;
+10
View File
@@ -24,6 +24,14 @@ internal abstract record WorkerCommand
internal sealed record DismissNotice(uint Id) : WorkerCommand;
internal sealed record DismissNoticeHttp(uint Id, TaskCompletionSource Result) : WorkerCommand;
/// <summary>Test/dev post onto the school board. Looks up an EventDef by name.</summary>
internal sealed record PostNotice(
string DefName,
uint PersonId,
TaskCompletionSource<PostedNotice?> Result) : WorkerCommand;
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
internal sealed record GetPerson(
@@ -81,3 +89,5 @@ internal abstract record WorkerCommand
string? PendingStaff,
TaskCompletionSource<SpeechRulesOutcome> Result) : WorkerCommand;
}
internal sealed record PostedNotice(uint Id, string DefName, bool Pause);
+5 -1
View File
@@ -182,7 +182,11 @@ internal sealed class GameSocketHandler(
case MessageType.ClientDismissNotice:
var dismiss = ProtocolCodec.ReadDismissNotice(frame);
commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id));
if (TryNormalizedUser(client, out var dismissUser))
{
commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id, dismissUser));
}
break;
default:
+1
View File
@@ -37,6 +37,7 @@ builder.Services
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.")
.Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.")
.Validate(options => options.MaxStickyNotices is > 0 and <= 32, "Simulation:MaxStickyNotices must be between 1 and 32.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
@@ -64,6 +64,11 @@ public sealed class SimulationOptions
/// </summary>
public int MaxDecisionsPerTick { get; set; } = 64;
/// <summary>
/// Unclosed warning/error notices kept in the save. The next one is not posted.
/// </summary>
public int MaxStickyNotices { get; set; } = 8;
/// <summary>
/// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day.
/// </summary>