Pause the school on warning notices until the owner dismisses them.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user