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
+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));
}
}