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
@@ -26,6 +26,7 @@ describe('t', () => {
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
expect(t('staffUncoveredShort', { label: 'Начальные классы', min: 1, max: 4, n: 3 }))
.toBe('Начальные классы (1–4) — 3 short');
expect(t('peopleCarryMass', { held: '1.2', cap: '14' })).toBe('1.2 / 14 kg');
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
.toBe('Кабинет 204 (Математика · 5Б)');
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
+6
View File
@@ -128,6 +128,9 @@ const ru = {
peopleSkills: 'Навыки',
peopleTraits: 'Черты',
peopleNeeds: 'Нужды',
peopleApparel: 'Одежда',
peopleCarry: 'Ноша',
peopleCarryMass: '{held} / {cap} кг',
peopleFamily: 'Семья',
peopleParents: 'Родители',
peopleChildren: 'Дети',
@@ -345,6 +348,9 @@ const en: Messages = {
peopleSkills: 'Skills',
peopleTraits: 'Traits',
peopleNeeds: 'Needs',
peopleApparel: 'Clothes',
peopleCarry: 'Carried',
peopleCarryMass: '{held} / {cap} kg',
peopleFamily: 'Family',
peopleParents: 'Parents',
peopleChildren: 'Children',
+22
View File
@@ -291,6 +291,28 @@ export interface PersonCard {
readonly siblings: readonly PersonRel[];
readonly partners: readonly PersonRel[];
};
readonly worn: readonly WornItem[];
readonly carried: readonly CarriedItem[];
readonly carryMass: number;
readonly carryCapacity: number;
}
export interface WornItem {
readonly defName: string;
readonly label: string;
readonly color: string | null;
readonly colorLabel: string | null;
readonly layers: readonly DefLabel[];
}
export interface CarriedItem {
readonly defName: string;
readonly label: string;
readonly color: string | null;
readonly colorLabel: string | null;
readonly subject: string | null;
readonly subjectLabel: string | null;
readonly mass: number;
}
export async function fetchPeople(schoolId: number, query: PeopleQuery, lang: string): Promise<PeoplePage> {
+5
View File
@@ -644,6 +644,11 @@ body {
font-size: 12px;
}
.people__carry-mass {
margin: 0 0 4px;
font-size: 12px;
}
.people__pair {
display: flex;
align-items: baseline;
@@ -55,6 +55,10 @@ function personCard(): PersonCard {
activity: null,
activityLabel: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [],
carried: [],
carryMass: 0,
carryCapacity: 0,
};
}
@@ -0,0 +1,77 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import type { PersonCard } from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { renderPersonCard } from './personCard.ts';
const initial = getLocale();
afterEach(() => setLocale(initial));
function card(overrides: Partial<PersonCard> = {}): PersonCard {
return {
id: 'f0.c0',
fullName: 'Иванова Мария',
surname: 'Иванова',
given: 'Мария',
patronymic: '',
female: true,
age: 12,
birthDate: '2000-03-14',
roles: ['student'],
classYear: 5,
classLetter: 'А',
classId: 'class-1',
position: null,
positionLabel: null,
body: [],
skills: [],
traits: [],
needs: [],
activity: null,
activityLabel: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [
{
defName: 'Shirt',
label: 'Рубашка',
color: 'White',
colorLabel: 'Белый',
layers: [{ defName: 'Top', label: 'Верх' }],
},
],
carried: [
{
defName: 'Textbook',
label: 'Учебник',
color: null,
colorLabel: null,
subject: 'Mathematics',
subjectLabel: 'Математика',
mass: 0.4,
},
],
carryMass: 1.2,
carryCapacity: 14,
...overrides,
};
}
describe('renderPersonCard', () => {
it('shows worn layers with colour and carried mass in the same scroll', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {});
expect(root.textContent).toContain(t('peopleApparel'));
expect(root.textContent).toContain('Верх');
expect(root.textContent).toContain('Рубашка · Белый');
expect(root.textContent).toContain(t('peopleCarry'));
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain('Учебник · Математика');
expect(root.querySelector('.people__tabs')).toBeNull();
});
});
+54
View File
@@ -47,6 +47,8 @@ export function renderPersonCard(
appendPairs(parent, t('peopleSkills'), card.skills);
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(parent, t('peopleNeeds'), card.needs);
appendApparel(parent, card.worn);
appendCarry(parent, card);
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
@@ -112,6 +114,58 @@ function appendPairs(
parent.append(section(title), grid);
}
function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
);
}
parent.append(section(t('peopleApparel')), grid);
}
function appendCarry(parent: HTMLElement, card: PersonCard): void {
const mass = el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
});
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(section(t('peopleCarry')), mass);
if (card.carried.length > 0) {
parent.append(grid);
}
}
function formatMass(value: number): string {
return String(Math.round(value * 100) / 100);
}
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) {
return;
+5
View File
@@ -609,6 +609,11 @@ public sealed class CatalogLoader
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown skirtLength '{thing.SkirtLength}'.");
}
if (thing.CarryChance is < 0f or > 1f)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' carryChance must be 01.");
}
var layers = new HashSet<string>(StringComparer.Ordinal);
foreach (var layer in thing.Layers)
{
+6
View File
@@ -104,6 +104,12 @@ public sealed class ThingDef : Def
/// <summary>PE kit. Worn for that lesson; not a second wardrobe of everyday clothes.</summary>
public bool Pe { get; init; }
/// <summary>
/// Chance the dress generator issues this portable non-apparel item. 0 — not rolled (textbooks
/// are granted per subject instead). Apparel ignores this and fills layers.
/// </summary>
public float CarryChance { get; init; }
}
public sealed class PositionDef : Def;
+13
View File
@@ -513,6 +513,19 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' goal weights cannot be negative.");
}
if (behavior.CarryMassBase < 0f
|| behavior.CarryMassPerStrength < 0f
|| behavior.CarryMassPerEndurance < 0f
|| behavior.CarryMassPerHauling < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' carry mass cannot be negative.");
}
if (behavior.OptionalApparelChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' optionalApparelChance must be 01.");
}
// A pack may invert this on purpose — lunch then pulls the class out of the lesson.
// The warning is the catch; refusing to load would make the number unmoddable.
if (behavior.LunchWeight > behavior.DutyLessonWeight)
+12
View File
@@ -250,6 +250,18 @@ public sealed class BehaviorDef : Def
/// pulls anybody out of a lesson.
/// </summary>
public float LunchWeight { get; init; } = 6f;
/// <summary>Kilograms a person can carry at strength/endurance/hauling 0, before the per-skill terms.</summary>
public float CarryMassBase { get; init; } = 5f;
public float CarryMassPerStrength { get; init; } = 0.08f;
public float CarryMassPerEndurance { get; init; } = 0.04f;
public float CarryMassPerHauling { get; init; } = 0.08f;
/// <summary>Chance each optional layer (sweater, coat, hat, accessory) is worn at generation.</summary>
public float OptionalApparelChance { get; init; } = 0.4f;
}
public enum BodyAttributeKind
+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
+21 -1
View File
@@ -61,7 +61,27 @@ internal sealed record PersonCardResponse(
IReadOnlyList<NeedStatResponse> Needs,
string? Activity,
string? ActivityLabel,
PersonFamilyResponse Family);
PersonFamilyResponse Family,
IReadOnlyList<WornItemResponse> Worn,
IReadOnlyList<CarriedItemResponse> Carried,
float CarryMass,
float CarryCapacity);
internal sealed record WornItemResponse(
string DefName,
string Label,
string? Color,
string? ColorLabel,
IReadOnlyList<DefLabelResponse> Layers);
internal sealed record CarriedItemResponse(
string DefName,
string Label,
string? Color,
string? ColorLabel,
string? Subject,
string? SubjectLabel,
float Mass);
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
+98 -1
View File
@@ -81,7 +81,11 @@ internal static class PersonCardReader
Needs(needs, catalog, locale),
activityId,
activityLabel,
Family(roster, person));
Family(roster, person),
Worn(person, catalog, locale),
Carried(person, catalog, locale),
catalog is null ? 0f : CarryMass.Held(catalog, person.Items),
Capacity(person, skills, catalog));
}
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -260,6 +264,99 @@ internal static class PersonCardReader
return rows;
}
private static IReadOnlyList<WornItemResponse> Worn(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<WornItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
IReadOnlyList<string> layerIds = [];
if (catalog is not null && catalog.Things.TryGetValue(item.Def, out var def))
{
layerIds = def.Layers;
}
var layers = layerIds
.Select(layer => new DefLabelResponse(layer, catalog?.Text(locale, layer) ?? layer))
.ToArray();
rows.Add(new WornItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
layers));
}
return rows;
}
private static IReadOnlyList<CarriedItemResponse> Carried(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<CarriedItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
continue;
}
var mass = catalog is not null && catalog.Things.TryGetValue(item.Def, out var def) ? def.Mass : 0f;
string? subjectLabel = null;
if (item.Subject is not null)
{
subjectLabel = catalog is not null && catalog.Subjects.TryGetValue(item.Subject, out var subject)
? catalog.Label(locale, subject)
: item.Subject;
}
rows.Add(new CarriedItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
item.Subject,
subjectLabel,
mass));
}
return rows;
}
private static string ThingLabel(DefCatalog? catalog, string locale, string defName)
{
if (catalog is not null && catalog.Things.TryGetValue(defName, out var def))
{
return catalog.Label(locale, def);
}
return defName;
}
private static string? ColorLabel(DefCatalog? catalog, string locale, string? color) =>
color is null ? null : catalog?.Text(locale, color) ?? color;
private static float Capacity(
Person person,
IReadOnlyDictionary<string, float>? live,
DefCatalog? catalog)
{
if (catalog is null)
{
return 0f;
}
if (live is not null)
{
return CarryMass.Capacity(catalog, live);
}
return CarryMass.Capacity(catalog, person.Skills);
}
private static PersonFamilyResponse Family(Roster roster, Person person)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
+7
View File
@@ -924,6 +924,13 @@ internal sealed class SchoolWorker
}
}
if (DressGenerator.NeedsDressing(roster, applicants))
{
roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time);
applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time);
generated = true;
}
if (!RosterFit.Matches(roster, demand))
{
throw new SchoolContentUnavailableException(
@@ -21,4 +21,11 @@
// beats walking on to the next room, below the lesson so it never pulls
// anybody out of class. Raise it past dutyLessonWeight and they will.
"lunchWeight": 6,
// Bag limit: base kg + per point of Strength, Endurance and Hauling.
"carryMassBase": 5,
"carryMassPerStrength": 0.08,
"carryMassPerEndurance": 0.04,
"carryMassPerHauling": 0.08,
// Sweater, coat, hat, accessory — each rolled separately at generation.
"optionalApparelChance": 0.4,
}
@@ -30,4 +30,10 @@
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
{
"defName": "Hauling",
"always": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 12 },
},
]
@@ -1,6 +1,6 @@
[
{ "defName": "SchoolBag", "portable": true, "mass": 0.8 },
{ "defName": "SchoolBag", "portable": true, "mass": 0.8, "age": { "min": 6, "max": 18 }, "carryChance": 1 },
{ "defName": "Textbook", "portable": true, "mass": 0.4 },
{ "defName": "Phone", "portable": true, "mass": 0.15, "colors": ["Black", "White", "Gray"] },
{ "defName": "Bottle", "portable": true, "mass": 0.3, "colors": ["Blue", "Green", "White"] },
{ "defName": "Phone", "portable": true, "mass": 0.15, "colors": ["Black", "White", "Gray"], "age": { "min": 11 }, "carryChance": 0.45 },
{ "defName": "Bottle", "portable": true, "mass": 0.3, "colors": ["Blue", "Green", "White"], "age": { "min": 6 }, "carryChance": 0.7 },
]
@@ -115,6 +115,7 @@
"Agility": "Agility",
"Strength": "Strength",
"Endurance": "Endurance",
"Hauling": "Hauling",
"Diligent": "Diligent",
"AbsentMinded": "Absent-minded",
"Bully": "Bully",
@@ -115,6 +115,7 @@
"Agility": "Ловкость",
"Strength": "Сила",
"Endurance": "Выносливость",
"Hauling": "Переноска",
"Diligent": "Усидчивый",
"AbsentMinded": "Рассеянный",
"Bully": "Задира",