Dress people at generation from a separate apparel stream.

Wardrobes live on the person in people.json: everyday layers, textbooks per parallel, and chance-based bags from catalog data. Hauling and the carry cap are defs, not constants. The golden roster gains an items column; later family members shift because Hauling consumes extra appearance rolls. Old saves without items are dressed on load.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 03:24:54 +03:00
co-authored by Cursor
parent d8f4958167
commit 37501c506a
43 changed files with 8883 additions and 1305 deletions
+63
View File
@@ -0,0 +1,63 @@
namespace HSchool.People;
/// <summary>
/// How much a person may carry in the bag. The curve lives on <see cref="BehaviorDef"/>;
/// these fallbacks match vanilla so a pack without the new fields still dresses people.
/// Worn mass does not count in this slice, and overload does not slow walking.
/// </summary>
public static class CarryMass
{
public const string Strength = "Strength";
public const string Endurance = "Endurance";
public const string Hauling = "Hauling";
public const float DefaultBase = 5f;
public const float DefaultPerStrength = 0.08f;
public const float DefaultPerEndurance = 0.04f;
public const float DefaultPerHauling = 0.08f;
public static float Capacity(DefCatalog catalog, IReadOnlyDictionary<string, int> skills) =>
Capacity(catalog, Skill(skills, Strength), Skill(skills, Endurance), Skill(skills, Hauling));
public static float Capacity(DefCatalog catalog, IReadOnlyDictionary<string, float> skills) =>
Capacity(
catalog,
Skill(skills, Strength),
Skill(skills, Endurance),
Skill(skills, Hauling));
public static float Capacity(DefCatalog catalog, float strength, float endurance, float hauling)
{
var rules = catalog.BehaviorRules;
var value = (rules?.CarryMassBase ?? DefaultBase)
+ (strength * (rules?.CarryMassPerStrength ?? DefaultPerStrength))
+ (endurance * (rules?.CarryMassPerEndurance ?? DefaultPerEndurance))
+ (hauling * (rules?.CarryMassPerHauling ?? DefaultPerHauling));
return MathF.Round(MathF.Max(value, 0f), 2);
}
public static float Held(DefCatalog catalog, IEnumerable<InventoryItem> items)
{
var sum = 0f;
foreach (var item in items)
{
if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
continue;
}
if (catalog.Things.TryGetValue(item.Def, out var def))
{
sum += def.Mass;
}
}
return MathF.Round(sum, 2);
}
private static float Skill(IReadOnlyDictionary<string, int> skills, string name) =>
skills.TryGetValue(name, out var value) ? value : 0f;
private static float Skill(IReadOnlyDictionary<string, float> skills, string name) =>
skills.TryGetValue(name, out var value) ? value : 0f;
}
+414
View File
@@ -0,0 +1,414 @@
namespace HSchool.People;
/// <summary>
/// Fills a person's wardrobe from a stream that does not touch looks or skills. Same person id
/// and school seed always yield the same items, colours and places.
/// </summary>
public static class DressGenerator
{
public static Person Dress(DefCatalog catalog, Person person, int schoolSeed, int? pupilYear)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(person);
var age = person.AgeOn(RosterGenerator.DefaultAsOf);
return Dress(catalog, person, schoolSeed, pupilYear, age);
}
public static Person Dress(
DefCatalog catalog,
Person person,
int schoolSeed,
int? pupilYear,
int age)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(person);
var rng = new Random(Seed.Mix(schoolSeed, person.Id, dayNumber: 0, Seed.ApparelSalt));
var items = new List<InventoryItem>();
var occupied = new HashSet<string>(StringComparer.Ordinal);
var chance = catalog.BehaviorRules?.OptionalApparelChance ?? 0.4f;
foreach (var layer in RequiredLayers)
{
TryWear(catalog, person, age, rng, layer, occupied, items);
}
foreach (var layer in OptionalLayers)
{
if (rng.NextDouble() < chance)
{
TryWear(catalog, person, age, rng, layer, occupied, items);
}
}
if (!occupied.Contains(ApparelLayers.Outer))
{
TryStashOuter(catalog, person, age, rng, items);
}
if (person.IsStudent && HasPhysicalEducation(catalog, pupilYear))
{
GrantPeKit(catalog, person, age, rng, items);
}
var capacity = CarryMass.Capacity(catalog, person.Skills);
var held = 0f;
GrantChanceCarry(catalog, person, age, rng, items, ref held, capacity);
if (person.IsStudent && pupilYear is { } year)
{
GrantTextbooks(catalog, year, rng, items, ref held, capacity);
}
return person with { Items = items };
}
public static Person EnsureDressed(
DefCatalog catalog,
Person person,
int schoolSeed,
int? pupilYear,
int age)
{
if (person.Items.Count > 0)
{
return person;
}
return Dress(catalog, person, schoolSeed, pupilYear, age);
}
public static Roster EnsureRoster(DefCatalog catalog, Roster roster, int schoolSeed, DateTime asOf)
{
var years = roster.Classes.ToDictionary(
schoolClass => schoolClass.Id,
schoolClass => schoolClass.Year,
StringComparer.Ordinal);
var people = new Person[roster.People.Count];
for (var i = 0; i < roster.People.Count; i++)
{
var person = roster.People[i];
int? year = person.ClassId is { } classId && years.TryGetValue(classId, out var value)
? value
: null;
people[i] = EnsureDressed(catalog, person, schoolSeed, year, person.AgeOn(asOf));
}
return new Roster(people, roster.Families, roster.Classes);
}
public static ApplicantPool EnsurePool(
DefCatalog catalog,
ApplicantPool pool,
Roster roster,
int schoolSeed,
DateTime asOf)
{
var rosterPeople = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var applicants = new Applicant[pool.Applicants.Count];
for (var i = 0; i < pool.Applicants.Count; i++)
{
var applicant = pool.Applicants[i];
if (rosterPeople.TryGetValue(applicant.Person.Id, out var member))
{
applicants[i] = applicant with { Person = member };
continue;
}
applicants[i] = applicant with
{
Person = EnsureDressed(
catalog,
applicant.Person,
schoolSeed,
pupilYear: null,
applicant.Person.AgeOn(asOf)),
};
}
return pool with { Applicants = applicants };
}
public static bool NeedsDressing(Roster roster, ApplicantPool? pool)
{
foreach (var person in roster.People)
{
if (person.Items.Count == 0)
{
return true;
}
}
if (pool is null)
{
return false;
}
foreach (var applicant in pool.Applicants)
{
if (applicant.Person.Items.Count == 0)
{
return true;
}
}
return false;
}
/// <summary>
/// Adds textbooks for subjects the pupil just became old enough for. Existing clothes stay.
/// </summary>
public static Person EnsureYearTextbooks(DefCatalog catalog, Person person, int year)
{
if (!person.IsStudent)
{
return person;
}
var items = person.Items.ToList();
var held = CarryMass.Held(catalog, items);
var capacity = CarryMass.Capacity(catalog, person.Skills);
var rng = new Random(0);
GrantTextbooks(catalog, year, rng, items, ref held, capacity);
return person with { Items = items };
}
private static readonly string[] RequiredLayers =
[ApparelLayers.Underwear, ApparelLayers.Socks, ApparelLayers.Bottom, ApparelLayers.Top, ApparelLayers.Shoes];
private static readonly string[] OptionalLayers =
[ApparelLayers.OverTop, ApparelLayers.Outer, ApparelLayers.Head, ApparelLayers.Accessory];
private static void TryWear(
DefCatalog catalog,
Person person,
int age,
Random rng,
string layer,
HashSet<string> occupied,
List<InventoryItem> items)
{
if (occupied.Contains(layer))
{
return;
}
var candidates = Apparel(catalog, person.Female, age, everyday: true)
.Where(def => def.Layers.Contains(layer, StringComparer.Ordinal)
&& def.Layers.All(candidate => !occupied.Contains(candidate)))
.ToList();
if (candidates.Count == 0)
{
return;
}
var pick = candidates[rng.Next(candidates.Count)];
var color = PickColor(catalog, pick, rng, worn: true);
items.Add(new InventoryItem(pick.DefName, color, Condition: 1, ItemLocations.Worn));
foreach (var taken in pick.Layers)
{
occupied.Add(taken);
}
}
private static void TryStashOuter(
DefCatalog catalog,
Person person,
int age,
Random rng,
List<InventoryItem> items)
{
var candidates = Apparel(catalog, person.Female, age, everyday: true)
.Where(def => def.Layers.Contains(ApparelLayers.Outer, StringComparer.Ordinal))
.ToList();
if (candidates.Count == 0)
{
return;
}
var pick = candidates[rng.Next(candidates.Count)];
var color = PickColor(catalog, pick, rng, worn: false);
items.Add(new InventoryItem(pick.DefName, color, Condition: 1, ItemLocations.Home));
}
private static void GrantPeKit(
DefCatalog catalog,
Person person,
int age,
Random rng,
List<InventoryItem> items)
{
foreach (var def in Apparel(catalog, person.Female, age, everyday: false).Where(candidate => candidate.Pe))
{
var color = PickColor(catalog, def, rng, worn: false);
items.Add(new InventoryItem(def.DefName, color, Condition: 1, ItemLocations.Home));
}
}
private static void GrantChanceCarry(
DefCatalog catalog,
Person person,
int age,
Random rng,
List<InventoryItem> items,
ref float held,
float capacity)
{
foreach (var def in catalog.Things.Values
.Where(thing => !thing.Abstract
&& thing.Portable
&& thing.Layers.Count == 0
&& thing.CarryChance > 0)
.OrderBy(thing => thing.DefName, StringComparer.Ordinal))
{
if (!AgeFits(def, age) || !SexFits(def, person.Female))
{
continue;
}
if (rng.NextDouble() >= def.CarryChance)
{
continue;
}
var color = PickColor(catalog, def, rng, worn: false);
Place(catalog, items, def, color, subject: null, ref held, capacity);
}
}
private static void GrantTextbooks(
DefCatalog catalog,
int year,
Random rng,
List<InventoryItem> items,
ref float held,
float capacity)
{
var textbook = catalog.Things.Values
.Where(thing => !thing.Abstract
&& thing.Portable
&& thing.Layers.Count == 0
&& thing.CarryChance <= 0)
.OrderBy(thing => thing.DefName, StringComparer.Ordinal)
.FirstOrDefault();
if (textbook is null)
{
return;
}
var have = items
.Where(item => item.Def.Equals(textbook.DefName, StringComparison.Ordinal) && item.Subject is not null)
.Select(item => item.Subject!)
.ToHashSet(StringComparer.Ordinal);
foreach (var subject in catalog.Subjects.Values
.Where(def => !def.Abstract && year >= def.Grades.Min && year <= def.Grades.Max)
.OrderBy(def => def.DefName, StringComparer.Ordinal))
{
if (!have.Add(subject.DefName))
{
continue;
}
var color = PickColor(catalog, textbook, rng, worn: false);
Place(catalog, items, textbook, color, subject.DefName, ref held, capacity);
}
}
private static void Place(
DefCatalog catalog,
List<InventoryItem> items,
ThingDef def,
string? color,
string? subject,
ref float held,
float capacity)
{
var location = held + def.Mass <= capacity ? ItemLocations.Bag : ItemLocations.Home;
items.Add(new InventoryItem(def.DefName, color, Condition: 1, location, subject));
if (location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
held = MathF.Round(held + def.Mass, 2);
}
}
private static IEnumerable<ThingDef> Apparel(DefCatalog catalog, bool female, int age, bool everyday)
{
foreach (var def in catalog.Things.Values.OrderBy(thing => thing.DefName, StringComparer.Ordinal))
{
if (def.Abstract || def.Layers.Count == 0 || !AgeFits(def, age) || !SexFits(def, female))
{
continue;
}
if (everyday)
{
if (def.Pe)
{
continue;
}
if (def.SkirtLength is not null
&& def.SkirtLength.Equals(SkirtLengths.Short, StringComparison.Ordinal))
{
continue;
}
}
yield return def;
}
}
private static string? PickColor(DefCatalog catalog, ThingDef def, Random rng, bool worn)
{
if (def.Colors.Count == 0)
{
return null;
}
var pool = def.Colors.ToList();
if (worn)
{
var quiet = def.Colors
.Where(id => catalog.Colors.TryGetValue(id, out var color)
&& !color.Tags.Contains(ColorTags.Bright, StringComparer.Ordinal))
.ToList();
if (quiet.Count > 0)
{
pool = quiet;
}
}
pool.Sort(StringComparer.Ordinal);
return pool[rng.Next(pool.Count)];
}
private static bool AgeFits(ThingDef def, int age) =>
def.Age is not { } range || (age >= range.Min && age <= range.Max);
private static bool SexFits(ThingDef def, bool female)
{
if (def.Sex is null)
{
return true;
}
var want = female ? "female" : "male";
return def.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
}
private static bool HasPhysicalEducation(DefCatalog catalog, int? pupilYear)
{
if (pupilYear is not { } year)
{
return false;
}
return catalog.Subjects.Values.Any(subject =>
!subject.Abstract
&& subject.DefName.Equals("PhysicalEducation", StringComparison.Ordinal)
&& year >= subject.Grades.Min
&& year <= subject.Grades.Max);
}
}
+92 -66
View File
@@ -78,40 +78,50 @@ internal static class FamilyFactory
{
parentIds.Add(fatherId);
members.Add(
RollAdult(
DressGenerator.Dress(
catalog,
names,
rng,
fatherId,
familyId,
female: false,
fatherBirth,
asOf,
surname,
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0,
nativeLanguage));
RollAdult(
catalog,
names,
rng,
fatherId,
familyId,
female: false,
fatherBirth,
asOf,
surname,
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0,
nativeLanguage),
schoolSeed,
pupilYear: null,
SchoolYears.AgeYears(fatherBirth, asOf)));
}
if (hasMother)
{
parentIds.Add(motherId);
members.Add(
RollAdult(
DressGenerator.Dress(
catalog,
names,
rng,
motherId,
familyId,
female: true,
motherBirth,
asOf,
surname,
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0,
nativeLanguage));
RollAdult(
catalog,
names,
rng,
motherId,
familyId,
female: true,
motherBirth,
asOf,
surname,
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0,
nativeLanguage),
schoolSeed,
pupilYear: null,
SchoolYears.AgeYears(motherBirth, asOf)));
}
var childIds = new List<string>(childDrafts.Length);
@@ -121,17 +131,22 @@ internal static class FamilyFactory
var id = $"{familyId}.c{i}";
childIds.Add(id);
members.Add(
RollChild(
DressGenerator.Dress(
catalog,
names,
rng,
id,
familyId,
draft,
asOf,
surname,
fatherGiven.Form,
nativeLanguage));
RollChild(
catalog,
names,
rng,
id,
familyId,
draft,
asOf,
surname,
fatherGiven.Form,
nativeLanguage),
schoolSeed,
draft.Seat.Year,
SchoolYears.AgeYears(draft.Birth, asOf)));
}
var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male);
@@ -151,6 +166,7 @@ internal static class FamilyFactory
DateTime yearStart,
DateTime asOf,
int childIndex,
int schoolSeed,
string? nativeLanguage = null)
{
var fatherGiven = FatherGivenOf(family, members);
@@ -184,23 +200,28 @@ internal static class FamilyFactory
surnameCases,
PatronymicTable(patronymic, female));
return new Person
{
Id = $"{family.Id}.c{childIndex}",
FamilyId = family.Id,
Female = female,
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
Name = name,
IsStudent = true,
IsStaff = false,
IsParent = false,
ClassId = seat.ClassId,
Numbers = numbers,
Choices = choices,
Skills = skills,
Traits = traits,
Needs = needs,
};
return DressGenerator.Dress(
catalog,
new Person
{
Id = $"{family.Id}.c{childIndex}",
FamilyId = family.Id,
Female = female,
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
Name = name,
IsStudent = true,
IsStaff = false,
IsParent = false,
ClassId = seat.ClassId,
Numbers = numbers,
Choices = choices,
Skills = skills,
Traits = traits,
Needs = needs,
},
schoolSeed,
seat.Year,
age);
}
/// <summary>
@@ -226,20 +247,25 @@ internal static class FamilyFactory
var birth = asOf.AddYears(-(24 + rng.Next(38))).AddDays(-rng.Next(365));
var id = $"{familyId}.p0";
var person = RollAdult(
var person = DressGenerator.Dress(
catalog,
names,
rng,
id,
familyId,
female,
birth,
asOf,
surname,
given,
NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule),
isParent: false,
nativeLanguage);
RollAdult(
catalog,
names,
rng,
id,
familyId,
female,
birth,
asOf,
surname,
given,
NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule),
isParent: false,
nativeLanguage),
schoolSeed,
pupilYear: null,
SchoolYears.AgeYears(birth, asOf));
return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person);
}
+21
View File
@@ -0,0 +1,21 @@
namespace HSchool.People;
/// <summary>Where an instance lives. One place at a time — worn, bag, locker or home.</summary>
public static class ItemLocations
{
public const string Worn = "worn";
public const string Bag = "bag";
public const string Locker = "locker";
public const string Home = "home";
}
/// <summary>
/// One thing on a person. <see cref="Subject"/> is set only on a textbook instance — the def is
/// shared. Condition is 1 at birth; wear is a later phase.
/// </summary>
public sealed record InventoryItem(
string Def,
string? Color,
float Condition,
string Location,
string? Subject = null);
+3
View File
@@ -46,6 +46,9 @@ public sealed record Person
/// <summary>Hourly rate frozen at hire. Null until the person is staff.</summary>
public float? HourlyWageAsk { get; init; }
/// <summary>Worn, bag, locker and home. Empty on rosters written before clothes existed.</summary>
public IReadOnlyList<InventoryItem> Items { get; init; } = [];
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
+1
View File
@@ -16,6 +16,7 @@ public static class Seed
public const int SkillGrantSalt = 8;
public const int NativeLanguageSalt = 9;
public const int ClimatePresetSalt = 10;
public const int ApparelSalt = 11;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
+17 -14
View File
@@ -175,19 +175,22 @@ public static class YearlyIntake
}
var rng = new Random(Seed.Mix(schoolSeed, person.Id, when.Year, Seed.SkillGrantSalt));
people[id] = person with
{
Skills = PersonSampler.EnsurePupilYear(
catalog,
names,
rng,
person.AgeOn(when),
person.Choices,
person.Traits,
person.Skills,
year,
nativeLanguage),
};
people[id] = DressGenerator.EnsureYearTextbooks(
catalog,
person with
{
Skills = PersonSampler.EnsurePupilYear(
catalog,
names,
rng,
person.AgeOn(when),
person.Choices,
person.Traits,
person.Skills,
year,
nativeLanguage),
},
year);
}
}
@@ -236,7 +239,7 @@ public static class YearlyIntake
var childIndex = family.NextChildIndex;
var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex));
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex, nativeLanguage);
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex, schoolSeed, nativeLanguage);
people[child.Id] = child;
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
families[familyAt] = family with