Split the person card into overview, clothes, carry and now tabs.

Today's history is a paged HTTP log on the worker, not on the card or in people.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 04:00:38 +03:00
co-authored by Cursor
parent 69106cb48b
commit 6be4fa75ab
27 changed files with 1551 additions and 109 deletions
+16 -2
View File
@@ -12,6 +12,16 @@ internal enum PersonLookupError
internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error);
internal sealed record PersonLogResult(PersonLogResponse? Page, PersonLookupError Error);
internal sealed record PersonLogResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonLogEntryResponse> Entries);
internal sealed record PersonLogEntryResponse(DateTime Time, string Type, string Label, string? ThingDef);
internal sealed record PeopleListResponse(
int Total,
int Page,
@@ -65,14 +75,18 @@ internal sealed record PersonCardResponse(
IReadOnlyList<WornItemResponse> Worn,
IReadOnlyList<CarriedItemResponse> Carried,
float CarryMass,
float CarryCapacity);
float CarryCapacity,
bool HasLocker,
int HomeCount);
internal sealed record WornItemResponse(
string DefName,
string Label,
string? Color,
string? ColorLabel,
IReadOnlyList<DefLabelResponse> Layers);
IReadOnlyList<DefLabelResponse> Layers,
float Condition,
string? ConditionLabel);
internal sealed record CarriedItemResponse(
string DefName,
+96 -1
View File
@@ -7,7 +7,7 @@ namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. People list and staffing read published
/// snapshots; the person card, hire and subject changes go through the school's mailbox.
/// snapshots; the person card, the personal log, hire and subject changes go through the school's mailbox.
/// The timetable is a published snapshot; pin/unpin go through the mailbox.
/// </summary>
internal static class SchoolEndpoints
@@ -194,6 +194,41 @@ internal static class SchoolEndpoints
})
.WithName("GetSchoolPerson");
schools.MapGet("/{id:int}/people/{personId}/log", async (
int id,
string personId,
string? q,
string? sort,
string? dir,
int? page,
int? pageSize,
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.");
}
if (!TryParsePersonLogQuery(q, sort, dir, page, pageSize, out var query, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.GetPersonLog(id, personId, ParseLocale(lang), query, NewCompletion<PersonLogResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Page is not null => Results.Ok(outcome.Page),
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("GetSchoolPersonLog");
schools.MapGet("/{id:int}/staffing", (
int id,
string? lang,
@@ -396,6 +431,66 @@ internal static class SchoolEndpoints
return true;
}
private static bool TryParsePersonLogQuery(
string? search,
string? sort,
string? dir,
int? page,
int? pageSize,
out PersonLogQuery query,
out string error)
{
query = default;
error = string.Empty;
if (search is { Length: > 128 })
{
error = "q must be at most 128 characters.";
return false;
}
if (!string.IsNullOrWhiteSpace(sort) && !sort.Equals("time", StringComparison.OrdinalIgnoreCase))
{
error = "sort must be time.";
return false;
}
var descending = true;
if (!string.IsNullOrWhiteSpace(dir))
{
if (dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
{
descending = false;
}
else if (!dir.Equals("desc", 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 ?? PersonLogBrowser.DefaultPageSize;
if (parsedPageSize < 1 || parsedPageSize > PersonLogBrowser.MaxPageSize)
{
error = $"pageSize must be between 1 and {PersonLogBrowser.MaxPageSize}.";
return false;
}
query = new PersonLogQuery(
string.IsNullOrWhiteSpace(search) ? null : search.Trim(),
descending,
parsedPage,
parsedPageSize);
return true;
}
private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) =>
StaffingMapper.From(
published.Roster,
+8
View File
@@ -59,6 +59,14 @@ internal abstract record GameCommand
string Locale,
TaskCompletionSource<PersonCardResult> Result) : GameCommand;
/// <summary>Today's history for one person. Completes on that school's worker; not a snapshot.</summary>
internal sealed record GetPersonLog(
int SchoolId,
string PersonId,
string Locale,
PersonLogQuery Query,
TaskCompletionSource<PersonLogResult> Result) : GameCommand;
internal sealed record HireStaff(
int SchoolId,
string PersonId,
@@ -187,6 +187,10 @@ internal sealed class GameLoopService(
HandleGetPerson(getPerson);
break;
case GameCommand.GetPersonLog getLog:
HandleGetPersonLog(getLog);
break;
case GameCommand.HireStaff hire:
HandleStaffing(
hire.SchoolId,
@@ -242,6 +246,15 @@ internal sealed class GameLoopService(
}
}
private void HandleGetPersonLog(GameCommand.GetPersonLog command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.GetPersonLog(command.PersonId, command.Locale, command.Query, command.Result)))
{
command.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
}
}
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+6 -2
View File
@@ -85,7 +85,9 @@ internal static class PersonCardReader
Worn(person, catalog, locale),
Carried(person, catalog, locale),
catalog is null ? 0f : CarryMass.Held(catalog, person.Items),
Capacity(person, skills, catalog));
Capacity(person, skills, catalog),
person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)));
}
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -288,7 +290,9 @@ internal static class PersonCardReader
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
layers));
layers,
item.Condition,
ConditionLabel: null));
}
return rows;
@@ -0,0 +1,30 @@
using HSchool.Server.Api;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>Pages today's log on the worker thread. The list is not a published snapshot.</summary>
internal static class PersonLogReader
{
public static PersonLogResponse? Read(School school, string personId, PersonLogQuery query, string locale)
{
var roster = school.Roster;
if (roster is null)
{
return null;
}
var known = roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal))
|| (school.Applicants?.Applicants.Any(applicant => applicant.Person.Id.Equals(personId, StringComparison.Ordinal)) ?? false);
if (!known)
{
return null;
}
var page = PersonLogBrowser.Apply(school.DayLog, personId, query, school.Catalog, locale);
var entries = page.Entries
.Select(row => new PersonLogEntryResponse(row.Time, row.Type, row.Label, row.ThingDef))
.ToArray();
return new PersonLogResponse(page.Total, page.Page, page.PageSize, entries);
}
}
+14
View File
@@ -429,6 +429,14 @@ internal sealed class SchoolWorker
: new PersonCardResult(card, PersonLookupError.None));
break;
case WorkerCommand.GetPersonLog getLog:
var log = PersonLogReader.Read(school, getLog.PersonId, getLog.Query, getLog.Locale);
getLog.Result.TrySetResult(
log is null
? new PersonLogResult(null, PersonLookupError.UnknownPerson)
: new PersonLogResult(log, PersonLookupError.None));
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
break;
@@ -485,6 +493,9 @@ internal sealed class SchoolWorker
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(Staffing.UnknownSchool());
break;
@@ -513,6 +524,9 @@ internal sealed class SchoolWorker
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception);
break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetException(exception);
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetException(exception);
break;
+7
View File
@@ -1,6 +1,7 @@
using HSchool.People;
using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation;
namespace HSchool.Server.Game;
@@ -27,6 +28,12 @@ internal abstract record WorkerCommand
string Locale,
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
internal sealed record GetPersonLog(
string PersonId,
string Locale,
PersonLogQuery Query,
TaskCompletionSource<PersonLogResult> Result) : WorkerCommand;
internal sealed record HireStaff(
string PersonId,
string Position,
@@ -159,5 +159,8 @@
"WinterBreak": "Winter break",
"SpringBreak": "Spring break",
"SummerBreak": "Summer break",
"ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}",
"core": "Core",
}
@@ -159,5 +159,8 @@
"WinterBreak": "Зимние каникулы",
"SpringBreak": "Весенние каникулы",
"SummerBreak": "Летние каникулы",
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}",
"core": "Базовая игра",
}