using System.Globalization; using Arch.Core; using HSchool.Content; namespace HSchool.Simulation; /// /// Log row types the person card pages. Captions for morning issue are ready even while phase 34 /// is the one that appends that row. /// public static class PersonLogTypes { public const string ActionStarted = "action-started"; public const string ActionEnded = "action-ended"; public const string TalkEnded = "talk-ended"; public const string ApparelReplaced = "apparel-replaced"; public const string ApparelChanged = "apparel-changed"; public const string LessonNoTeacher = "lesson-no-teacher"; public const string LessonCold = "lesson-cold"; } /// /// One thing that happened to a person today. Stored on the school worker, not in people.json. /// The day boundary is six in the morning — the same hour skip lands on. /// 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.ApparelChanged, StringComparison.Ordinal)) { var name = catalog.Things.TryGetValue(ThingDef, out var def) ? catalog.Label(locale, def) : ThingDef; return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelChanged"), 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); } if (Type.Equals(PersonLogTypes.LessonNoTeacher, StringComparison.Ordinal) || Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal)) { var name = catalog.Subjects.TryGetValue(ThingDef, out var subject) ? catalog.Label(locale, subject) : ThingDef; var key = Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal) ? "LessonCold" : "LessonNoTeacher"; return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal)) { var topic = catalog.Topics.TryGetValue(ThingDef ?? "", out var def) ? catalog.Label(locale, def) : ThingDef ?? Type; return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic); } 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 Entries); public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef); /// Filters, sorts and pages today's log for one person. Does not touch a World. public static class PersonLogBrowser { public const int DefaultPageSize = 20; public const int MaxPageSize = 100; public static PersonLogPage Apply( IReadOnlyList 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(); 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() : rows.Skip(skip).Take(pageSize).ToArray(); return new PersonLogPage(rows.Count, page, pageSize, slice); } } /// Records action start/end and clears yesterday at six in the morning. internal static class PersonDayLog { private static readonly QueryDescription IdentityAndActivity = new QueryDescription().WithAll(); 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(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; } }