Update wire protocol to version 7 and enhance presence management features
- 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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
Reference in New Issue
Block a user