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:
@@ -0,0 +1,171 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal enum PersonLookupError
|
||||
{
|
||||
None,
|
||||
UnknownSchool,
|
||||
UnknownPerson,
|
||||
}
|
||||
|
||||
internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error);
|
||||
|
||||
internal sealed record PeopleListResponse(
|
||||
int Total,
|
||||
int Page,
|
||||
int PageSize,
|
||||
IReadOnlyList<PersonListItemResponse> People,
|
||||
PeopleFilterOptionsResponse Filters);
|
||||
|
||||
internal sealed record PeopleFilterOptionsResponse(
|
||||
IReadOnlyList<int> Years,
|
||||
IReadOnlyList<string> Letters,
|
||||
IReadOnlyList<DefLabelResponse> Positions);
|
||||
|
||||
internal sealed record DefLabelResponse(string DefName, string Label);
|
||||
|
||||
internal sealed record PersonListItemResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
string Surname,
|
||||
string Given,
|
||||
string Patronymic,
|
||||
bool Female,
|
||||
int Age,
|
||||
IReadOnlyList<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel);
|
||||
|
||||
internal sealed record PersonCardResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
string Surname,
|
||||
string Given,
|
||||
string Patronymic,
|
||||
bool Female,
|
||||
int Age,
|
||||
DateTime BirthDate,
|
||||
IReadOnlyList<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel,
|
||||
IReadOnlyList<LabeledStatResponse> Body,
|
||||
IReadOnlyList<LabeledStatResponse> Skills,
|
||||
IReadOnlyList<DefLabelResponse> Traits,
|
||||
IReadOnlyList<NeedStatResponse> Needs,
|
||||
PersonFamilyResponse Family);
|
||||
|
||||
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
|
||||
|
||||
internal sealed record NeedStatResponse(string Id, string Label, float Value);
|
||||
|
||||
internal sealed record PersonFamilyResponse(
|
||||
IReadOnlyList<PersonRelResponse> Parents,
|
||||
IReadOnlyList<PersonRelResponse> Children,
|
||||
IReadOnlyList<PersonRelResponse> Siblings,
|
||||
IReadOnlyList<PersonRelResponse> Partners);
|
||||
|
||||
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||
|
||||
internal static class PeopleListMapper
|
||||
{
|
||||
public static PeopleListResponse From(
|
||||
Roster roster,
|
||||
RosterPage page,
|
||||
DateTime asOf,
|
||||
DefCatalog? catalog,
|
||||
string locale,
|
||||
RosterQuery query)
|
||||
{
|
||||
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
var people = page.People
|
||||
.Select(person => Item(person, classes, asOf, catalog, locale))
|
||||
.ToArray();
|
||||
return new PeopleListResponse(page.Total, query.Page, query.PageSize, people, FilterOptions(roster, catalog, locale));
|
||||
}
|
||||
|
||||
private static PersonListItemResponse Item(
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, SchoolClass> classes,
|
||||
DateTime asOf,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
int? year = null;
|
||||
string? letter = null;
|
||||
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
|
||||
{
|
||||
year = schoolClass.Year;
|
||||
letter = schoolClass.Letter;
|
||||
}
|
||||
|
||||
return new PersonListItemResponse(
|
||||
person.Id,
|
||||
person.Name.Full,
|
||||
person.Name.Surname,
|
||||
person.Name.Given,
|
||||
person.Name.Patronymic,
|
||||
person.Female,
|
||||
person.AgeOn(asOf),
|
||||
RolesOf(person),
|
||||
year,
|
||||
letter,
|
||||
person.Position,
|
||||
PositionLabel(catalog, locale, person.Position));
|
||||
}
|
||||
|
||||
internal static string[] RolesOf(Person person)
|
||||
{
|
||||
var roles = new List<string>(3);
|
||||
if (person.IsStudent)
|
||||
{
|
||||
roles.Add(PersonRoles.Student);
|
||||
}
|
||||
|
||||
if (person.IsStaff)
|
||||
{
|
||||
roles.Add(PersonRoles.Staff);
|
||||
}
|
||||
|
||||
if (person.IsParent)
|
||||
{
|
||||
roles.Add(PersonRoles.Parent);
|
||||
}
|
||||
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
internal static string? PositionLabel(DefCatalog? catalog, string locale, string? position)
|
||||
{
|
||||
if (position is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (catalog is not null && catalog.Positions.TryGetValue(position, out var def))
|
||||
{
|
||||
return catalog.Label(locale, def);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
private static PeopleFilterOptionsResponse FilterOptions(Roster roster, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var years = roster.Classes.Select(schoolClass => schoolClass.Year).Distinct().OrderBy(year => year).ToArray();
|
||||
var letters = roster.Classes.Select(schoolClass => schoolClass.Letter).Distinct().OrderBy(letter => letter, StringComparer.Ordinal).ToArray();
|
||||
var positions = roster.People
|
||||
.Select(person => person.Position)
|
||||
.Where(position => position is not null)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(position => PositionLabel(catalog, locale, position) ?? position, StringComparer.Ordinal)
|
||||
.Select(position => new DefLabelResponse(position!, PositionLabel(catalog, locale, position) ?? position!))
|
||||
.ToArray();
|
||||
return new PeopleFilterOptionsResponse(years, letters, positions);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
|
||||
/// the supervisor as a command and awaited, so each school stays on its own worker thread.
|
||||
/// The main menu talks to these: list, create, delete. The people list reads a published roster
|
||||
/// snapshot; the person card goes through the school's mailbox because needs are live.
|
||||
/// </summary>
|
||||
internal static class SchoolEndpoints
|
||||
{
|
||||
@@ -91,6 +92,70 @@ internal static class SchoolEndpoints
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("DeleteSchool");
|
||||
|
||||
schools.MapGet("/{id:int}/people", (
|
||||
int id,
|
||||
string? role,
|
||||
int? year,
|
||||
string? letter,
|
||||
string? position,
|
||||
string? sex,
|
||||
int? ageMin,
|
||||
int? ageMax,
|
||||
string? sort,
|
||||
string? dir,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
string? lang,
|
||||
GameLoopService loop) =>
|
||||
{
|
||||
var published = loop.FindPeople(id);
|
||||
if (published is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||
}
|
||||
|
||||
if (!TryParsePeopleQuery(role, year, letter, position, sex, ageMin, ageMax, sort, dir, page, pageSize, out var query, out var error))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
|
||||
}
|
||||
|
||||
var roster = published.Roster;
|
||||
if (roster is null)
|
||||
{
|
||||
return Results.Ok(new PeopleListResponse(0, query.Page, query.PageSize, [], new PeopleFilterOptionsResponse([], [], [])));
|
||||
}
|
||||
|
||||
var locale = ParseLocale(lang);
|
||||
var slice = RosterBrowser.Apply(roster, published.School.GameTime, query);
|
||||
return Results.Ok(PeopleListMapper.From(roster, slice, published.School.GameTime, published.Catalog, locale, query));
|
||||
})
|
||||
.WithName("GetSchoolPeople");
|
||||
|
||||
schools.MapGet("/{id:int}/people/{personId}", async (
|
||||
int id,
|
||||
string personId,
|
||||
string? lang,
|
||||
GameCommandQueue commands,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
|
||||
}
|
||||
|
||||
var command = new GameCommand.GetPerson(id, personId, ParseLocale(lang), NewCompletion<PersonCardResult>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return outcome.Error switch
|
||||
{
|
||||
PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card),
|
||||
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
||||
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
|
||||
};
|
||||
})
|
||||
.WithName("GetSchoolPerson");
|
||||
}
|
||||
|
||||
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
|
||||
@@ -102,6 +167,118 @@ internal static class SchoolEndpoints
|
||||
? SchoolNameLanguage.English
|
||||
: SchoolNameLanguage.Russian;
|
||||
|
||||
private static string ParseLocale(string? lang) =>
|
||||
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
|
||||
|
||||
private static bool TryParsePeopleQuery(
|
||||
string? role,
|
||||
int? year,
|
||||
string? letter,
|
||||
string? position,
|
||||
string? sex,
|
||||
int? ageMin,
|
||||
int? ageMax,
|
||||
string? sort,
|
||||
string? dir,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
out RosterQuery query,
|
||||
out string error)
|
||||
{
|
||||
query = default;
|
||||
error = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(role) && !HSchool.Content.PersonRoles.IsKnown(role))
|
||||
{
|
||||
error = "role must be student, staff or parent.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (year is < 1)
|
||||
{
|
||||
error = "year must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool? female = null;
|
||||
if (!string.IsNullOrWhiteSpace(sex))
|
||||
{
|
||||
if (sex.Equals("female", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
female = true;
|
||||
}
|
||||
else if (sex.Equals("male", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
female = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
error = "sex must be male or female.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (ageMin is < 0 || ageMax is < 0)
|
||||
{
|
||||
error = "ageMin and ageMax must be zero or greater.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ageMin is int min && ageMax is int max && min > max)
|
||||
{
|
||||
error = "ageMin cannot be greater than ageMax.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RosterBrowser.TryParseSort(sort, out var parsedSort))
|
||||
{
|
||||
error = "sort must be surname, age, year or position.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var descending = false;
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
if (dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descending = true;
|
||||
}
|
||||
else if (!dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = "dir must be asc or desc.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var parsedPage = page ?? 1;
|
||||
if (parsedPage < 1)
|
||||
{
|
||||
error = "page must be 1 or greater.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedPageSize = pageSize ?? RosterBrowser.DefaultPageSize;
|
||||
if (parsedPageSize < 1 || parsedPageSize > RosterBrowser.MaxPageSize)
|
||||
{
|
||||
error = $"pageSize must be between 1 and {RosterBrowser.MaxPageSize}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
query = new RosterQuery(
|
||||
string.IsNullOrWhiteSpace(role) ? null : role,
|
||||
year,
|
||||
string.IsNullOrWhiteSpace(letter) ? null : letter,
|
||||
string.IsNullOrWhiteSpace(position) ? null : position,
|
||||
female,
|
||||
ageMin,
|
||||
ageMax,
|
||||
parsedSort,
|
||||
descending,
|
||||
parsedPage,
|
||||
parsedPageSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
|
||||
@@ -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