Merge branch 'main' into phase/32-weather-warmth

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	src/HSchool.Simulation/School.cs
This commit is contained in:
Leonid Pershin
2026-08-20 04:03:42 +03:00
21 changed files with 848 additions and 29 deletions
+259
View File
@@ -0,0 +1,259 @@
using System.Globalization;
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>Log row types phase 36 will page. Captions are ready even while the card has no tab.</summary>
public static class PersonLogTypes
{
public const string ApparelReplaced = "apparel-replaced";
}
/// <summary>
/// One thing that happened to a person today. Stored on the 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 (!Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal) || ThingDef is null)
{
return Type;
}
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
}
/// <summary>Condition captions live in the catalog. The client draws what it is told.</summary>
public static class ApparelCondition
{
public static string BandId(BehaviorDef? rules, float condition)
{
var bands = rules?.ApparelConditionBands is { Count: > 0 } listed
? listed
: BehaviorDef.DefaultConditionBands;
ApparelConditionBand? best = null;
foreach (var band in bands)
{
if (condition >= band.Min && (best is null || band.Min > best.Min))
{
best = band;
}
}
return best?.Id ?? "ApparelConditionRags";
}
public static string Label(DefCatalog catalog, string locale, float condition) =>
catalog.Text(locale, BandId(catalog.BehaviorRules, condition));
}
/// <summary>
/// Worn apparel loses condition on campus from game time, then morning issues a fresh copy
/// of anything too ragged to leave home in. Bag and locker are left alone.
/// </summary>
internal static class ApparelWear
{
private static readonly QueryDescription Identities =
new QueryDescription().WithAll<PersonIdentity, Presence>();
public static bool Apply(School school, double gameMinutes, DateTime before)
{
var bandCrossed = Wear(school, gameMinutes);
if (!CrossedDayStart(before, school.Clock.Time))
{
return bandCrossed;
}
school.ResetDayLog();
if (school.Catalog is null || !SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays))
{
return bandCrossed;
}
return bandCrossed | ReplaceRags(school);
}
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;
}
private static bool Wear(School school, double gameMinutes)
{
if (gameMinutes <= 0 || school.Roster is null || school.Catalog?.BehaviorRules is null)
{
return false;
}
var rate = school.Catalog.BehaviorRules.ApparelWearPerHour;
if (rate <= 0)
{
return false;
}
var onCampus = OnCampusIds(school);
if (onCampus.Count == 0)
{
return false;
}
var drop = (float)(rate * (gameMinutes / 60d));
var crossed = false;
foreach (var person in school.Roster.People)
{
if (!onCampus.Contains(person.Id))
{
continue;
}
crossed |= WearPerson(school.Catalog, person, drop);
}
return crossed;
}
private static bool WearPerson(DefCatalog catalog, Person person, float drop)
{
var crossed = false;
for (var i = 0; i < person.Items.Count; i++)
{
var item = person.Items[i];
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)
|| !IsApparel(catalog, item.Def))
{
continue;
}
var next = Math.Clamp(item.Condition - drop, 0f, 1f);
if (next == item.Condition)
{
continue;
}
if (ApparelCondition.BandId(catalog.BehaviorRules, item.Condition)
!= ApparelCondition.BandId(catalog.BehaviorRules, next))
{
crossed = true;
}
SetItem(person, i, item with { Condition = next });
}
return crossed;
}
private static bool ReplaceRags(School school)
{
if (school.Roster is null || school.Catalog?.BehaviorRules is null)
{
return false;
}
var onCampus = OnCampusIds(school);
var threshold = school.Catalog.BehaviorRules.ApparelReplaceBelow;
var replaced = false;
foreach (var person in school.Roster.People)
{
if (onCampus.Contains(person.Id))
{
continue;
}
replaced |= ReplacePerson(school, person, threshold);
}
return replaced;
}
private static bool ReplacePerson(School school, Person person, float threshold)
{
var catalog = school.Catalog!;
var replaced = false;
for (var i = 0; i < person.Items.Count; i++)
{
var item = person.Items[i];
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)
|| item.Condition >= threshold
|| !IsApparel(catalog, item.Def))
{
continue;
}
var color = NearestColor(catalog, item.Def, item.Color);
SetItem(person, i, item with { Condition = 1f, Color = color });
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.ApparelReplaced,
item.Def));
replaced = true;
}
return replaced;
}
private static HashSet<string> OnCampusIds(School school)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
var world = school.World;
world.Query(in Identities, (ref PersonIdentity identity, ref Presence presence) =>
{
if (presence.IsOnCampus)
{
ids.Add(identity.Id);
}
});
return ids;
}
private static bool IsApparel(DefCatalog catalog, string defName) =>
catalog.Things.TryGetValue(defName, out var def) && def.Layers.Count > 0;
private static string? NearestColor(DefCatalog catalog, string defName, string? current)
{
if (!catalog.Things.TryGetValue(defName, out var def) || def.Colors.Count == 0)
{
return current;
}
if (current is not null
&& def.Colors.Contains(current, StringComparer.Ordinal))
{
return current;
}
return def.Colors[0];
}
private static void SetItem(Person person, int index, InventoryItem item)
{
if (person.Items is IList<InventoryItem> list && !list.IsReadOnly)
{
list[index] = item;
return;
}
throw new InvalidOperationException($"Cannot mutate items for {person.Id}.");
}
}
+14 -2
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)
{
@@ -114,6 +115,15 @@ public sealed class School : IDisposable
public int PendingDecisionCount => DecisionQueue.Count;
/// <summary>
/// Today's history for phase 36. Cleared at six in the morning. Not written to disk.
/// </summary>
public IReadOnlyList<PersonLogEvent> DayLog => _dayLog;
internal void ResetDayLog() => _dayLog.Clear();
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
public void QueueDecision(string personId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -212,6 +222,7 @@ public sealed class School : IDisposable
PlanDay = null;
LastDecisionSlot = null;
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before);
SyncWeather(force: true);
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
@@ -251,8 +262,8 @@ public sealed class School : IDisposable
}
}
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, then need decay.</summary>
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, then apparel wear.</summary>
/// <returns><see langword="true"/> when the roster, applicant pool or wardrobe changed this step.</returns>
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -285,6 +296,7 @@ public sealed class School : IDisposable
PresenceSystem.EnqueueNewlyUrgent(this, below);
PresenceSystem.DrainDecisions(this);
LessonLearningSystem.Apply(this, gameMinutes);
peopleChanged |= ApparelWear.Apply(this, gameMinutes, before);
}
}
else