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
@@ -9,6 +9,10 @@
<PackageReference Include="Arch" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.Simulation.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" />
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
+179
View File
@@ -0,0 +1,179 @@
using System.Globalization;
using Arch.Core;
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Log row types the person card pages. Captions for morning issue are ready even while phase 34
/// is the one that appends that row.
/// </summary>
public static class PersonLogTypes
{
public const string ActionStarted = "action-started";
public const string ActionEnded = "action-ended";
public const string ApparelReplaced = "apparel-replaced";
public const string ApparelChanged = "apparel-changed";
}
/// <summary>
/// One thing that happened to a person today. Stored on the school worker, not in <c>people.json</c>.
/// The day boundary is six in the morning — the same hour skip lands on.
/// </summary>
public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, string? ThingDef)
{
public string Caption(DefCatalog catalog, string locale)
{
ArgumentNullException.ThrowIfNull(catalog);
if (ThingDef is null)
{
return Type;
}
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
{
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{
var name = catalog.Actions.TryGetValue(ThingDef, out var action)
? catalog.Label(locale, action)
: ThingDef;
var key = Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
? "ActionStarted"
: "ActionEnded";
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name);
}
return Type;
}
}
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
public sealed record PersonLogPage(int Total, int Page, int PageSize, IReadOnlyList<PersonLogRow> Entries);
public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef);
/// <summary>Filters, sorts and pages today's log for one person. Does not touch a World.</summary>
public static class PersonLogBrowser
{
public const int DefaultPageSize = 20;
public const int MaxPageSize = 100;
public static PersonLogPage Apply(
IReadOnlyList<PersonLogEvent> log,
string personId,
PersonLogQuery query,
DefCatalog? catalog,
string locale)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize);
var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search.Trim();
var rows = new List<PersonLogRow>();
foreach (var row in log)
{
if (!row.PersonId.Equals(personId, StringComparison.Ordinal))
{
continue;
}
var label = catalog is null ? row.Type : row.Caption(catalog, locale);
if (search is not null
&& !label.Contains(search, StringComparison.OrdinalIgnoreCase)
&& !row.Type.Contains(search, StringComparison.OrdinalIgnoreCase)
&& (row.ThingDef is null || !row.ThingDef.Contains(search, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
rows.Add(new PersonLogRow(row.Time, row.Type, label, row.ThingDef));
}
rows.Sort((left, right) =>
{
var byTime = left.Time.CompareTo(right.Time);
return query.Descending ? -byTime : byTime;
});
var skip = (page - 1) * pageSize;
var slice = skip >= rows.Count ? Array.Empty<PersonLogRow>() : rows.Skip(skip).Take(pageSize).ToArray();
return new PersonLogPage(rows.Count, page, pageSize, slice);
}
}
/// <summary>Records action start/end and clears yesterday at six in the morning.</summary>
internal static class PersonDayLog
{
private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
internal static bool CrossedDayStart(DateTime before, DateTime after)
{
if (after <= before)
{
return false;
}
var cursor = DateTime.SpecifyKind(before.Date, DateTimeKind.Utc).Add(SchoolDay.DayStart.ToTimeSpan());
if (before >= cursor)
{
cursor = cursor.AddDays(1);
}
return after >= cursor;
}
internal static void Sync(School school)
{
var world = school.World;
var seen = new HashSet<string>(StringComparer.Ordinal);
var query = IdentityAndActivity;
world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
seen.Add(identity.Id);
Record(school, identity.Id, activity.ActionId);
});
foreach (var id in school.LoggedActivity.Keys.ToArray())
{
if (!seen.Contains(id))
{
Record(school, id, current: null);
school.LoggedActivity.Remove(id);
}
}
}
private static void Record(School school, string personId, string? current)
{
school.LoggedActivity.TryGetValue(personId, out var last);
if (string.Equals(last, current, StringComparison.Ordinal))
{
school.LoggedActivity[personId] = current;
return;
}
if (last is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionEnded, last));
}
if (current is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionStarted, current));
}
school.LoggedActivity[personId] = current;
}
}
+32 -1
View File
@@ -16,6 +16,7 @@ public sealed class School : IDisposable
public const int MaxNameLength = 40;
private bool _disposed;
private readonly List<PersonLogEvent> _dayLog = [];
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
{
@@ -111,6 +112,21 @@ public sealed class School : IDisposable
public int PendingDecisionCount => DecisionQueue.Count;
/// <summary>
/// Today's history for the person card. Cleared at six in the morning. Not written to disk.
/// </summary>
public IReadOnlyList<PersonLogEvent> DayLog => _dayLog;
internal Dictionary<string, string?> LoggedActivity { get; } = new(StringComparer.Ordinal);
internal void ResetDayLog()
{
_dayLog.Clear();
LoggedActivity.Clear();
}
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
public void QueueDecision(string personId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -137,6 +153,7 @@ public sealed class School : IDisposable
LastDecisionSlot = null;
Plans.Clear();
DecisionQueue.Clear();
LoggedActivity.Clear();
}
public bool TryStartAction(string personId, string actionId)
@@ -144,7 +161,13 @@ public sealed class School : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return ActivitySystem.TryStart(this, personId, actionId);
var started = ActivitySystem.TryStart(this, personId, actionId);
if (started)
{
PersonDayLog.Sync(this);
}
return started;
}
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
@@ -203,6 +226,7 @@ public sealed class School : IDisposable
var before = Clock.Time;
Clock.JumpTo(next.Value);
ResetDayLog();
var peopleChanged = TryYearlyIntake(before, next.Value);
peopleChanged |= TryApplicantRefresh();
PlanDay = null;
@@ -257,6 +281,11 @@ public sealed class School : IDisposable
var peopleChanged = false;
if (gameMinutes > 0)
{
if (PersonDayLog.CrossedDayStart(before, Clock.Time))
{
ResetDayLog();
}
peopleChanged = TryYearlyIntake(before, Clock.Time);
peopleChanged |= TryApplicantRefresh();
if (peopleChanged)
@@ -271,6 +300,8 @@ public sealed class School : IDisposable
PresenceSystem.Enqueue(this, id);
}
PersonDayLog.Sync(this);
if (Catalog is not null)
{
var below = PresenceSystem.BelowThreshold(this);