Update wire protocol to version 7 and enhance presence management features
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 15s

- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure.
- Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states.
- Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution.
- Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream.
- Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity.
- Updated documentation to reflect the new protocol features and changes in presence management.
- Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
Leonid Pershin
2026-08-19 17:00:53 +03:00
parent 4137400621
commit 3c54f981b7
31 changed files with 1025 additions and 373 deletions
+4
View File
@@ -73,6 +73,10 @@ internal sealed record PersonFamilyResponse(
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
internal sealed record DirectoryPersonResponse(string Id, string FullName);
internal static class PeopleListMapper
{
public static PeopleListResponse From(
+23
View File
@@ -134,6 +134,29 @@ internal static class SchoolEndpoints
})
.WithName("GetSchoolPeople");
schools.MapGet("/{id:int}/directory", (int id, string? lang, GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
_ = ParseLocale(lang);
var roster = published.Roster;
if (roster is null)
{
return Results.Ok(new DirectoryResponse([]));
}
var people = roster.People
.OrderBy(person => person.Id, StringComparer.Ordinal)
.Select(person => new DirectoryPersonResponse(person.Id, person.Name.Full))
.ToArray();
return Results.Ok(new DirectoryResponse(people));
})
.WithName("GetSchoolDirectory");
schools.MapGet("/{id:int}/people/{personId}", async (
int id,
string personId,
+3 -1
View File
@@ -7,7 +7,7 @@ namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a request or connection thread to the supervisor. Create, delete and
/// name suggestions stay here; open/close/running/speed are forwarded to the school's worker.
/// name suggestions stay here; open/close/running/speed/skip are forwarded to the school's worker.
/// </summary>
internal abstract record GameCommand
{
@@ -36,6 +36,8 @@ internal abstract record GameCommand
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
internal sealed record SkipEmpty(uint PlayerId) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
@@ -167,6 +167,10 @@ internal sealed class GameLoopService(
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
break;
case GameCommand.SkipEmpty skipEmpty:
RouteOpenSchool(skipEmpty.PlayerId, new WorkerCommand.SkipEmpty());
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
-93
View File
@@ -1,93 +0,0 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Overlays the current lesson onto map-tree nodes. Occupancy is derived from the timetable and
/// the clock; it is not stored on the map.
/// </summary>
internal static class MapOccupancy
{
public static void Apply(
MapSnapshotNode[] nodes,
School school,
int weekDays,
string locale)
{
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
{
return;
}
var occurring = TimetableClock.OccurringAt(
school.Timetable,
school.Catalog,
school.Clock.Time,
weekDays);
if (occurring.Count == 0)
{
return;
}
var catalog = school.Catalog;
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
var people = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var byRoom = occurring.ToDictionary(lesson => lesson.RoomId, StringComparer.Ordinal);
for (var i = 0; i < nodes.Length; i++)
{
if (!byRoom.TryGetValue(nodes[i].Id, out var lesson))
{
continue;
}
classes.TryGetValue(lesson.ClassId, out var schoolClass);
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
? catalog.Label(locale, subject)
: lesson.Subject;
var classLabel = schoolClass is null
? lesson.ClassId
: $"{schoolClass.Year}{schoolClass.Letter}";
nodes[i] = nodes[i] with
{
ActivitySubject = subjectLabel,
ActivityClass = classLabel,
Characters = NamesOf(lesson, schoolClass, people),
};
}
}
private static string[] NamesOf(
LessonPlacement lesson,
SchoolClass? schoolClass,
IReadOnlyDictionary<string, Person> people)
{
var names = new List<string>();
if (people.TryGetValue(lesson.TeacherId, out var teacher))
{
names.Add(teacher.Name.Full);
}
else if (lesson.TeacherId.Length > 0)
{
names.Add(lesson.TeacherId);
}
if (schoolClass is not null)
{
foreach (var pupilId in schoolClass.PupilIds)
{
if (people.TryGetValue(pupilId, out var pupil))
{
names.Add(pupil.Name.Full);
}
}
}
return names.Count > byte.MaxValue ? [.. names.Take(byte.MaxValue)] : [.. names];
}
}
+98
View File
@@ -0,0 +1,98 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Builds a presence frame from stored places plus the current lesson. Occupancy is state;
/// the lesson labels are still derived from the timetable so an empty classroom during a period
/// still shows what should be happening there.
/// </summary>
internal static class PresenceFrame
{
public static ServerPresenceMessage Build(School school, int weekDays, string locale)
{
var people = new List<PresencePerson>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var row in school.CapturePresence())
{
if (row.NodeId is null)
{
continue;
}
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
people.Add(new PresencePerson(
row.PersonId,
row.NodeId,
walking ? PresenceState.Walking : PresenceState.Here));
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
}
people.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
var activity = ActivityByRoom(school, weekDays, locale);
var nodeIds = counts.Keys
.Concat(activity.Keys)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal);
var nodes = new List<PresenceNode>();
foreach (var id in nodeIds)
{
var count = (ushort)Math.Min(counts.GetValueOrDefault(id), ushort.MaxValue);
var subject = "";
var classLabel = "";
if (activity.TryGetValue(id, out var labels))
{
subject = labels.Subject;
classLabel = labels.Class;
}
nodes.Add(new PresenceNode(id, count, subject, classLabel));
}
return new ServerPresenceMessage(school.Id, nodes, people);
}
private static Dictionary<string, (string Subject, string Class)> ActivityByRoom(
School school,
int weekDays,
string locale)
{
var labels = new Dictionary<string, (string Subject, string Class)>(StringComparer.Ordinal);
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
{
return labels;
}
var occurring = TimetableClock.OccurringAt(
school.Timetable,
school.Catalog,
school.Clock.Time,
weekDays);
if (occurring.Count == 0)
{
return labels;
}
var catalog = school.Catalog;
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
foreach (var lesson in occurring)
{
classes.TryGetValue(lesson.ClassId, out var schoolClass);
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
? catalog.Label(locale, subject)
: lesson.Subject;
var classLabel = schoolClass is null
? lesson.ClassId
: $"{schoolClass.Year}{schoolClass.Letter}";
labels[lesson.RoomId] = (subjectLabel, classLabel);
}
return labels;
}
}
+60 -27
View File
@@ -48,7 +48,7 @@ internal sealed class SchoolWorker
private DefCatalog? _catalogSnapshot;
private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot;
private OccupancyKey _occupancyKey;
private int _presenceAge;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
@@ -308,7 +308,7 @@ internal sealed class SchoolWorker
{
PublishSnapshot();
BroadcastClock();
MaybeBroadcastOccupancy(school);
MaybeBroadcastPresence(school);
}
FlushSettings();
@@ -381,6 +381,7 @@ internal sealed class SchoolWorker
open.Client.OpenSchoolId = _id;
SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school);
SendPresence(open.Client, school);
break;
case WorkerCommand.Close close:
@@ -402,6 +403,10 @@ internal sealed class SchoolWorker
dirty = true;
break;
case WorkerCommand.SkipEmpty:
ApplySkip(school);
break;
case WorkerCommand.GetPerson getPerson:
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
getPerson.Result.TrySetResult(
@@ -613,7 +618,6 @@ internal sealed class SchoolWorker
PersistTimetable(school);
}
RememberOccupancy(school);
return;
}
}
@@ -770,11 +774,7 @@ internal sealed class SchoolWorker
PublishSnapshot();
if (broadcast)
{
MaybeBroadcastOccupancy(school, force: true);
}
else
{
RememberOccupancy(school);
BroadcastPresence();
}
}
@@ -799,35 +799,41 @@ internal sealed class SchoolWorker
}
}
private void RememberOccupancy(School school)
private void MaybeBroadcastPresence(School school)
{
if (school.Catalog is not null)
_presenceAge++;
var interval = Math.Max(1, _options.TickRate / 2);
if (_presenceAge < interval)
{
_occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
return;
}
_presenceAge = 0;
BroadcastPresence();
}
private void MaybeBroadcastOccupancy(School school, bool force = false)
private void ApplySkip(School school)
{
if (school.Catalog is null)
var result = school.TrySkipEmpty();
if (!result.Succeeded)
{
return;
}
var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
if (!force && key == _occupancyKey)
if (result.PeopleChanged)
{
return;
}
_occupancyKey = key;
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
PersistPeople();
if (school.TimetableDirty)
{
SendMapSnapshot(client, school);
RebuildTimetable(school);
}
}
PublishSnapshot();
Persist();
BroadcastClock();
BroadcastPresence();
_presenceAge = 0;
}
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
@@ -987,8 +993,6 @@ internal sealed class SchoolWorker
node.Positions);
}
MapOccupancy.Apply(nodes, school, _options.SchoolWeekDays, locale);
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
var message = new ServerMapSnapshotMessage(school.Id, nodes);
@@ -997,15 +1001,44 @@ internal sealed class SchoolWorker
client.TrySendReliable(frame.AsMemory(0, length));
}
private static void BroadcastClockTo(GameClient client, School school)
private void BroadcastClockTo(GameClient client, School school)
{
var skip = school.PeekSkipEmpty();
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
school.Id,
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex));
(byte)school.Clock.SpeedIndex,
skip.Allowed,
skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0));
client.TrySend(frame.AsMemory(0, length));
}
private void BroadcastPresence()
{
var school = _school;
if (school is null)
{
return;
}
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
SendPresence(client, school);
}
}
}
private void SendPresence(GameClient client, School school)
{
var locale = ProtocolConstants.CatalogLocale(client.Locale);
var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale);
var frame = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
}
+2
View File
@@ -18,6 +18,8 @@ internal abstract record WorkerCommand
internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand;
internal sealed record SkipEmpty : WorkerCommand;
internal sealed record GetPerson(
string PersonId,
string Locale,
@@ -153,6 +153,10 @@ internal sealed class GameSocketHandler(
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
break;
case MessageType.ClientSkipEmpty:
commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId));
break;
default:
logger.LogDebug(
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",