Implement people management features by adding API endpoints for retrieving school rosters and individual person cards. Enhance the UI to support a people browser with filtering and pagination capabilities. Update localization strings for improved user experience and ensure robust handling of person data. Revise documentation to reflect new API functionalities and update tests to validate the new features.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
@@ -42,4 +43,11 @@ internal abstract record GameCommand
|
||||
/// it to drop the school instead of removing itself.
|
||||
/// </summary>
|
||||
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
|
||||
|
||||
/// <summary>One person's card, including live needs. Completes on that school's worker thread.</summary>
|
||||
internal sealed record GetPerson(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : GameCommand;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -52,6 +54,23 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Menu-style read of one school's published roster and frozen catalog. Does not post to the
|
||||
/// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks.
|
||||
/// </summary>
|
||||
public PublishedSchoolPeople? FindPeople(int schoolId)
|
||||
{
|
||||
foreach (var worker in Volatile.Read(ref _publishedWorkers))
|
||||
{
|
||||
if (worker.Id == schoolId)
|
||||
{
|
||||
return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.CatalogSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task ReloadFromDiskAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -148,6 +167,19 @@ internal sealed class GameLoopService(
|
||||
case GameCommand.WorkerFailed failed:
|
||||
HandleWorkerFailed(failed.SchoolId);
|
||||
break;
|
||||
|
||||
case GameCommand.GetPerson getPerson:
|
||||
HandleGetPerson(getPerson);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGetPerson(GameCommand.GetPerson command)
|
||||
{
|
||||
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
||||
|| !worker.Post(new WorkerCommand.GetPerson(command.PersonId, command.Locale, command.Result)))
|
||||
{
|
||||
command.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,3 +580,5 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, DefCatalog? Catalog);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a person card on the school's worker thread so live need values come from the World.
|
||||
/// </summary>
|
||||
internal static class PersonCardReader
|
||||
{
|
||||
private static readonly QueryDescription IdentityAndNeeds =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||
|
||||
public static PersonCardResponse? Read(School school, string personId, string locale)
|
||||
{
|
||||
var roster = school.Roster;
|
||||
if (roster is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
|
||||
if (person is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var catalog = school.Catalog;
|
||||
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
int? year = null;
|
||||
string? letter = null;
|
||||
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
|
||||
{
|
||||
year = schoolClass.Year;
|
||||
letter = schoolClass.Letter;
|
||||
}
|
||||
|
||||
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
||||
return new PersonCardResponse(
|
||||
person.Id,
|
||||
person.Name.Full,
|
||||
person.Name.Surname,
|
||||
person.Name.Given,
|
||||
person.Name.Patronymic,
|
||||
person.Female,
|
||||
person.AgeOn(school.Clock.Time),
|
||||
person.BirthDate,
|
||||
PeopleListMapper.RolesOf(person),
|
||||
year,
|
||||
letter,
|
||||
person.Position,
|
||||
PeopleListMapper.PositionLabel(catalog, locale, person.Position),
|
||||
Body(person, catalog, locale),
|
||||
Skills(person, catalog, locale),
|
||||
Traits(person, catalog, locale),
|
||||
Needs(needs, catalog, locale),
|
||||
Family(roster, person));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
||||
{
|
||||
Dictionary<string, float>? found = null;
|
||||
world.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds needs) =>
|
||||
{
|
||||
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
found = new Dictionary<string, float>(needs.Values, StringComparer.Ordinal);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var rows = new List<LabeledStatResponse>();
|
||||
if (catalog is not null)
|
||||
{
|
||||
foreach (var def in catalog.BodyAttributes.Values)
|
||||
{
|
||||
if (def.Abstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number))
|
||||
{
|
||||
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString()));
|
||||
}
|
||||
else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice))
|
||||
{
|
||||
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice)));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (id, number) in person.Numbers)
|
||||
{
|
||||
rows.Add(new LabeledStatResponse(id, id, number.ToString()));
|
||||
}
|
||||
|
||||
foreach (var (id, choice) in person.Choices)
|
||||
{
|
||||
rows.Add(new LabeledStatResponse(id, id, choice));
|
||||
}
|
||||
}
|
||||
|
||||
if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build)
|
||||
&& rows.TrueForAll(row => row.Id != BodyBuilds.Attribute))
|
||||
{
|
||||
var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute;
|
||||
var value = catalog?.Text(locale, build) ?? build;
|
||||
rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LabeledStatResponse> Skills(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
if (catalog is null)
|
||||
{
|
||||
return person.Skills
|
||||
.Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString()))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var rows = new List<LabeledStatResponse>();
|
||||
foreach (var def in catalog.Skills.Values)
|
||||
{
|
||||
if (def.Abstract || !person.Skills.TryGetValue(def.DefName, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString()));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var rows = new List<DefLabelResponse>(person.Traits.Count);
|
||||
foreach (var id in person.Traits)
|
||||
{
|
||||
var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def)
|
||||
? catalog.Label(locale, def)
|
||||
: id;
|
||||
rows.Add(new DefLabelResponse(id, label));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NeedStatResponse> Needs(
|
||||
IReadOnlyDictionary<string, float> values,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
if (catalog is null)
|
||||
{
|
||||
return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray();
|
||||
}
|
||||
|
||||
var rows = new List<NeedStatResponse>();
|
||||
foreach (var def in catalog.Needs.Values)
|
||||
{
|
||||
if (def.Abstract || !values.TryGetValue(def.DefName, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static PersonFamilyResponse Family(Roster roster, Person person)
|
||||
{
|
||||
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
|
||||
if (family is null)
|
||||
{
|
||||
return new PersonFamilyResponse([], [], [], []);
|
||||
}
|
||||
|
||||
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
|
||||
var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal);
|
||||
var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal);
|
||||
return new PersonFamilyResponse(
|
||||
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
|
||||
inParents ? Relatives(family.ChildIds, people, except: person.Id) : [],
|
||||
inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [],
|
||||
inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PersonRelResponse> Relatives(
|
||||
IReadOnlyList<string> ids,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
string except)
|
||||
{
|
||||
var rows = new List<PersonRelResponse>();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Channels;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
|
||||
@@ -41,6 +42,7 @@ internal sealed class SchoolWorker
|
||||
|
||||
private SchoolState _snapshot;
|
||||
private Roster? _rosterSnapshot;
|
||||
private DefCatalog? _catalogSnapshot;
|
||||
private School? _school;
|
||||
private Task? _run;
|
||||
private bool _persistOnStop = true;
|
||||
@@ -94,6 +96,9 @@ internal sealed class SchoolWorker
|
||||
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||
|
||||
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
|
||||
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_run = Task.Factory.StartNew(
|
||||
@@ -103,12 +108,15 @@ internal sealed class SchoolWorker
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public void Post(WorkerCommand command)
|
||||
public bool Post(WorkerCommand command)
|
||||
{
|
||||
if (!_mailbox.Writer.TryWrite(command))
|
||||
if (_mailbox.Writer.TryWrite(command))
|
||||
{
|
||||
_logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task StopAsync(bool persist)
|
||||
@@ -185,6 +193,7 @@ internal sealed class SchoolWorker
|
||||
}
|
||||
|
||||
var catalog = _mods.LoadCatalog(packIds, _logger);
|
||||
Volatile.Write(ref _catalogSnapshot, catalog);
|
||||
var map = _mods.LoadMap(packIds, _savedMap);
|
||||
try
|
||||
{
|
||||
@@ -320,6 +329,14 @@ internal sealed class SchoolWorker
|
||||
var school = _school;
|
||||
if (school is null)
|
||||
{
|
||||
while (_mailbox.Reader.TryRead(out var orphan))
|
||||
{
|
||||
if (orphan is WorkerCommand.GetPerson getPerson)
|
||||
{
|
||||
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -357,10 +374,23 @@ internal sealed class SchoolWorker
|
||||
school.Clock.SpeedIndex = setSpeed.SpeedIndex;
|
||||
dirty = true;
|
||||
break;
|
||||
|
||||
case WorkerCommand.GetPerson getPerson:
|
||||
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
||||
getPerson.Result.TrySetResult(
|
||||
card is null
|
||||
? new PersonCardResult(null, PersonLookupError.UnknownPerson)
|
||||
: new PersonCardResult(card, PersonLookupError.None));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (command is WorkerCommand.GetPerson failed)
|
||||
{
|
||||
failed.Result.TrySetException(ex);
|
||||
}
|
||||
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Command {Command} failed for school {SchoolId}; the school keeps running.",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Net;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
@@ -15,4 +16,9 @@ internal abstract record WorkerCommand
|
||||
internal sealed record SetRunning(bool Running) : WorkerCommand;
|
||||
|
||||
internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand;
|
||||
|
||||
internal sealed record GetPerson(
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user